mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
asyncio holds only a weak reference to tasks returned by ``create_task``. Fire-and-forget callers that discard the return value let the event loop GC the task before it finishes, logging ``Task was destroyed but it is pending!`` with no traceback. The #1648 support-bundle review surfaced 94 such warnings in 8 days of v0.2.4.5 -- the silently-vanished exceptions reach support bundles as opaque GC notices instead of actionable errors. New backend/app/core/tasks.py::spawn_background_task(coro, *, name=None) is the one place in the codebase that calls asyncio.create_task. It stores the task in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts name= so a leak source is traceable through /tracebacks and the log line. Cancelled tasks don't log (a shutting-down service is not an error). Migrated the 16 truly-orphan create_task call sites to the helper: main.py (8): reconcile-stale, cooldown-poweroff, energy calc, smart-plug, maintenance-check, photo-then-notify, layer-timelapse, scan-timelapse, print-scheduler, notify-no-archive (the last one was hand-rolling the same pattern with task + no-op done_callback) printers.py:3123 apply-pa-after-refresh print_queue.py:1034 queue cooldown-poweroff firmware_update.py:261 firmware upload archive.py:1514 timelapse mp4 convert print_scheduler.py:2199 watchdog print-start library.py:1614 STL backfill smart_plugs.py:259 tasmota scan discovery.py:159 subnet scan smart_plug_manager.py x3 plug auto-off-pending background_dispatch.py x2 (lambda-wrapped inside loop.call_soon_threadsafe) upload progress Sites that already kept strong refs are unchanged: self._tasks.append(asyncio.create_task(...)) -- VP manager, tcp_proxy, mqtt_server self._x_task = asyncio.create_task(...) on service instances -- mqtt_bridge, obico_detection, github_backup, archive_purge, local_backup, library_trash, discovery service Locally assigned + awaited/gathered -- tcp_proxy bidirectional pumps, camera_fanout, slice_dispatch, slicer_api progress_task, manager._finish_release_task, main.py module-level cleanup loops
197 lines
5.5 KiB
Python
197 lines
5.5 KiB
Python
"""
|
|
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
|
|
|
|
from fastapi import APIRouter
|
|
from pydantic import BaseModel
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.core.tasks import spawn_background_task
|
|
from backend.app.models.user import User
|
|
from backend.app.services.discovery import (
|
|
discovery_service,
|
|
is_running_in_docker,
|
|
subnet_scanner,
|
|
)
|
|
from backend.app.services.network_utils import get_network_interfaces
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
|
|
|
|
|
class DiscoveryStatus(BaseModel):
|
|
"""Discovery status response."""
|
|
|
|
running: bool
|
|
|
|
|
|
class DiscoveryInfo(BaseModel):
|
|
"""Discovery environment info."""
|
|
|
|
is_docker: bool
|
|
ssdp_running: bool
|
|
scan_running: bool
|
|
subnets: list[str] = []
|
|
|
|
|
|
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."""
|
|
|
|
serial: str
|
|
name: str
|
|
ip_address: str
|
|
model: str | None = None
|
|
discovered_at: str | None = None
|
|
|
|
|
|
@router.get("/info", response_model=DiscoveryInfo)
|
|
async def get_discovery_info(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""Get discovery environment info (Docker detection, etc.)."""
|
|
subnets = [iface["subnet"] for iface in get_network_interfaces()]
|
|
return DiscoveryInfo(
|
|
is_docker=is_running_in_docker(),
|
|
ssdp_running=discovery_service.is_running,
|
|
scan_running=subnet_scanner.is_running,
|
|
subnets=subnets,
|
|
)
|
|
|
|
|
|
@router.get("/status", response_model=DiscoveryStatus)
|
|
async def get_discovery_status(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""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,
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""Start SSDP 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(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""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(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""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,
|
|
name=p.name,
|
|
ip_address=p.ip_address,
|
|
model=p.model,
|
|
discovered_at=p.discovered_at,
|
|
)
|
|
for p in printers.values()
|
|
]
|
|
|
|
|
|
# Subnet scanning endpoints (for Docker environments)
|
|
|
|
|
|
@router.post("/scan", response_model=SubnetScanStatus)
|
|
async def start_subnet_scan(
|
|
request: SubnetScanRequest,
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""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
|
|
spawn_background_task(
|
|
subnet_scanner.scan_subnet(request.subnet, request.timeout),
|
|
name=f"subnet-scan-{request.subnet}",
|
|
)
|
|
|
|
# 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(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
|
):
|
|
"""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(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_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,
|
|
)
|