mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +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
409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""
|
|
Firmware Update Service
|
|
|
|
Orchestrates firmware updates for Bambu Lab printers:
|
|
1. Check prerequisites (SD card, space, update available)
|
|
2. Download firmware from Bambu Lab
|
|
3. Upload to printer's SD card via FTP
|
|
4. Notify user to trigger update from printer screen
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.compat import StrEnum
|
|
from backend.app.core.tasks import spawn_background_task
|
|
from backend.app.core.websocket import ws_manager
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.services.bambu_ftp import (
|
|
get_ftp_retry_settings,
|
|
get_storage_info_async,
|
|
upload_file_async,
|
|
with_ftp_retry,
|
|
)
|
|
from backend.app.services.firmware_check import get_firmware_service
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FirmwareUploadStatus(StrEnum):
|
|
"""Status of a firmware upload operation."""
|
|
|
|
IDLE = "idle"
|
|
PREPARING = "preparing"
|
|
DOWNLOADING = "downloading"
|
|
UPLOADING = "uploading"
|
|
COMPLETE = "complete"
|
|
ERROR = "error"
|
|
|
|
|
|
@dataclass
|
|
class FirmwareUploadState:
|
|
"""State of a firmware upload operation for a printer."""
|
|
|
|
status: FirmwareUploadStatus = FirmwareUploadStatus.IDLE
|
|
progress: int = 0 # 0-100
|
|
message: str = ""
|
|
error: str | None = None
|
|
firmware_filename: str | None = None
|
|
firmware_version: str | None = None
|
|
|
|
|
|
# Track upload state per printer
|
|
_upload_states: dict[int, FirmwareUploadState] = {}
|
|
|
|
|
|
def get_upload_state(printer_id: int) -> FirmwareUploadState:
|
|
"""Get the current upload state for a printer."""
|
|
if printer_id not in _upload_states:
|
|
_upload_states[printer_id] = FirmwareUploadState()
|
|
return _upload_states[printer_id]
|
|
|
|
|
|
def reset_upload_state(printer_id: int):
|
|
"""Reset the upload state for a printer."""
|
|
_upload_states[printer_id] = FirmwareUploadState()
|
|
|
|
|
|
class FirmwareUpdateService:
|
|
"""Service for managing firmware updates."""
|
|
|
|
# Minimum free space required (100MB buffer)
|
|
MIN_FREE_SPACE_BYTES = 100 * 1024 * 1024
|
|
|
|
async def prepare_update(
|
|
self,
|
|
printer_id: int,
|
|
db: AsyncSession,
|
|
target_version: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Check prerequisites for firmware update.
|
|
|
|
Returns:
|
|
Dict with:
|
|
- can_proceed: bool
|
|
- sd_card_present: bool
|
|
- sd_card_free_space: int (bytes, -1 if unknown)
|
|
- firmware_size: int (bytes, estimated)
|
|
- space_sufficient: bool
|
|
- update_available: bool
|
|
- current_version: str | None
|
|
- latest_version: str | None
|
|
- firmware_filename: str | None
|
|
- errors: list[str]
|
|
"""
|
|
result = {
|
|
"can_proceed": False,
|
|
"sd_card_present": False,
|
|
"sd_card_free_space": -1,
|
|
"firmware_size": 0,
|
|
"space_sufficient": False,
|
|
"update_available": False,
|
|
"current_version": None,
|
|
"latest_version": None,
|
|
"target_version": target_version,
|
|
"firmware_filename": None,
|
|
"errors": [],
|
|
}
|
|
|
|
# Get printer from database
|
|
stmt = select(Printer).where(Printer.id == printer_id)
|
|
db_result = await db.execute(stmt)
|
|
printer = db_result.scalar_one_or_none()
|
|
|
|
if not printer:
|
|
result["errors"].append("Printer not found")
|
|
return result
|
|
|
|
# Check printer is connected
|
|
mqtt_client = printer_manager.get_client(printer_id)
|
|
if not mqtt_client or not mqtt_client.state:
|
|
result["errors"].append("Printer not connected")
|
|
return result
|
|
|
|
state = mqtt_client.state
|
|
|
|
# Get current firmware version
|
|
result["current_version"] = state.firmware_version
|
|
|
|
# Check SD card
|
|
result["sd_card_present"] = state.sdcard
|
|
if not state.sdcard:
|
|
result["errors"].append("No SD card inserted in printer")
|
|
|
|
# Get storage info via FTP
|
|
if state.sdcard:
|
|
try:
|
|
storage_info = await get_storage_info_async(
|
|
printer.ip_address,
|
|
printer.access_code,
|
|
printer_model=printer.model,
|
|
)
|
|
if storage_info and "free_bytes" in storage_info:
|
|
result["sd_card_free_space"] = storage_info["free_bytes"]
|
|
except Exception as e:
|
|
logger.warning("Could not get storage info: %s", e)
|
|
|
|
# Check for firmware update
|
|
firmware_service = get_firmware_service()
|
|
model = printer.model or "Unknown"
|
|
|
|
if state.firmware_version:
|
|
update_info = await firmware_service.check_for_update(model, state.firmware_version)
|
|
result["update_available"] = update_info["update_available"]
|
|
result["latest_version"] = update_info["latest_version"]
|
|
else:
|
|
# If we don't know current version, just get latest
|
|
latest = await firmware_service.get_latest_version(model)
|
|
if latest:
|
|
result["latest_version"] = latest.version
|
|
result["update_available"] = True # Assume update needed
|
|
|
|
# Get firmware file info (for target_version if specified, else latest)
|
|
file_info = await firmware_service.get_firmware_file_info(model, version=target_version)
|
|
if file_info:
|
|
result["firmware_filename"] = file_info["filename"]
|
|
# Estimate size (typical firmware is 50-150MB)
|
|
# We'll get actual size during download
|
|
result["firmware_size"] = 100 * 1024 * 1024 # 100MB estimate
|
|
elif target_version:
|
|
# Requested specific version has no download URL. Distinguish
|
|
# "Bambu doesn't list this file" from "we couldn't reach Bambu's
|
|
# download page" (Cloudflare 403 reported in #1350) so users in
|
|
# affected regions get an actionable error instead of believing
|
|
# the firmware doesn't exist.
|
|
if firmware_service.download_page_unreachable:
|
|
result["errors"].append(
|
|
f"Could not reach Bambu Lab's firmware download page to fetch the file URL for "
|
|
f"{target_version}. Version is listed on the Bambu wiki but the download endpoint "
|
|
f"is unreachable from this network. Try again later, or download the firmware "
|
|
f"manually from bambulab.com and copy it to the printer's SD card."
|
|
)
|
|
else:
|
|
result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
|
|
|
|
# If a target version is requested, allow proceeding even if it equals or
|
|
# is older than the current version (explicit downgrade/reinstall).
|
|
if target_version:
|
|
result["update_available"] = bool(file_info)
|
|
elif not result["update_available"]:
|
|
result["errors"].append("Firmware is already up to date")
|
|
|
|
# Check space
|
|
if result["sd_card_free_space"] > 0:
|
|
# Need firmware size + buffer
|
|
required = result["firmware_size"] + self.MIN_FREE_SPACE_BYTES
|
|
result["space_sufficient"] = result["sd_card_free_space"] >= required
|
|
if not result["space_sufficient"]:
|
|
result["errors"].append(
|
|
f"Insufficient SD card space. Need {required // (1024 * 1024)}MB, "
|
|
f"have {result['sd_card_free_space'] // (1024 * 1024)}MB"
|
|
)
|
|
elif result["sd_card_present"]:
|
|
# Couldn't determine space, assume sufficient
|
|
result["space_sufficient"] = True
|
|
|
|
# Final check
|
|
result["can_proceed"] = (
|
|
result["sd_card_present"]
|
|
and result["space_sufficient"]
|
|
and result["update_available"]
|
|
and len(result["errors"]) == 0
|
|
)
|
|
|
|
return result
|
|
|
|
async def start_upload(
|
|
self,
|
|
printer_id: int,
|
|
db: AsyncSession,
|
|
target_version: str | None = None,
|
|
) -> bool:
|
|
"""
|
|
Start the firmware upload process.
|
|
|
|
This runs asynchronously and broadcasts progress via WebSocket.
|
|
Returns True if upload started successfully.
|
|
"""
|
|
state = get_upload_state(printer_id)
|
|
|
|
# Check if already in progress
|
|
if state.status in (FirmwareUploadStatus.DOWNLOADING, FirmwareUploadStatus.UPLOADING):
|
|
logger.warning("Firmware upload already in progress for printer %s", printer_id)
|
|
return False
|
|
|
|
# Get printer
|
|
stmt = select(Printer).where(Printer.id == printer_id)
|
|
db_result = await db.execute(stmt)
|
|
printer = db_result.scalar_one_or_none()
|
|
|
|
if not printer:
|
|
state.status = FirmwareUploadStatus.ERROR
|
|
state.error = "Printer not found"
|
|
return False
|
|
|
|
# Get printer model
|
|
model = printer.model or "Unknown"
|
|
|
|
# Reset state
|
|
reset_upload_state(printer_id)
|
|
state = get_upload_state(printer_id)
|
|
state.status = FirmwareUploadStatus.PREPARING
|
|
state.message = "Preparing firmware update..."
|
|
await self._broadcast_progress(printer_id, state)
|
|
|
|
# Run the upload in background
|
|
spawn_background_task(
|
|
self._do_upload(
|
|
printer_id=printer_id,
|
|
ip_address=printer.ip_address,
|
|
access_code=printer.access_code,
|
|
model=model,
|
|
target_version=target_version,
|
|
),
|
|
name=f"firmware-upload-{printer_id}",
|
|
)
|
|
|
|
return True
|
|
|
|
async def _do_upload(
|
|
self,
|
|
printer_id: int,
|
|
ip_address: str,
|
|
access_code: str,
|
|
model: str,
|
|
target_version: str | None = None,
|
|
):
|
|
"""Perform the actual firmware download and upload."""
|
|
state = get_upload_state(printer_id)
|
|
firmware_service = get_firmware_service()
|
|
|
|
try:
|
|
# Download firmware (quick, usually cached)
|
|
state.status = FirmwareUploadStatus.DOWNLOADING
|
|
state.progress = 0
|
|
state.message = "Preparing firmware..."
|
|
await self._broadcast_progress(printer_id, state)
|
|
|
|
firmware_path = await firmware_service.download_firmware(model, version=target_version)
|
|
|
|
if not firmware_path:
|
|
raise Exception("Failed to download firmware")
|
|
|
|
state.firmware_filename = firmware_path.name
|
|
|
|
# Get firmware version for state
|
|
if target_version:
|
|
state.firmware_version = target_version
|
|
else:
|
|
latest = await firmware_service.get_latest_version(model)
|
|
if latest:
|
|
state.firmware_version = latest.version
|
|
|
|
# Upload to printer (0-100% progress shown here)
|
|
state.status = FirmwareUploadStatus.UPLOADING
|
|
state.progress = 0
|
|
state.message = f"Uploading {firmware_path.name} to printer..."
|
|
await self._broadcast_progress(printer_id, state)
|
|
|
|
# Upload to root of SD card (where printer expects firmware)
|
|
remote_path = f"/{firmware_path.name}"
|
|
|
|
logger.info("Uploading firmware to printer %s: %s", printer_id, remote_path)
|
|
|
|
# Track real progress via FTP callback
|
|
loop = asyncio.get_event_loop()
|
|
last_progress = 0
|
|
|
|
def on_upload_progress(uploaded: int, total: int):
|
|
nonlocal last_progress
|
|
if total > 0:
|
|
progress = int((uploaded / total) * 100)
|
|
# Only broadcast every 1% to avoid flooding
|
|
if progress > last_progress:
|
|
last_progress = progress
|
|
state.progress = min(99, progress) # Cap at 99 until complete
|
|
asyncio.run_coroutine_threadsafe(self._broadcast_progress(printer_id, state), loop)
|
|
|
|
# Get FTP retry settings
|
|
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
|
|
|
if ftp_retry_enabled:
|
|
success = await with_ftp_retry(
|
|
upload_file_async,
|
|
ip_address,
|
|
access_code,
|
|
firmware_path,
|
|
remote_path,
|
|
progress_callback=on_upload_progress,
|
|
socket_timeout=ftp_timeout,
|
|
printer_model=model,
|
|
max_retries=ftp_retry_count,
|
|
retry_delay=ftp_retry_delay,
|
|
operation_name=f"Upload firmware to printer {printer_id}",
|
|
)
|
|
else:
|
|
success = await upload_file_async(
|
|
ip_address,
|
|
access_code,
|
|
firmware_path,
|
|
remote_path,
|
|
progress_callback=on_upload_progress,
|
|
socket_timeout=ftp_timeout,
|
|
printer_model=model,
|
|
)
|
|
|
|
if not success:
|
|
raise Exception("Failed to upload firmware to printer")
|
|
|
|
# Complete
|
|
state.status = FirmwareUploadStatus.COMPLETE
|
|
state.progress = 100
|
|
state.message = (
|
|
f"Firmware {state.firmware_version or ''} uploaded successfully! "
|
|
"Please go to printer screen and trigger the update from Settings > Firmware."
|
|
)
|
|
await self._broadcast_progress(printer_id, state)
|
|
|
|
logger.info("Firmware upload complete for printer %s", printer_id)
|
|
|
|
except Exception as e:
|
|
logger.error("Firmware upload failed for printer %s: %s", printer_id, e)
|
|
state.status = FirmwareUploadStatus.ERROR
|
|
state.error = str(e)
|
|
state.message = f"Firmware upload failed: {e}"
|
|
await self._broadcast_progress(printer_id, state)
|
|
|
|
async def _broadcast_progress(self, printer_id: int, state: FirmwareUploadState):
|
|
"""Broadcast firmware upload progress via WebSocket."""
|
|
await ws_manager.broadcast(
|
|
{
|
|
"type": "firmware_upload_progress",
|
|
"printer_id": printer_id,
|
|
"status": state.status.value,
|
|
"progress": state.progress,
|
|
"message": state.message,
|
|
"error": state.error,
|
|
"firmware_filename": state.firmware_filename,
|
|
"firmware_version": state.firmware_version,
|
|
}
|
|
)
|
|
|
|
|
|
# Singleton instance
|
|
_firmware_update_service: FirmwareUpdateService | None = None
|
|
|
|
|
|
def get_firmware_update_service() -> FirmwareUpdateService:
|
|
"""Get the singleton firmware update service instance."""
|
|
global _firmware_update_service
|
|
if _firmware_update_service is None:
|
|
_firmware_update_service = FirmwareUpdateService()
|
|
return _firmware_update_service
|