- 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)
This commit is contained in:
maziggy
2025-12-28 09:40:47 +01:00
parent 017e36a0a6
commit 8acc91d3cd
10 changed files with 673 additions and 119 deletions
+105 -6
View File
@@ -2,6 +2,7 @@
Printer discovery API endpoints.
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
@@ -9,7 +10,11 @@ import logging
from fastapi import APIRouter
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__)
router = APIRouter(prefix="/discovery", tags=["discovery"])
@@ -21,6 +26,29 @@ class DiscoveryStatus(BaseModel):
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):
"""Discovered printer response."""
@@ -31,15 +59,25 @@ class DiscoveredPrinterResponse(BaseModel):
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)
async def get_discovery_status():
"""Get the current discovery status."""
"""Get the current SSDP 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.
"""Start SSDP printer discovery.
Args:
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)
async def stop_discovery():
"""Stop printer discovery."""
"""Stop SSDP 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."""
"""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 [
DiscoveredPrinterResponse(
serial=p.serial,
@@ -66,5 +116,54 @@ async def get_discovered_printers():
model=p.model,
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
View File
@@ -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)
on the local network. This service listens for these advertisements and provides
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 ipaddress
import logging
import os
import re
import socket
import struct
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
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_ADDR = "239.255.255.250"
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}")
# 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()
subnet_scanner = SubnetScanner()
@@ -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"]