Selaa lähdekoodia

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.
maziggy 3 kuukautta sitten
vanhempi
sitoutus
d73da5e0ef

+ 1 - 0
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.
 - **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
 ### 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.
 - **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
 ### Fixed

+ 1 - 1
README.md

@@ -442,7 +442,7 @@ services:
     network_mode: host
     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>
 </details>
 
 

+ 4 - 0
backend/app/api/routes/discovery.py

@@ -18,6 +18,7 @@ from backend.app.services.discovery import (
     is_running_in_docker,
     is_running_in_docker,
     subnet_scanner,
     subnet_scanner,
 )
 )
+from backend.app.services.network_utils import get_network_interfaces
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/discovery", tags=["discovery"])
 router = APIRouter(prefix="/discovery", tags=["discovery"])
@@ -35,6 +36,7 @@ class DiscoveryInfo(BaseModel):
     is_docker: bool
     is_docker: bool
     ssdp_running: bool
     ssdp_running: bool
     scan_running: bool
     scan_running: bool
+    subnets: list[str] = []
 
 
 
 
 class SubnetScanRequest(BaseModel):
 class SubnetScanRequest(BaseModel):
@@ -67,10 +69,12 @@ async def get_discovery_info(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
 ):
 ):
     """Get discovery environment info (Docker detection, etc.)."""
     """Get discovery environment info (Docker detection, etc.)."""
+    subnets = [iface["subnet"] for iface in get_network_interfaces()]
     return DiscoveryInfo(
     return DiscoveryInfo(
         is_docker=is_running_in_docker(),
         is_docker=is_running_in_docker(),
         ssdp_running=discovery_service.is_running,
         ssdp_running=discovery_service.is_running,
         scan_running=subnet_scanner.is_running,
         scan_running=subnet_scanner.is_running,
+        subnets=subnets,
     )
     )
 
 
 
 

+ 26 - 0
backend/tests/integration/test_discovery_api.py

@@ -25,9 +25,24 @@ class TestDiscoveryAPI:
         assert "is_docker" in data
         assert "is_docker" in data
         assert "ssdp_running" in data
         assert "ssdp_running" in data
         assert "scan_running" in data
         assert "scan_running" in data
+        assert "subnets" in data
         assert isinstance(data["is_docker"], bool)
         assert isinstance(data["is_docker"], bool)
         assert isinstance(data["ssdp_running"], bool)
         assert isinstance(data["ssdp_running"], bool)
         assert isinstance(data["scan_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
     # SSDP Discovery endpoints
@@ -140,3 +155,14 @@ class TestDiscoveryService:
         assert response1.status_code == 200
         assert response1.status_code == 200
         assert response2.status_code == 200
         assert response2.status_code == 200
         assert response1.json()["is_docker"] == response2.json()["is_docker"]
         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"]

+ 182 - 0
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(<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();
+  });
+});

+ 13 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -364,6 +364,19 @@ export const handlers = [
     return new HttpResponse(null, { status: 204 });
     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
   // Version / Health
   // ========================================================================
   // ========================================================================

+ 1 - 0
frontend/src/api/client.ts

@@ -3934,6 +3934,7 @@ export interface DiscoveryInfo {
   is_docker: boolean;
   is_docker: boolean;
   ssdp_running: boolean;
   ssdp_running: boolean;
   scan_running: boolean;
   scan_running: boolean;
+  subnets: string[];
 }
 }
 
 
 export interface SubnetScanStatus {
 export interface SubnetScanStatus {

+ 27 - 9
frontend/src/pages/PrintersPage.tsx

@@ -3385,13 +3385,18 @@ function AddPrinterModal({
   const [discoveryError, setDiscoveryError] = useState('');
   const [discoveryError, setDiscoveryError] = useState('');
   const [hasScanned, setHasScanned] = useState(false);
   const [hasScanned, setHasScanned] = useState(false);
   const [isDocker, setIsDocker] = 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 });
   const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
 
 
   // Fetch discovery info on mount
   // Fetch discovery info on mount
   useEffect(() => {
   useEffect(() => {
     discoveryApi.getInfo().then(info => {
     discoveryApi.getInfo().then(info => {
       setIsDocker(info.is_docker);
       setIsDocker(info.is_docker);
+      if (info.subnets.length > 0) {
+        setDetectedSubnets(info.subnets);
+        setSubnet(info.subnets[0]);
+      }
     }).catch(() => {
     }).catch(() => {
       // Ignore errors, assume not Docker
       // Ignore errors, assume not Docker
     });
     });
@@ -3556,14 +3561,27 @@ function AddPrinterModal({
                 <label className="block text-sm text-bambu-gray mb-1">
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('printers.discovery.subnetToScan')}
                   {t('printers.discovery.subnetToScan')}
                 </label>
                 </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">
                 <p className="mt-1 text-xs text-bambu-gray">
                   {t('printers.discovery.dockerNote')}
                   {t('printers.discovery.dockerNote')}
                 </p>
                 </p>