mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
Auto-detect subnet for printer discovery
The Add Printer dialog previously required users to manually enter their network subnet for scanning (defaulting to 192.168.1.0/24). Now the backend detects available network interfaces and returns their subnets via the /discovery/info endpoint. The frontend auto-selects the first detected subnet and shows a dropdown when multiple subnets are available, falling back to a text input if none are detected.
This commit is contained in:
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
|
||||
|
||||
### Improved
|
||||
- **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
|
||||
- **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -442,7 +442,7 @@ services:
|
||||
network_mode: host
|
||||
```
|
||||
|
||||
> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy can discover printers via subnet scanning - enter your network range (e.g., `192.168.1.0/24`) in the Add Printer dialog.
|
||||
> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy auto-detects your network subnet and can discover printers via subnet scanning in the Add Printer dialog.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from backend.app.services.discovery import (
|
||||
is_running_in_docker,
|
||||
subnet_scanner,
|
||||
)
|
||||
from backend.app.services.network_utils import get_network_interfaces
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
||||
@@ -35,6 +36,7 @@ class DiscoveryInfo(BaseModel):
|
||||
is_docker: bool
|
||||
ssdp_running: bool
|
||||
scan_running: bool
|
||||
subnets: list[str] = []
|
||||
|
||||
|
||||
class SubnetScanRequest(BaseModel):
|
||||
@@ -67,10 +69,12 @@ async def get_discovery_info(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get discovery environment info (Docker detection, etc.)."""
|
||||
subnets = [iface["subnet"] for iface in get_network_interfaces()]
|
||||
return DiscoveryInfo(
|
||||
is_docker=is_running_in_docker(),
|
||||
ssdp_running=discovery_service.is_running,
|
||||
scan_running=subnet_scanner.is_running,
|
||||
subnets=subnets,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,9 +25,24 @@ class TestDiscoveryAPI:
|
||||
assert "is_docker" in data
|
||||
assert "ssdp_running" in data
|
||||
assert "scan_running" in data
|
||||
assert "subnets" in data
|
||||
assert isinstance(data["is_docker"], bool)
|
||||
assert isinstance(data["ssdp_running"], bool)
|
||||
assert isinstance(data["scan_running"], bool)
|
||||
assert isinstance(data["subnets"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_discovery_info_subnets_are_cidr(self, async_client: AsyncClient):
|
||||
"""Verify subnets are valid CIDR notation strings."""
|
||||
response = await async_client.get("/api/v1/discovery/info")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for subnet in data["subnets"]:
|
||||
assert isinstance(subnet, str)
|
||||
# Should contain a slash for CIDR notation
|
||||
assert "/" in subnet, f"Subnet {subnet} is not in CIDR notation"
|
||||
|
||||
# ========================================================================
|
||||
# SSDP Discovery endpoints
|
||||
@@ -140,3 +155,14 @@ class TestDiscoveryService:
|
||||
assert response1.status_code == 200
|
||||
assert response2.status_code == 200
|
||||
assert response1.json()["is_docker"] == response2.json()["is_docker"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_subnets_consistent_across_calls(self, async_client: AsyncClient):
|
||||
"""Verify subnet detection returns consistent results."""
|
||||
response1 = await async_client.get("/api/v1/discovery/info")
|
||||
response2 = await async_client.get("/api/v1/discovery/info")
|
||||
|
||||
assert response1.status_code == 200
|
||||
assert response2.status_code == 200
|
||||
assert response1.json()["subnets"] == response2.json()["subnets"]
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for AddPrinterModal discovery subnet auto-detection.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockPrinters = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '00M09A350100001',
|
||||
access_code: '12345678',
|
||||
model: 'X1C',
|
||||
enabled: true,
|
||||
nozzle_diameter: 0.4,
|
||||
nozzle_type: 'hardened_steel',
|
||||
location: null,
|
||||
auto_archive: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockPrinterStatus = {
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
progress: 0,
|
||||
layer_num: 0,
|
||||
total_layers: 0,
|
||||
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
|
||||
remaining_time: 0,
|
||||
filename: null,
|
||||
wifi_signal: -50,
|
||||
};
|
||||
|
||||
describe('AddPrinterModal Discovery', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => {
|
||||
return HttpResponse.json(mockPrinters);
|
||||
}),
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
return HttpResponse.json(mockPrinterStatus);
|
||||
}),
|
||||
http.get('/api/v1/queue/', () => {
|
||||
return HttpResponse.json([]);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-populates subnet from discovery info in Docker mode', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/discovery/info', () => {
|
||||
return HttpResponse.json({
|
||||
is_docker: true,
|
||||
ssdp_running: false,
|
||||
scan_running: false,
|
||||
subnets: ['10.0.0.0/24'],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
// Wait for printer page to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the Add Printer button
|
||||
const addButton = screen.getByText(/add printer/i);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
// Wait for the modal and discovery info to load
|
||||
await waitFor(() => {
|
||||
// Should show subnet dropdown with detected subnet
|
||||
const subnetSelect = screen.getByDisplayValue('10.0.0.0/24');
|
||||
expect(subnetSelect).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows dropdown when multiple subnets detected in Docker mode', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/discovery/info', () => {
|
||||
return HttpResponse.json({
|
||||
is_docker: true,
|
||||
ssdp_running: false,
|
||||
scan_running: false,
|
||||
subnets: ['192.168.1.0/24', '10.0.0.0/24'],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addButton = screen.getByText(/add printer/i);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show a select element (dropdown) with both subnets
|
||||
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
|
||||
expect(selectElement.tagName).toBe('SELECT');
|
||||
|
||||
// Both options should be available
|
||||
const options = selectElement.querySelectorAll('option');
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].textContent).toBe('192.168.1.0/24');
|
||||
expect(options[1].textContent).toBe('10.0.0.0/24');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows text input when no subnets detected in Docker mode', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/discovery/info', () => {
|
||||
return HttpResponse.json({
|
||||
is_docker: true,
|
||||
ssdp_running: false,
|
||||
scan_running: false,
|
||||
subnets: [],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addButton = screen.getByText(/add printer/i);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show a text input with placeholder
|
||||
const textInput = screen.getByPlaceholderText('192.168.1.0/24');
|
||||
expect(textInput).toBeInTheDocument();
|
||||
expect(textInput.tagName).toBe('INPUT');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show subnet field in non-Docker mode', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/discovery/info', () => {
|
||||
return HttpResponse.json({
|
||||
is_docker: false,
|
||||
ssdp_running: false,
|
||||
scan_running: false,
|
||||
subnets: ['192.168.1.0/24'],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addButton = screen.getByText(/add printer/i);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show the discover button but NOT the subnet field
|
||||
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Subnet field should not exist
|
||||
expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -364,6 +364,19 @@ export const handlers = [
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
// ========================================================================
|
||||
// Discovery
|
||||
// ========================================================================
|
||||
|
||||
http.get('/api/v1/discovery/info', () => {
|
||||
return HttpResponse.json({
|
||||
is_docker: false,
|
||||
ssdp_running: false,
|
||||
scan_running: false,
|
||||
subnets: ['192.168.1.0/24'],
|
||||
});
|
||||
}),
|
||||
|
||||
// ========================================================================
|
||||
// Version / Health
|
||||
// ========================================================================
|
||||
|
||||
@@ -3934,6 +3934,7 @@ export interface DiscoveryInfo {
|
||||
is_docker: boolean;
|
||||
ssdp_running: boolean;
|
||||
scan_running: boolean;
|
||||
subnets: string[];
|
||||
}
|
||||
|
||||
export interface SubnetScanStatus {
|
||||
|
||||
@@ -3385,13 +3385,18 @@ function AddPrinterModal({
|
||||
const [discoveryError, setDiscoveryError] = useState('');
|
||||
const [hasScanned, setHasScanned] = useState(false);
|
||||
const [isDocker, setIsDocker] = useState(false);
|
||||
const [subnet, setSubnet] = useState('192.168.1.0/24');
|
||||
const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
|
||||
const [subnet, setSubnet] = useState('');
|
||||
const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
|
||||
|
||||
// Fetch discovery info on mount
|
||||
useEffect(() => {
|
||||
discoveryApi.getInfo().then(info => {
|
||||
setIsDocker(info.is_docker);
|
||||
if (info.subnets.length > 0) {
|
||||
setDetectedSubnets(info.subnets);
|
||||
setSubnet(info.subnets[0]);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Ignore errors, assume not Docker
|
||||
});
|
||||
@@ -3556,14 +3561,27 @@ function AddPrinterModal({
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
{t('printers.discovery.subnetToScan')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={subnet}
|
||||
onChange={(e) => setSubnet(e.target.value)}
|
||||
placeholder="192.168.1.0/24"
|
||||
disabled={discovering}
|
||||
/>
|
||||
{detectedSubnets.length > 0 ? (
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={subnet}
|
||||
onChange={(e) => setSubnet(e.target.value)}
|
||||
disabled={discovering}
|
||||
>
|
||||
{detectedSubnets.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={subnet}
|
||||
onChange={(e) => setSubnet(e.target.value)}
|
||||
placeholder="192.168.1.0/24"
|
||||
disabled={discovering}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-bambu-gray">
|
||||
{t('printers.discovery.dockerNote')}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user