- Added printer auto discovery to add printer modal

This commit is contained in:
maziggy
2025-12-21 11:23:20 +01:00
parent 0da5de0f46
commit ef68804fa9
7 changed files with 979 additions and 420 deletions
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>