mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
A failed TLS handshake arms a 300s per-IP cool-off, and connect() consulted it for every caller. A print dispatch retries after 2s, so once the cool-off was armed all four attempts were answered from the gate rather than the network, and every further job queued for that printer failed the same way for the rest of the window. The reporter's farm lost three jobs to one handshake error, with the retry budget contributing nothing to any of them. The gate was serving two callers that want opposite things from it. The background sweeps -- the post-print 3MF, cover and timelapse fetches -- walk ~110 candidate paths against one wedged printer with nobody waiting, and backing off for minutes is right for them. A dispatch is one delete plus at most four upload attempts with someone watching a progress bar. So the split is by caller: a client built with respect_handshake_cooloff=False goes to the printer regardless, and the dispatch's delete and upload -- and a firmware upload, same shape -- opt out. Everything else keeps #2780's behaviour untouched. In the reported trace it is the pre-upload delete that takes the SSL error and arms the cool-off, 8ms before the upload's first attempt, so exempting the upload alone would have left one dispatch's worth of the problem in place. Callers that do respect the cool-off no longer sleep out a retry loop against it: with_ftp_retry takes the printer's IP and stops at the attempt that armed the gate, instead of spending three more attempts and six seconds on connections that cannot happen. It also reports the attempts it really made -- "failed after 4 attempts" for one attempt is part of how this read as a network problem. Two diagnosis fixes go with it. The cool-off skip was the one connect() failure path that reported without naming its cause, and at DEBUG, so four identical reason-free warnings were all the operator saw. It now says at WARNING that nothing was sent and how long the printer has left, once per cool-off rather than once per attempt -- not every caller is gated, and a download-zip of 200 files would otherwise repeat the sentence 200 times, which is the flood #2780 set out to stop. And a dispatch that fails this way no longer tells anyone to check whether the SD card is inserted and formatted -- nothing reached the printer's filesystem, so the card is the one part of the machine that was working. The message names the file service and rules the card out. It is used only when a handshake failed during the dispatch itself, read from the cool-off deadline MOVING rather than merely being armed: the dispatch ignores the gate, so it can be running underneath one an unrelated background fetch left behind, and blaming TLS for an upload that really hit a full disk would repeat the mistake in the other direction. Tests count sockets rather than return values, since "returned False" looks identical whether or not anything was attempted -- which is what made the original report a log dive. Reverting any one of the five behaviours above fails a distinct test.
415 lines
15 KiB
Python
415 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,
|
|
# Someone pressed "update firmware" and is watching a
|
|
# progress bar. Bounded and user-initiated, like a print
|
|
# dispatch, so it does not spend its retries on a cool-off
|
|
# meant for the background sweeps (#2898).
|
|
respect_handshake_cooloff=False,
|
|
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,
|
|
respect_handshake_cooloff=False,
|
|
)
|
|
|
|
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
|