From d73da5e0efa3b654bd678744f974cf7efd8e3c7f Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 8 Feb 2026 12:17:20 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + README.md | 2 +- backend/app/api/routes/discovery.py | 4 + .../tests/integration/test_discovery_api.py | 26 +++ .../components/AddPrinterDiscovery.test.tsx | 182 ++++++++++++++++++ frontend/src/__tests__/mocks/handlers.ts | 13 ++ frontend/src/api/client.ts | 1 + frontend/src/pages/PrintersPage.tsx | 36 +++- 8 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ea19b7d79..f839fa557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 77f2e6653..9517cda7c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/api/routes/discovery.py b/backend/app/api/routes/discovery.py index bdfddb651..b6debb8ef 100644 --- a/backend/app/api/routes/discovery.py +++ b/backend/app/api/routes/discovery.py @@ -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, ) diff --git a/backend/tests/integration/test_discovery_api.py b/backend/tests/integration/test_discovery_api.py index 1c4710219..cea86b7da 100644 --- a/backend/tests/integration/test_discovery_api.py +++ b/backend/tests/integration/test_discovery_api.py @@ -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"] diff --git a/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx b/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx new file mode 100644 index 000000000..7ad6c60e8 --- /dev/null +++ b/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx @@ -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(); + + // 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 5ce009b2a..06eab5467 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -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 // ======================================================================== diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4bb893c38..4d9bd1877 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3934,6 +3934,7 @@ export interface DiscoveryInfo { is_docker: boolean; ssdp_running: boolean; scan_running: boolean; + subnets: string[]; } export interface SubnetScanStatus { diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 1f588c2cc..a918a8e05 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -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([]); + 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({ - setSubnet(e.target.value)} - placeholder="192.168.1.0/24" - disabled={discovering} - /> + {detectedSubnets.length > 0 ? ( + + ) : ( + setSubnet(e.target.value)} + placeholder="192.168.1.0/24" + disabled={discovering} + /> + )}

{t('printers.discovery.dockerNote')}