mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
- 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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user