mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +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,20 @@
|
||||
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -288,7 +288,7 @@ server {
|
||||
|
||||
> **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
|
||||
services:
|
||||
@@ -297,6 +297,8 @@ services:
|
||||
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>
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
@@ -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"]
|
||||
+9
-5
@@ -2,11 +2,15 @@ services:
|
||||
bambuddy:
|
||||
build: .
|
||||
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:
|
||||
- bambuddy_data:/app/data
|
||||
- bambuddy_logs:/app/logs
|
||||
|
||||
@@ -2190,8 +2190,22 @@ export interface DiscoveryStatus {
|
||||
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
|
||||
export const discoveryApi = {
|
||||
getInfo: () => request<DiscoveryInfo>('/discovery/info'),
|
||||
|
||||
getStatus: () => request<DiscoveryStatus>('/discovery/status'),
|
||||
|
||||
startDiscovery: (duration: number = 10) =>
|
||||
@@ -2202,4 +2216,16 @@ export const discoveryApi = {
|
||||
|
||||
getDiscoveredPrinters: () =>
|
||||
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' }),
|
||||
};
|
||||
|
||||
@@ -1496,6 +1496,18 @@ function AddPrinterModal({
|
||||
const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);
|
||||
const [discoveryError, setDiscoveryError] = useState('');
|
||||
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
|
||||
const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
|
||||
@@ -1505,38 +1517,64 @@ function AddPrinterModal({
|
||||
setDiscovered([]);
|
||||
setDiscovering(true);
|
||||
setHasScanned(false);
|
||||
setScanProgress({ scanned: 0, total: 0 });
|
||||
|
||||
try {
|
||||
await discoveryApi.startDiscovery(10);
|
||||
if (isDocker) {
|
||||
// Use subnet scanning for Docker
|
||||
await discoveryApi.startSubnetScan(subnet);
|
||||
|
||||
// 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);
|
||||
// Poll for scan status and results
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await discoveryApi.getScanStatus();
|
||||
setScanProgress({ scanned: status.scanned, total: status.total });
|
||||
|
||||
// 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);
|
||||
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) {
|
||||
console.error('Failed to start discovery:', e);
|
||||
setDiscoveryError(e instanceof Error ? e.message : 'Failed to start discovery');
|
||||
@@ -1596,6 +1634,7 @@ function AddPrinterModal({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
discoveryApi.stopDiscovery().catch(() => {});
|
||||
discoveryApi.stopSubnetScan().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1619,6 +1658,26 @@ function AddPrinterModal({
|
||||
|
||||
{/* Discovery Section */}
|
||||
<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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -1629,12 +1688,14 @@ function AddPrinterModal({
|
||||
{discovering ? (
|
||||
<>
|
||||
<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" />
|
||||
Discover Printers on Network
|
||||
{isDocker ? 'Scan Subnet for Printers' : 'Discover Printers on Network'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -1656,7 +1717,7 @@ function AddPrinterModal({
|
||||
{printer.name || printer.serial}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray truncate">
|
||||
{printer.model || 'Unknown'} • {printer.ip_address}
|
||||
{mapModelCode(printer.model) || 'Unknown'} • {printer.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />
|
||||
@@ -1667,13 +1728,13 @@ function AddPrinterModal({
|
||||
|
||||
{discovering && (
|
||||
<p className="mt-2 text-sm text-bambu-gray text-center">
|
||||
Scanning network...
|
||||
{isDocker ? 'Scanning subnet for Bambu printers...' : '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.
|
||||
No printers found{isDocker ? ' in the specified subnet' : ' on the network'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-BCPmsaqF.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DpxuzRSm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CUXYdb1N.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user