Browse Source

- Add subnet scanning for printer discovery in Docker environments
- Detect Docker environment automatically via /.dockerenv and cgroup
- Show subnet input field in Add Printer dialog when running in Docker
- Scan IP range for Bambu printer ports (8883 MQTT, 990 FTPS)
- Query SSDP to get printer name/serial/model from discovered IPs
- Map raw SSDP model codes to friendly names (BL-P001→X1C, O1D→H2D)

maziggy 5 months ago
parent
commit
8acc91d3cd

+ 14 - 0
CHANGELOG.md

@@ -2,6 +2,20 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.6b] - 2025-12-28
+
+### Added
+- **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
+- **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
+- **Discovery API tests** - Comprehensive test coverage for discovery endpoints.
+
+### Changed
+- **GitHub issue template** - Added mandatory printer firmware version field and LAN-only mode checkbox for better bug reports.
+- **Docker compose** - Clearer comments explaining `network_mode: host` requirement for printer discovery and camera streaming.
+
+### Fixed
+- **Notification module** - Fixed bug where notifications were sent even when printer was offline.
+
 ## [0.1.5] - 2025-12-19
 ## [0.1.5] - 2025-12-19
 
 
 ### Fixed
 ### Fixed

+ 3 - 1
README.md

@@ -288,7 +288,7 @@ server {
 
 
 > **Note:** WebSocket support is required for real-time printer updates.
 > **Note:** WebSocket support is required for real-time printer updates.
 
 
-**Network Mode Host** (for easier printer discovery):
+**Network Mode Host** (required for printer discovery and camera streaming):
 
 
 ```yaml
 ```yaml
 services:
 services:
@@ -297,6 +297,8 @@ 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.
+
 </details>
 </details>
 
 
 #### Manual Installation
 #### Manual Installation

+ 105 - 6
backend/app/api/routes/discovery.py

@@ -2,6 +2,7 @@
 Printer discovery API endpoints.
 Printer discovery API endpoints.
 
 
 Provides endpoints for discovering Bambu Lab printers on the local network.
 Provides endpoints for discovering Bambu Lab printers on the local network.
+Supports both SSDP discovery (for native installs) and subnet scanning (for Docker).
 """
 """
 
 
 import logging
 import logging
@@ -9,7 +10,11 @@ import logging
 from fastapi import APIRouter
 from fastapi import APIRouter
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
-from backend.app.services.discovery import discovery_service
+from backend.app.services.discovery import (
+    discovery_service,
+    is_running_in_docker,
+    subnet_scanner,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/discovery", tags=["discovery"])
 router = APIRouter(prefix="/discovery", tags=["discovery"])
@@ -21,6 +26,29 @@ class DiscoveryStatus(BaseModel):
     running: bool
     running: bool
 
 
 
 
+class DiscoveryInfo(BaseModel):
+    """Discovery environment info."""
+
+    is_docker: bool
+    ssdp_running: bool
+    scan_running: bool
+
+
+class SubnetScanRequest(BaseModel):
+    """Request to scan a subnet."""
+
+    subnet: str  # CIDR notation, e.g., "192.168.1.0/24"
+    timeout: float = 1.0  # Connection timeout per host
+
+
+class SubnetScanStatus(BaseModel):
+    """Subnet scan status response."""
+
+    running: bool
+    scanned: int
+    total: int
+
+
 class DiscoveredPrinterResponse(BaseModel):
 class DiscoveredPrinterResponse(BaseModel):
     """Discovered printer response."""
     """Discovered printer response."""
 
 
@@ -31,15 +59,25 @@ class DiscoveredPrinterResponse(BaseModel):
     discovered_at: str | None = None
     discovered_at: str | None = None
 
 
 
 
+@router.get("/info", response_model=DiscoveryInfo)
+async def get_discovery_info():
+    """Get discovery environment info (Docker detection, etc.)."""
+    return DiscoveryInfo(
+        is_docker=is_running_in_docker(),
+        ssdp_running=discovery_service.is_running,
+        scan_running=subnet_scanner.is_running,
+    )
+
+
 @router.get("/status", response_model=DiscoveryStatus)
 @router.get("/status", response_model=DiscoveryStatus)
 async def get_discovery_status():
 async def get_discovery_status():
-    """Get the current discovery status."""
+    """Get the current SSDP discovery status."""
     return DiscoveryStatus(running=discovery_service.is_running)
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 
 
 @router.post("/start", response_model=DiscoveryStatus)
 @router.post("/start", response_model=DiscoveryStatus)
 async def start_discovery(duration: float = 10.0):
 async def start_discovery(duration: float = 10.0):
-    """Start printer discovery.
+    """Start SSDP printer discovery.
 
 
     Args:
     Args:
         duration: Discovery duration in seconds (default 10)
         duration: Discovery duration in seconds (default 10)
@@ -50,14 +88,26 @@ async def start_discovery(duration: float = 10.0):
 
 
 @router.post("/stop", response_model=DiscoveryStatus)
 @router.post("/stop", response_model=DiscoveryStatus)
 async def stop_discovery():
 async def stop_discovery():
-    """Stop printer discovery."""
+    """Stop SSDP printer discovery."""
     await discovery_service.stop()
     await discovery_service.stop()
     return DiscoveryStatus(running=discovery_service.is_running)
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 
 
 @router.get("/printers", response_model=list[DiscoveredPrinterResponse])
 @router.get("/printers", response_model=list[DiscoveredPrinterResponse])
 async def get_discovered_printers():
 async def get_discovered_printers():
-    """Get list of discovered printers."""
+    """Get list of discovered printers (from both SSDP and subnet scan)."""
+    # Combine results from both discovery methods
+    printers = {}
+
+    # Add SSDP discovered printers
+    for p in discovery_service.discovered_printers:
+        printers[p.ip_address] = p
+
+    # Add subnet scan discovered printers (may override if same IP)
+    for p in subnet_scanner.discovered_printers:
+        if p.ip_address not in printers:
+            printers[p.ip_address] = p
+
     return [
     return [
         DiscoveredPrinterResponse(
         DiscoveredPrinterResponse(
             serial=p.serial,
             serial=p.serial,
@@ -66,5 +116,54 @@ async def get_discovered_printers():
             model=p.model,
             model=p.model,
             discovered_at=p.discovered_at,
             discovered_at=p.discovered_at,
         )
         )
-        for p in discovery_service.discovered_printers
+        for p in printers.values()
     ]
     ]
+
+
+# Subnet scanning endpoints (for Docker environments)
+
+
+@router.post("/scan", response_model=SubnetScanStatus)
+async def start_subnet_scan(request: SubnetScanRequest):
+    """Start a subnet scan for Bambu printers.
+
+    Use this when running in Docker where SSDP multicast doesn't work.
+
+    Args:
+        request: Subnet to scan in CIDR notation (e.g., "192.168.1.0/24")
+    """
+    # Start scan in background
+    import asyncio
+
+    asyncio.create_task(subnet_scanner.scan_subnet(request.subnet, request.timeout))
+
+    # Return immediate status
+    scanned, total = subnet_scanner.progress
+    return SubnetScanStatus(
+        running=subnet_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.get("/scan/status", response_model=SubnetScanStatus)
+async def get_scan_status():
+    """Get the current subnet scan status."""
+    scanned, total = subnet_scanner.progress
+    return SubnetScanStatus(
+        running=subnet_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.post("/scan/stop", response_model=SubnetScanStatus)
+async def stop_subnet_scan():
+    """Stop the current subnet scan."""
+    subnet_scanner.stop()
+    scanned, total = subnet_scanner.progress
+    return SubnetScanStatus(
+        running=subnet_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )

+ 206 - 2
backend/app/services/discovery.py

@@ -1,21 +1,47 @@
 """
 """
-Bambu Lab printer discovery service using SSDP.
+Bambu Lab printer discovery service using SSDP and subnet scanning.
 
 
 Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
 Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
 on the local network. This service listens for these advertisements and provides
 on the local network. This service listens for these advertisements and provides
 a list of discovered printers.
 a list of discovered printers.
+
+For Docker environments where SSDP multicast doesn't work, subnet scanning is
+available as an alternative discovery method.
 """
 """
 
 
 import asyncio
 import asyncio
+import ipaddress
 import logging
 import logging
+import os
 import re
 import re
 import socket
 import socket
 import struct
 import struct
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime
 from datetime import datetime
+from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+
+def is_running_in_docker() -> bool:
+    """Detect if we're running inside a Docker container."""
+    # Check for .dockerenv file
+    if Path("/.dockerenv").exists():
+        return True
+
+    # Check cgroup for docker/containerd
+    try:
+        with open("/proc/1/cgroup") as f:
+            content = f.read()
+            if "docker" in content or "containerd" in content or "kubepods" in content:
+                return True
+    except (FileNotFoundError, PermissionError):
+        pass
+
+    # Check for container environment variable
+    return bool(os.environ.get("CONTAINER") or os.environ.get("DOCKER_CONTAINER"))
+
+
 # SSDP multicast address - Bambu uses port 2021, not standard 1900
 # SSDP multicast address - Bambu uses port 2021, not standard 1900
 SSDP_ADDR = "239.255.255.250"
 SSDP_ADDR = "239.255.255.250"
 SSDP_PORT = 2021  # Bambu Lab uses non-standard port
 SSDP_PORT = 2021  # Bambu Lab uses non-standard port
@@ -276,5 +302,183 @@ class PrinterDiscoveryService:
         logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
         logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
 
 
 
 
-# Global discovery service instance
+class SubnetScanner:
+    """Scanner for discovering Bambu printers by probing IP addresses."""
+
+    # Bambu printer ports
+    MQTT_PORT = 8883
+    FTP_PORT = 990
+
+    def __init__(self):
+        self._discovered: dict[str, DiscoveredPrinter] = {}
+        self._running = False
+        self._scanned = 0
+        self._total = 0
+
+    @property
+    def is_running(self) -> bool:
+        return self._running
+
+    @property
+    def discovered_printers(self) -> list[DiscoveredPrinter]:
+        return list(self._discovered.values())
+
+    @property
+    def progress(self) -> tuple[int, int]:
+        """Return (scanned, total) counts."""
+        return self._scanned, self._total
+
+    async def scan_subnet(self, subnet: str, timeout: float = 1.0) -> list[DiscoveredPrinter]:
+        """Scan a subnet for Bambu printers.
+
+        Args:
+            subnet: CIDR notation subnet (e.g., "192.168.1.0/24")
+            timeout: Connection timeout per host in seconds
+
+        Returns:
+            List of discovered printers
+        """
+        if self._running:
+            return []
+
+        self._running = True
+        self._discovered.clear()
+        self._scanned = 0
+
+        try:
+            network = ipaddress.ip_network(subnet, strict=False)
+            hosts = list(network.hosts())
+            self._total = len(hosts)
+
+            if self._total > 1024:
+                logger.warning(f"Subnet {subnet} has {self._total} hosts, limiting to /22 (1024 hosts)")
+                self._total = 1024
+                hosts = hosts[:1024]
+
+            logger.info(f"Starting subnet scan of {subnet} ({self._total} hosts)")
+
+            # Scan in batches to avoid overwhelming the network
+            batch_size = 50
+            for i in range(0, len(hosts), batch_size):
+                if not self._running:
+                    break
+
+                batch = hosts[i : i + batch_size]
+                tasks = [self._probe_host(str(ip), timeout) for ip in batch]
+                await asyncio.gather(*tasks, return_exceptions=True)
+                self._scanned = min(i + batch_size, len(hosts))
+
+            logger.info(f"Subnet scan complete. Found {len(self._discovered)} printers.")
+            return self.discovered_printers
+
+        except ValueError as e:
+            logger.error(f"Invalid subnet format: {e}")
+            return []
+        finally:
+            self._running = False
+
+    async def _probe_host(self, ip: str, timeout: float):
+        """Probe a single host for Bambu printer ports."""
+        # Check FTP port (990) - more reliable indicator
+        ftp_open = await self._check_port(ip, self.FTP_PORT, timeout)
+        if not ftp_open:
+            return
+
+        # Also check MQTT port (8883) for confirmation
+        mqtt_open = await self._check_port(ip, self.MQTT_PORT, timeout)
+        if not mqtt_open:
+            return
+
+        # Both ports open - likely a Bambu printer
+        logger.info(f"Found potential Bambu printer at {ip}")
+
+        # Try to get printer info via SSDP unicast
+        serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
+
+        printer = DiscoveredPrinter(
+            serial=serial or f"unknown-{ip.replace('.', '-')}",
+            name=name or f"Printer at {ip}",
+            ip_address=ip,
+            model=model,
+            discovered_at=datetime.now().isoformat(),
+        )
+        self._discovered[ip] = printer
+
+    async def _get_printer_info_ssdp(self, ip: str, timeout: float) -> tuple[str | None, str | None, str | None]:
+        """Try to get printer info via SSDP unicast query."""
+        loop = asyncio.get_event_loop()
+
+        def _query():
+            try:
+                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+                sock.settimeout(timeout)
+                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+                # Send M-SEARCH directly to the printer
+                msearch = (
+                    "M-SEARCH * HTTP/1.1\r\n"
+                    f"HOST: {ip}:{SSDP_PORT}\r\n"
+                    'MAN: "ssdp:discover"\r\n'
+                    "MX: 1\r\n"
+                    f"ST: {BAMBU_SEARCH_TARGET}\r\n"
+                    "\r\n"
+                )
+                sock.sendto(msearch.encode(), (ip, SSDP_PORT))
+
+                # Wait for response
+                data, _ = sock.recvfrom(4096)
+                response = data.decode("utf-8", errors="ignore")
+                sock.close()
+
+                # Parse response
+                serial = None
+                name = None
+                model = None
+
+                usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
+                if usn_match:
+                    serial = usn_match.group(1).strip()
+
+                name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+                if name_match:
+                    name = name_match.group(1).strip()
+
+                model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+                if model_match:
+                    model = model_match.group(1).strip()
+
+                logger.debug(f"SSDP info from {ip}: serial={serial}, name={name}, model={model}")
+                return serial, name, model
+
+            except Exception as e:
+                logger.debug(f"SSDP query to {ip} failed: {e}")
+                return None, None, None
+
+        return await loop.run_in_executor(None, _query)
+
+    async def _check_port(self, ip: str, port: int, timeout: float) -> bool:
+        """Check if a port is open on the given IP."""
+        try:
+            _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+            writer.close()
+            await writer.wait_closed()
+            logger.debug(f"Port {port} open on {ip}")
+            return True
+        except TimeoutError:
+            return False
+        except ConnectionRefusedError:
+            return False
+        except OSError as e:
+            # Log first few errors to help debug network issues
+            if self._scanned < 5:
+                logger.debug(f"OSError checking {ip}:{port}: {e}")
+            return False
+
+    def stop(self):
+        """Stop the current scan."""
+        self._running = False
+
+
+# Global instances
 discovery_service = PrinterDiscoveryService()
 discovery_service = PrinterDiscoveryService()
+subnet_scanner = SubnetScanner()

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

@@ -0,0 +1,144 @@
+"""Integration tests for Discovery API endpoints.
+
+Tests the full request/response cycle for /api/v1/discovery/ endpoints.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestDiscoveryAPI:
+    """Integration tests for /api/v1/discovery/ endpoints."""
+
+    # ========================================================================
+    # Info endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_discovery_info(self, async_client: AsyncClient):
+        """Verify discovery info endpoint returns expected fields."""
+        response = await async_client.get("/api/v1/discovery/info")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "is_docker" in data
+        assert "ssdp_running" in data
+        assert "scan_running" in data
+        assert isinstance(data["is_docker"], bool)
+        assert isinstance(data["ssdp_running"], bool)
+        assert isinstance(data["scan_running"], bool)
+
+    # ========================================================================
+    # SSDP Discovery endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_discovery_status(self, async_client: AsyncClient):
+        """Verify SSDP discovery status endpoint works."""
+        response = await async_client.get("/api/v1/discovery/status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert isinstance(data["running"], bool)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_discovery(self, async_client: AsyncClient):
+        """Verify SSDP discovery can be started."""
+        response = await async_client.post("/api/v1/discovery/start?duration=1.0")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_discovery(self, async_client: AsyncClient):
+        """Verify SSDP discovery can be stopped."""
+        response = await async_client.post("/api/v1/discovery/stop")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert data["running"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_discovered_printers_empty(self, async_client: AsyncClient):
+        """Verify empty list when no printers discovered."""
+        response = await async_client.get("/api/v1/discovery/printers")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+    # ========================================================================
+    # Subnet scanning endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_subnet_scan(self, async_client: AsyncClient):
+        """Verify subnet scan can be started."""
+        response = await async_client.post(
+            "/api/v1/discovery/scan",
+            json={"subnet": "192.168.1.0/30", "timeout": 0.1},  # Small subnet for testing
+        )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_scan_status(self, async_client: AsyncClient):
+        """Verify subnet scan status endpoint works."""
+        response = await async_client.get("/api/v1/discovery/scan/status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_subnet_scan(self, async_client: AsyncClient):
+        """Verify subnet scan can be stopped."""
+        response = await async_client.post("/api/v1/discovery/scan/stop")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_subnet_scan_invalid_subnet(self, async_client: AsyncClient):
+        """Verify invalid subnet format is rejected."""
+        response = await async_client.post("/api/v1/discovery/scan", json={"subnet": "invalid-subnet", "timeout": 1.0})
+
+        # Should return 422 validation error or 200 with empty results
+        assert response.status_code in [200, 422]
+
+
+class TestDiscoveryService:
+    """Unit tests for discovery service functionality."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_docker_detection_fields(self, async_client: AsyncClient):
+        """Verify Docker detection returns consistent response."""
+        # Call multiple times to ensure consistency
+        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()["is_docker"] == response2.json()["is_docker"]

+ 9 - 5
docker-compose.yml

@@ -2,11 +2,15 @@ services:
   bambuddy:
   bambuddy:
     build: .
     build: .
     container_name: bambuddy
     container_name: bambuddy
-    # OPTIONAL: Host network mode can help if you have camera or printer
-    # discovery issues. Docker's default bridge networking works in most setups.
-    # network_mode: host  # Uncomment this and remove "ports:" below if needed
-    ports:
-      - "8000:8000"
+    # Network mode options:
+    # - Default (bridge): Works for basic usage, but printer discovery and
+    #   camera streaming may not work since container can't reach LAN directly.
+    # - Host mode: Required for printer discovery scanning and camera streaming.
+    #   Uncomment "network_mode: host" and remove "ports:" section below.
+    #
+    network_mode: host
+    #ports:
+    #  - "8000:8000"
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs

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

@@ -2190,8 +2190,22 @@ export interface DiscoveryStatus {
   running: boolean;
   running: boolean;
 }
 }
 
 
+export interface DiscoveryInfo {
+  is_docker: boolean;
+  ssdp_running: boolean;
+  scan_running: boolean;
+}
+
+export interface SubnetScanStatus {
+  running: boolean;
+  scanned: number;
+  total: number;
+}
+
 // Discovery API
 // Discovery API
 export const discoveryApi = {
 export const discoveryApi = {
+  getInfo: () => request<DiscoveryInfo>('/discovery/info'),
+
   getStatus: () => request<DiscoveryStatus>('/discovery/status'),
   getStatus: () => request<DiscoveryStatus>('/discovery/status'),
 
 
   startDiscovery: (duration: number = 10) =>
   startDiscovery: (duration: number = 10) =>
@@ -2202,4 +2216,16 @@ export const discoveryApi = {
 
 
   getDiscoveredPrinters: () =>
   getDiscoveredPrinters: () =>
     request<DiscoveredPrinter[]>('/discovery/printers'),
     request<DiscoveredPrinter[]>('/discovery/printers'),
+
+  // Subnet scanning (for Docker environments)
+  startSubnetScan: (subnet: string, timeout: number = 1.0) =>
+    request<SubnetScanStatus>('/discovery/scan', {
+      method: 'POST',
+      body: JSON.stringify({ subnet, timeout }),
+    }),
+
+  getScanStatus: () => request<SubnetScanStatus>('/discovery/scan/status'),
+
+  stopSubnetScan: () =>
+    request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),
 };
 };

+ 96 - 35
frontend/src/pages/PrintersPage.tsx

@@ -1496,6 +1496,18 @@ function AddPrinterModal({
   const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);
   const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);
   const [discoveryError, setDiscoveryError] = useState('');
   const [discoveryError, setDiscoveryError] = useState('');
   const [hasScanned, setHasScanned] = useState(false);
   const [hasScanned, setHasScanned] = useState(false);
+  const [isDocker, setIsDocker] = useState(false);
+  const [subnet, setSubnet] = useState('192.168.1.0/24');
+  const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
+
+  // Fetch discovery info on mount
+  useEffect(() => {
+    discoveryApi.getInfo().then(info => {
+      setIsDocker(info.is_docker);
+    }).catch(() => {
+      // Ignore errors, assume not Docker
+    });
+  }, []);
 
 
   // Filter out already-added printers
   // Filter out already-added printers
   const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
   const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
@@ -1505,38 +1517,64 @@ function AddPrinterModal({
     setDiscovered([]);
     setDiscovered([]);
     setDiscovering(true);
     setDiscovering(true);
     setHasScanned(false);
     setHasScanned(false);
+    setScanProgress({ scanned: 0, total: 0 });
 
 
     try {
     try {
-      await discoveryApi.startDiscovery(10);
-
-      // Poll for discovered printers every second
-      const pollInterval = setInterval(async () => {
-        try {
-          const printers = await discoveryApi.getDiscoveredPrinters();
-          setDiscovered(printers);
-        } catch (e) {
-          console.error('Failed to get discovered printers:', e);
-        }
-      }, 1000);
-
-      // Stop after 10 seconds
-      setTimeout(async () => {
-        clearInterval(pollInterval);
-        try {
-          await discoveryApi.stopDiscovery();
-        } catch (e) {
-          // Ignore stop errors
-        }
-        setDiscovering(false);
-        setHasScanned(true);
-        // Final fetch
-        try {
-          const printers = await discoveryApi.getDiscoveredPrinters();
-          setDiscovered(printers);
-        } catch (e) {
-          console.error('Failed to get final discovered printers:', e);
-        }
-      }, 10000);
+      if (isDocker) {
+        // Use subnet scanning for Docker
+        await discoveryApi.startSubnetScan(subnet);
+
+        // Poll for scan status and results
+        const pollInterval = setInterval(async () => {
+          try {
+            const status = await discoveryApi.getScanStatus();
+            setScanProgress({ scanned: status.scanned, total: status.total });
+
+            const printers = await discoveryApi.getDiscoveredPrinters();
+            setDiscovered(printers);
+
+            if (!status.running) {
+              clearInterval(pollInterval);
+              setDiscovering(false);
+              setHasScanned(true);
+            }
+          } catch (e) {
+            console.error('Failed to get scan status:', e);
+          }
+        }, 500);
+      } else {
+        // Use SSDP discovery for native installs
+        await discoveryApi.startDiscovery(10);
+
+        // Poll for discovered printers every second
+        const pollInterval = setInterval(async () => {
+          try {
+            const printers = await discoveryApi.getDiscoveredPrinters();
+            setDiscovered(printers);
+          } catch (e) {
+            console.error('Failed to get discovered printers:', e);
+          }
+        }, 1000);
+
+        // Stop after 10 seconds
+        setTimeout(async () => {
+          clearInterval(pollInterval);
+          try {
+            await discoveryApi.stopDiscovery();
+          } catch (e) {
+            // Ignore stop errors
+          }
+          setDiscovering(false);
+          setHasScanned(true);
+          // Final fetch
+          try {
+            const printers = await discoveryApi.getDiscoveredPrinters();
+            setDiscovered(printers);
+          } catch (e) {
+            console.error('Failed to get final discovered printers:', e);
+          }
+        }, 10000);
+      }
     } catch (e) {
     } catch (e) {
       console.error('Failed to start discovery:', e);
       console.error('Failed to start discovery:', e);
       setDiscoveryError(e instanceof Error ? e.message : 'Failed to start discovery');
       setDiscoveryError(e instanceof Error ? e.message : 'Failed to start discovery');
@@ -1596,6 +1634,7 @@ function AddPrinterModal({
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {
       discoveryApi.stopDiscovery().catch(() => {});
       discoveryApi.stopDiscovery().catch(() => {});
+      discoveryApi.stopSubnetScan().catch(() => {});
     };
     };
   }, []);
   }, []);
 
 
@@ -1619,6 +1658,26 @@ function AddPrinterModal({
 
 
           {/* Discovery Section */}
           {/* Discovery Section */}
           <div className="mb-4 pb-4 border-b border-bambu-dark-tertiary">
           <div className="mb-4 pb-4 border-b border-bambu-dark-tertiary">
+            {isDocker && (
+              <div className="mb-3">
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Subnet to scan
+                </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}
+                />
+                <p className="mt-1 text-xs text-bambu-gray">
+                  Docker detected. Enter your printer's subnet in CIDR notation.
+                  Requires <code className="text-bambu-green">network_mode: host</code> in docker-compose.yml.
+                </p>
+              </div>
+            )}
+
             <Button
             <Button
               type="button"
               type="button"
               variant="secondary"
               variant="secondary"
@@ -1629,12 +1688,14 @@ function AddPrinterModal({
               {discovering ? (
               {discovering ? (
                 <>
                 <>
                   <Loader2 className="w-4 h-4 animate-spin" />
                   <Loader2 className="w-4 h-4 animate-spin" />
-                  Scanning...
+                  {isDocker && scanProgress.total > 0
+                    ? `Scanning... ${scanProgress.scanned}/${scanProgress.total}`
+                    : 'Scanning...'}
                 </>
                 </>
               ) : (
               ) : (
                 <>
                 <>
                   <Search className="w-4 h-4" />
                   <Search className="w-4 h-4" />
-                  Discover Printers on Network
+                  {isDocker ? 'Scan Subnet for Printers' : 'Discover Printers on Network'}
                 </>
                 </>
               )}
               )}
             </Button>
             </Button>
@@ -1656,7 +1717,7 @@ function AddPrinterModal({
                         {printer.name || printer.serial}
                         {printer.name || printer.serial}
                       </p>
                       </p>
                       <p className="text-xs text-bambu-gray truncate">
                       <p className="text-xs text-bambu-gray truncate">
-                        {printer.model || 'Unknown'} • {printer.ip_address}
+                        {mapModelCode(printer.model) || 'Unknown'} • {printer.ip_address}
                       </p>
                       </p>
                     </div>
                     </div>
                     <ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />
                     <ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />
@@ -1667,13 +1728,13 @@ function AddPrinterModal({
 
 
             {discovering && (
             {discovering && (
               <p className="mt-2 text-sm text-bambu-gray text-center">
               <p className="mt-2 text-sm text-bambu-gray text-center">
-                Scanning network...
+                {isDocker ? 'Scanning subnet for Bambu printers...' : 'Scanning network...'}
               </p>
               </p>
             )}
             )}
 
 
             {hasScanned && !discovering && discovered.length === 0 && (
             {hasScanned && !discovering && discovered.length === 0 && (
               <p className="mt-2 text-sm text-bambu-gray text-center">
               <p className="mt-2 text-sm text-bambu-gray text-center">
-                No printers found on the network.
+                No printers found{isDocker ? ' in the specified subnet' : ' on the network'}.
               </p>
               </p>
             )}
             )}
 
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DpxuzRSm.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BCPmsaqF.js"></script>
+    <script type="module" crossorigin src="/assets/index-DpxuzRSm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CUXYdb1N.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CUXYdb1N.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff