Browse Source

- Added printer auto discovery to add printer modal

maziggy 5 months ago
parent
commit
ef68804fa9

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

@@ -0,0 +1,70 @@
+"""
+Printer discovery API endpoints.
+
+Provides endpoints for discovering Bambu Lab printers on the local network.
+"""
+
+import logging
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from backend.app.services.discovery import discovery_service
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/discovery", tags=["discovery"])
+
+
+class DiscoveryStatus(BaseModel):
+    """Discovery status response."""
+
+    running: bool
+
+
+class DiscoveredPrinterResponse(BaseModel):
+    """Discovered printer response."""
+
+    serial: str
+    name: str
+    ip_address: str
+    model: str | None = None
+    discovered_at: str | None = None
+
+
+@router.get("/status", response_model=DiscoveryStatus)
+async def get_discovery_status():
+    """Get the current discovery status."""
+    return DiscoveryStatus(running=discovery_service.is_running)
+
+
+@router.post("/start", response_model=DiscoveryStatus)
+async def start_discovery(duration: float = 10.0):
+    """Start printer discovery.
+
+    Args:
+        duration: Discovery duration in seconds (default 10)
+    """
+    await discovery_service.start(duration=duration)
+    return DiscoveryStatus(running=discovery_service.is_running)
+
+
+@router.post("/stop", response_model=DiscoveryStatus)
+async def stop_discovery():
+    """Stop printer discovery."""
+    await discovery_service.stop()
+    return DiscoveryStatus(running=discovery_service.is_running)
+
+
+@router.get("/printers", response_model=list[DiscoveredPrinterResponse])
+async def get_discovered_printers():
+    """Get list of discovered printers."""
+    return [
+        DiscoveredPrinterResponse(
+            serial=p.serial,
+            name=p.name,
+            ip_address=p.ip_address,
+            model=p.model,
+            discovered_at=p.discovered_at,
+        )
+        for p in discovery_service.discovered_printers
+    ]

+ 2 - 0
backend/app/main.py

@@ -56,6 +56,7 @@ from backend.app.api.routes import (
     archives,
     camera,
     cloud,
+    discovery,
     external_links,
     filaments,
     kprofiles,
@@ -1530,6 +1531,7 @@ app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
+app.include_router(discovery.router, prefix=app_settings.api_prefix)
 
 
 # Serve static files (React build)

+ 280 - 0
backend/app/services/discovery.py

@@ -0,0 +1,280 @@
+"""
+Bambu Lab printer discovery service using SSDP.
+
+Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
+on the local network. This service listens for these advertisements and provides
+a list of discovered printers.
+"""
+
+import asyncio
+import logging
+import re
+import socket
+import struct
+from dataclasses import dataclass
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+# SSDP multicast address - Bambu uses port 2021, not standard 1900
+SSDP_ADDR = "239.255.255.250"
+SSDP_PORT = 2021  # Bambu Lab uses non-standard port
+
+# Bambu Lab SSDP search target
+BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
+
+# SSDP M-SEARCH message
+SSDP_MSEARCH = (
+    "M-SEARCH * HTTP/1.1\r\n"
+    f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+    'MAN: "ssdp:discover"\r\n'
+    "MX: 3\r\n"
+    f"ST: {BAMBU_SEARCH_TARGET}\r\n"
+    "\r\n"
+)
+
+
+@dataclass
+class DiscoveredPrinter:
+    """Represents a discovered Bambu Lab printer."""
+
+    serial: str
+    name: str
+    ip_address: str
+    model: str | None = None
+    discovered_at: str | None = None
+
+    def to_dict(self) -> dict:
+        return {
+            "serial": self.serial,
+            "name": self.name,
+            "ip_address": self.ip_address,
+            "model": self.model,
+            "discovered_at": self.discovered_at,
+        }
+
+
+class PrinterDiscoveryService:
+    """Service for discovering Bambu Lab printers on the network."""
+
+    def __init__(self):
+        self._discovered: dict[str, DiscoveredPrinter] = {}
+        self._running = False
+        self._task: asyncio.Task | None = None
+
+    @property
+    def is_running(self) -> bool:
+        return self._running
+
+    @property
+    def discovered_printers(self) -> list[DiscoveredPrinter]:
+        return list(self._discovered.values())
+
+    def clear(self):
+        """Clear discovered printers."""
+        self._discovered.clear()
+
+    async def start(self, duration: float = 10.0):
+        """Start discovery for a specified duration."""
+        if self._running:
+            return
+
+        self._running = True
+        self._discovered.clear()
+        self._task = asyncio.create_task(self._discover(duration))
+
+    async def stop(self):
+        """Stop discovery."""
+        self._running = False
+        if self._task and not self._task.done():
+            self._task.cancel()
+            try:
+                await self._task
+            except asyncio.CancelledError:
+                pass
+        self._task = None
+
+    async def _discover(self, duration: float):
+        """Run discovery for the specified duration.
+
+        Bambu printers broadcast NOTIFY messages periodically on port 2021.
+        We need to bind to that port and listen for broadcasts.
+        """
+        sock = None
+        try:
+            # Create UDP socket for SSDP
+            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+            # Try to set SO_REUSEPORT if available (Linux/macOS)
+            try:
+                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+
+            # Set non-blocking mode
+            sock.setblocking(False)
+
+            # Bind to the SSDP port to receive NOTIFY broadcasts from printers
+            sock.bind(("", SSDP_PORT))
+
+            # Join multicast group to receive multicast messages
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+
+            # Enable broadcast
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info(f"Starting SSDP discovery on port {SSDP_PORT} for Bambu Lab printers...")
+
+            # Send initial M-SEARCH request to trigger responses
+            try:
+                sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
+            except Exception as e:
+                logger.debug(f"M-SEARCH send error: {e}")
+
+            start_time = asyncio.get_event_loop().time()
+            last_send = start_time
+
+            while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
+                # Try to receive data
+                try:
+                    data, addr = sock.recvfrom(4096)
+                    message = data.decode("utf-8", errors="ignore")
+                    logger.debug(f"Received from {addr[0]}: {message[:100]}...")
+                    self._handle_response(message, addr[0])
+                except BlockingIOError:
+                    # No data available, that's fine
+                    pass
+                except Exception as e:
+                    logger.debug(f"SSDP receive error: {e}")
+
+                # Re-send M-SEARCH every 3 seconds
+                now = asyncio.get_event_loop().time()
+                if now - last_send >= 3.0:
+                    try:
+                        sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
+                        last_send = now
+                    except Exception as e:
+                        logger.debug(f"SSDP send error: {e}")
+
+                await asyncio.sleep(0.1)
+
+            logger.info(f"Discovery complete. Found {len(self._discovered)} printers.")
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.warning(f"Port {SSDP_PORT} is in use, trying alternative discovery...")
+                await self._discover_alternative(duration)
+            else:
+                logger.error(f"Discovery error: {e}")
+        except Exception as e:
+            logger.error(f"Discovery error: {e}")
+        finally:
+            self._running = False
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+
+    async def _discover_alternative(self, duration: float):
+        """Alternative discovery using a random port (less reliable)."""
+        sock = None
+        try:
+            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            sock.setblocking(False)
+            sock.bind(("", 0))
+
+            # Join multicast group
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info("Using alternative discovery method...")
+
+            start_time = asyncio.get_event_loop().time()
+            last_send = start_time
+
+            while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
+                try:
+                    data, addr = sock.recvfrom(4096)
+                    self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    logger.debug(f"SSDP receive error: {e}")
+
+                now = asyncio.get_event_loop().time()
+                if now - last_send >= 2.0:
+                    try:
+                        sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
+                        last_send = now
+                    except Exception:
+                        pass
+
+                await asyncio.sleep(0.1)
+
+            logger.info(f"Alternative discovery complete. Found {len(self._discovered)} printers.")
+        except Exception as e:
+            logger.error(f"Alternative discovery error: {e}")
+        finally:
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+
+    def _handle_response(self, response: str, ip_address: str):
+        """Parse SSDP response and extract printer info."""
+        # Check if it's a Bambu Lab printer response
+        if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
+            logger.debug(f"Ignoring non-Bambu response from {ip_address}")
+            return
+
+        # Extract USN (Unique Service Name) which contains the serial
+        # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
+        usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
+        if not usn_match:
+            logger.debug(f"No USN found in response from {ip_address}")
+            return
+
+        serial = usn_match.group(1).strip()
+
+        # Extract device name from LOCATION or DevName header
+        name = serial  # Default to serial if no name found
+        name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+        if name_match:
+            name = name_match.group(1).strip()
+
+        # Try to extract model from DevModel header
+        model = None
+        model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+        if model_match:
+            model = model_match.group(1).strip()
+
+        # Also try NT header for model
+        if not model:
+            nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
+            if nt_match:
+                model = nt_match.group(1).strip()
+
+        # Skip if already discovered
+        if serial in self._discovered:
+            return
+
+        printer = DiscoveredPrinter(
+            serial=serial,
+            name=name,
+            ip_address=ip_address,
+            model=model,
+            discovered_at=datetime.now().isoformat(),
+        )
+
+        self._discovered[serial] = printer
+        logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
+
+
+# Global discovery service instance
+discovery_service = PrinterDiscoveryService()

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

@@ -2016,3 +2016,30 @@ export interface SystemInfo {
     percent: number;
   };
 }
+
+// Discovery types
+export interface DiscoveredPrinter {
+  serial: string;
+  name: string;
+  ip_address: string;
+  model: string | null;
+  discovered_at: string | null;
+}
+
+export interface DiscoveryStatus {
+  running: boolean;
+}
+
+// Discovery API
+export const discoveryApi = {
+  getStatus: () => request<DiscoveryStatus>('/discovery/status'),
+
+  startDiscovery: (duration: number = 10) =>
+    request<DiscoveryStatus>(`/discovery/start?duration=${duration}`, { method: 'POST' }),
+
+  stopDiscovery: () =>
+    request<DiscoveryStatus>('/discovery/stop', { method: 'POST' }),
+
+  getDiscoveredPrinters: () =>
+    request<DiscoveredPrinter[]>('/discovery/printers'),
+};

+ 182 - 2
frontend/src/pages/PrintersPage.tsx

@@ -26,10 +26,12 @@ import {
   LayoutList,
   Layers,
   Video,
+  Search,
+  Loader2,
 } from 'lucide-react';
 import { useNavigate } from 'react-router-dom';
-import { api } from '../api/client';
-import type { Printer, PrinterCreate, AMSUnit } from '../api/client';
+import { api, discoveryApi } from '../api/client';
+import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -1425,9 +1427,11 @@ function PrinterCard({
 function AddPrinterModal({
   onClose,
   onAdd,
+  existingSerials,
 }: {
   onClose: () => void;
   onAdd: (data: PrinterCreate) => void;
+  existingSerials: string[];
 }) {
   const [form, setForm] = useState<PrinterCreate>({
     name: '',
@@ -1438,6 +1442,114 @@ function AddPrinterModal({
     auto_archive: true,
   });
 
+  // Discovery state
+  const [discovering, setDiscovering] = useState(false);
+  const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);
+  const [discoveryError, setDiscoveryError] = useState('');
+  const [hasScanned, setHasScanned] = useState(false);
+
+  // Filter out already-added printers
+  const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
+
+  const startDiscovery = async () => {
+    setDiscoveryError('');
+    setDiscovered([]);
+    setDiscovering(true);
+    setHasScanned(false);
+
+    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);
+    } catch (e) {
+      console.error('Failed to start discovery:', e);
+      setDiscoveryError(e instanceof Error ? e.message : 'Failed to start discovery');
+      setDiscovering(false);
+      setHasScanned(true);
+    }
+  };
+
+  // Map SSDP model codes to dropdown values
+  const mapModelCode = (ssdpModel: string | null): string => {
+    if (!ssdpModel) return '';
+    const modelMap: Record<string, string> = {
+      // H2 Series
+      'O1D': 'H2D',
+      'O1C': 'H2C',
+      'O1S': 'H2S',
+      // X1 Series
+      'BL-P001': 'X1C',
+      'BL-P002': 'X1',
+      'BL-P003': 'X1E',
+      // P Series
+      'C11': 'P1S',
+      'C12': 'P1P',
+      'C13': 'P2S',
+      // A1 Series
+      'N2S': 'A1',
+      'N1': 'A1 Mini',
+      // Direct matches
+      'X1C': 'X1C',
+      'X1': 'X1',
+      'X1E': 'X1E',
+      'P1S': 'P1S',
+      'P1P': 'P1P',
+      'P2S': 'P2S',
+      'A1': 'A1',
+      'A1 Mini': 'A1 Mini',
+      'H2D': 'H2D',
+      'H2C': 'H2C',
+      'H2S': 'H2S',
+    };
+    return modelMap[ssdpModel] || ssdpModel;
+  };
+
+  const selectPrinter = (printer: DiscoveredPrinter) => {
+    setForm({
+      ...form,
+      name: printer.name || '',
+      serial_number: printer.serial,
+      ip_address: printer.ip_address,
+      model: mapModelCode(printer.model),
+    });
+    // Clear discovery results after selection
+    setDiscovered([]);
+  };
+
+  // Cleanup discovery on unmount
+  useEffect(() => {
+    return () => {
+      discoveryApi.stopDiscovery().catch(() => {});
+    };
+  }, []);
+
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1455,6 +1567,73 @@ function AddPrinterModal({
       <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
         <CardContent>
           <h2 className="text-xl font-semibold mb-4">Add Printer</h2>
+
+          {/* Discovery Section */}
+          <div className="mb-4 pb-4 border-b border-bambu-dark-tertiary">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={startDiscovery}
+              disabled={discovering}
+              className="w-full"
+            >
+              {discovering ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Scanning...
+                </>
+              ) : (
+                <>
+                  <Search className="w-4 h-4" />
+                  Discover Printers on Network
+                </>
+              )}
+            </Button>
+
+            {discoveryError && (
+              <div className="mt-2 text-sm text-red-400">{discoveryError}</div>
+            )}
+
+            {newPrinters.length > 0 && (
+              <div className="mt-3 space-y-2 max-h-40 overflow-y-auto">
+                {newPrinters.map((printer) => (
+                  <div
+                    key={printer.serial}
+                    className="flex items-center justify-between p-2 bg-bambu-dark rounded-lg hover:bg-bambu-dark-secondary cursor-pointer transition-colors"
+                    onClick={() => selectPrinter(printer)}
+                  >
+                    <div className="min-w-0 flex-1">
+                      <p className="font-medium text-white text-sm truncate">
+                        {printer.name || printer.serial}
+                      </p>
+                      <p className="text-xs text-bambu-gray truncate">
+                        {printer.model || 'Unknown'} • {printer.ip_address}
+                      </p>
+                    </div>
+                    <ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />
+                  </div>
+                ))}
+              </div>
+            )}
+
+            {discovering && (
+              <p className="mt-2 text-sm text-bambu-gray text-center">
+                Scanning network...
+              </p>
+            )}
+
+            {hasScanned && !discovering && discovered.length === 0 && (
+              <p className="mt-2 text-sm text-bambu-gray text-center">
+                No printers found on the network.
+              </p>
+            )}
+
+            {hasScanned && !discovering && discovered.length > 0 && newPrinters.length === 0 && (
+              <p className="mt-2 text-sm text-bambu-gray text-center">
+                All discovered printers are already configured.
+              </p>
+            )}
+          </div>
           <form
             onSubmit={(e) => {
               e.preventDefault();
@@ -2152,6 +2331,7 @@ export function PrintersPage() {
         <AddPrinterModal
           onClose={() => setShowAddModal(false)}
           onAdd={(data) => addMutation.mutate(data)}
+          existingSerials={printers?.map(p => p.serial_number) || []}
         />
       )}
     </div>

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


+ 1 - 1
static/index.html

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

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