import asyncio import logging import re import secrets import zipfile from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse, Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from starlette.background import BackgroundTask from backend.app.core import database from backend.app.core.auth import ( RequireCameraStreamTokenIfAuthEnabled, RequireOverlayTokenIfAuthEnabled, RequirePermissionIfAuthEnabled, RequirePrinterPermissionIfAuthEnabled, is_auth_enabled, ) from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.core.tasks import spawn_background_task from backend.app.models.ams_label import AmsLabel from backend.app.models.printer import Printer from backend.app.models.slot_preset import SlotPresetMapping from backend.app.models.user import User from backend.app.schemas.printer import ( AmsLabelBody, AMSTray, AMSUnit, DiagnosticRequest, ExtruderSlotResponse, FilaSwitchResponse, HmsActionBody, HMSErrorResponse, NozzleInfoResponse, NozzleRackSlot, PrinterCreate, PrinterDiagnosticResult, PrinterFilesDownloadRequest, PrinterFilesJobRequest, PrinterResponse, PrinterResponseWithSecret, PrinterStatus, PrinterUpdate, PrintOptionsResponse, ) from backend.app.services import drying_preflight from backend.app.services.bambu_ftp import ( cache_3mf_download, delete_file_async, download_file_bytes_async, download_file_try_paths_async, ftps_handshake_blocked, get_cached_3mf, get_storage_info_async, list_files_result_async, ) from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp from backend.app.services.printer_diagnostic import run_connection_diagnostic from backend.app.services.printer_manager import ( display_temperatures, drying_screen_only, get_derived_status_name, printer_manager, resolve_expected_tray, resolve_plate_id, supports_chamber_heater, supports_chamber_temp, supports_drying, supports_drying_while_printing, uniform_tray_filament_hint, ) from backend.app.services.printer_media import ( MAX_PRINTER_ZIP_PREPARE_SECONDS, PrinterFilesZipInsufficientSpaceError, PrinterFilesZipTooLargeError, build_printer_file, build_printer_files_zip, cancel_printer_files_job, get_printer_files_job, printer_file_path, printer_files_zip_path, remove_printer_files_zip, start_printer_files_job, ) from backend.app.services.slot_nozzle import resolve_slot_nozzle from backend.app.utils.filament_ids import filament_id_to_setting_id from backend.app.utils.filament_types import printer_filament_type from backend.app.utils.fts_routing import slot_extruder from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename from backend.app.utils.kprofile_lookup import build_slot_k_resolver from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label logger = logging.getLogger(__name__) router = APIRouter(prefix="/printers", tags=["printers"]) # Seconds the /hms/execute-action route waits for a printer status push # confirming the command landed before reporting 502 to the UI. Module-level # so tests can monkeypatch a near-zero value instead of mocking asyncio.sleep. HMS_ACTION_ACK_WAIT_SECONDS = 2.5 async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool: """Whether the caller is trusted enough to see ``access_code`` on a printer response. Fail-CLOSED: anything that isn't an authenticated user holding PRINTERS_UPDATE returns False. - Auth disabled → True (single trust domain — same as today's local UI). - JWT user with PRINTERS_UPDATE → True (Admin or Operator; the same roles that already manage printers and the Virtual Printer card UX that surfaces a target's code for slicer configuration). - JWT Viewer → False (the bug fix: Viewers must not be able to read access_code via PRINTERS_READ and then go around Bambuddy to MQTT). - API-key principal (``user is None`` because the dep returns None for API keys) → False. PRINTERS_UPDATE is admin-only and absent from ``_APIKEY_SCOPE_BY_PERMISSION``, so no API key can hold it. """ if not await is_auth_enabled(db): return True if user is None: return False return user.has_permission(Permission.PRINTERS_UPDATE.value) def _serialize_printer(printer: Printer, *, include_secret: bool): """Build the response shape that matches the caller's authority.""" if include_secret: return PrinterResponseWithSecret.model_validate(printer) return PrinterResponse.model_validate(printer) @router.get("/") async def list_printers( user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """List all configured printers. ``access_code`` is included in each item only when the caller is trusted to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and API keys never receive it. """ result = await db.execute(select(Printer).order_by(Printer.name)) printers = list(result.scalars().all()) include_secret = await _caller_can_view_printer_secrets(user, db) return [_serialize_printer(p, include_secret=include_secret) for p in printers] @router.post("/", response_model=PrinterResponse) async def create_printer( printer_data: PrinterCreate, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE), db: AsyncSession = Depends(get_db), ): """Add a new printer. Verifies the MQTT connection succeeds before persisting. A wrong access code or unreachable IP would otherwise create a printer row that shows as an empty / never-connecting card on the dashboard — those reports were turning into support tickets that all traced back to a mistyped access code. """ # Check if serial number already exists result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number)) if result.scalar_one_or_none(): raise HTTPException(400, "Printer with this serial number already exists") test_result = await printer_manager.test_connection( ip_address=printer_data.ip_address, serial_number=printer_data.serial_number, access_code=printer_data.access_code, ) if not test_result.get("success"): # The frontend renders the user-facing message via i18n on `code`; # `message` is an English fallback for non-UI clients (curl / scripts). raise HTTPException( status_code=400, detail={ "code": "printer_connection_failed", "message": ( "Could not connect to the printer. Verify IP address, serial number, " "and access code, and confirm LAN-only mode is enabled. " "The printer was not added." ), }, ) printer = Printer(**printer_data.model_dump()) db.add(printer) await db.commit() await db.refresh(printer) # Connect to the printer if printer.is_active: await printer_manager.connect_printer(printer) return printer @router.get("/usb-cameras") async def list_usb_cameras( _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), ): """List available USB cameras connected to the system. Returns a list of detected V4L2 video devices with their info. Only works on Linux systems with V4L2 support. Returns: List of dicts with {device: str, name: str, capabilities: list, formats?: list} """ from backend.app.services.external_camera import list_usb_cameras cameras = list_usb_cameras() return {"cameras": cameras} @router.get("/available-filaments") async def get_available_filaments( model: str = Query(..., description="Target printer model"), location: str | None = Query(None, description="Optional location filter"), _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE), db: AsyncSession = Depends(get_db), ): """Get deduplicated list of filaments loaded across all active printers of a given model. Used by the frontend to offer filament override options for model-based queue assignment. """ from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id # Normalize model name normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model query = ( select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True) # noqa: E712 ) if location: query = query.where(Printer.location == location) result = await db.execute(query) printers_list = list(result.scalars().all()) if not printers_list: return [] # Collect filaments from all matching printers # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately seen: set[tuple[str, str, str, int | None]] = set() # (type_upper, color_normalized, sub_brands_upper, extruder_id) filaments = [] for printer in printers_list: status = printer_manager.get_status(printer.id) if not status: continue # Get ams_extruder_map for dual-nozzle printers ams_extruder_map = status.raw_data.get("ams_extruder_map", {}) # AMS trays for ams_unit in status.raw_data.get("ams", []): ams_id = str(ams_unit.get("id", 0)) extruder_id = ams_extruder_map.get(ams_id) for tray in ams_unit.get("tray", []): tray_type = tray.get("tray_type") if not tray_type: continue tray_color = tray.get("tray_color", "") or "808080" # Preserve the full RRGGBBAA so transparent filament (alpha=00) # reaches the frontend instead of collapsing to #000000 → black # (#1545). Opaque colours still round-trip as #RRGGBB. The # dedup key uses the 6-char RGB so two slots that share an RGB # but differ only in alpha still merge. stripped = tray_color.replace("#", "") rgb = stripped[:6].lower() or "808080" color = f"#{stripped}" tray_info_idx = tray.get("tray_info_idx", "") tray_sub_brands = tray.get("tray_sub_brands", "") or "" key = (tray_type.upper(), rgb, tray_sub_brands.upper(), extruder_id) if key not in seen: seen.add(key) filaments.append( { "type": tray_type, "color": color, "tray_info_idx": tray_info_idx, "tray_sub_brands": tray_sub_brands, "extruder_id": extruder_id, } ) # External spools (vt_tray) for vt in status.raw_data.get("vt_tray") or []: vt_type = vt.get("tray_type") if not vt_type: continue vt_color = vt.get("tray_color", "") or "808080" # Same alpha-preserving handling as the AMS branch — see #1545. stripped = vt_color.replace("#", "") rgb = stripped[:6].lower() or "808080" color = f"#{stripped}" tray_info_idx = vt.get("tray_info_idx", "") tray_sub_brands = vt.get("tray_sub_brands", "") or "" vt_id = int(vt.get("id", 254)) extruder_id = (255 - vt_id) if ams_extruder_map else None key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id) if key not in seen: seen.add(key) filaments.append( { "type": vt_type, "color": color, "tray_info_idx": tray_info_idx, "tray_sub_brands": tray_sub_brands, "extruder_id": extruder_id, } ) return filaments @router.get("/developer-mode-warnings") async def get_developer_mode_warnings( _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Check if any connected printer lacks developer LAN mode.""" result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712 printers = result.scalars().all() statuses = printer_manager.get_all_statuses() warnings = [] for printer in printers: state = statuses.get(printer.id) if state and state.connected and state.developer_mode is False: warnings.append( { "printer_id": printer.id, "name": printer.name, } ) return warnings @router.get("/{printer_id}") async def get_printer( printer_id: int, user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get a specific printer. ``access_code`` is included only when the caller is trusted to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and API keys never receive it. """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") include_secret = await _caller_can_view_printer_secrets(user, db) return _serialize_printer(printer, include_secret=include_secret) @router.patch("/{printer_id}", response_model=PrinterResponse) async def update_printer( printer_id: int, printer_data: PrinterUpdate, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Update a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") update_data = printer_data.model_dump(exclude_unset=True) # Handle nested ROI object - flatten to individual columns if "plate_detection_roi" in update_data: roi = update_data.pop("plate_detection_roi") if roi: update_data["plate_detection_roi_x"] = roi.get("x") update_data["plate_detection_roi_y"] = roi.get("y") update_data["plate_detection_roi_w"] = roi.get("w") update_data["plate_detection_roi_h"] = roi.get("h") else: # Clear ROI if set to null update_data["plate_detection_roi_x"] = None update_data["plate_detection_roi_y"] = None update_data["plate_detection_roi_w"] = None update_data["plate_detection_roi_h"] = None for field, value in update_data.items(): setattr(printer, field, value) await db.commit() await db.refresh(printer) # Reconnect if connection settings changed if any(k in update_data for k in ["ip_address", "access_code", "is_active"]): printer_manager.disconnect_printer(printer_id) if printer.is_active: await printer_manager.connect_printer(printer) return printer @router.delete("/{printer_id}") async def delete_printer( printer_id: int, delete_archives: bool = True, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE), db: AsyncSession = Depends(get_db), ): """Delete a printer. Args: printer_id: ID of the printer to delete delete_archives: If True (default), delete all print archives for this printer. If False, keep archives but remove their printer association. """ from sqlalchemy import delete as sql_delete from backend.app.models.archive import PrintArchive from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance from backend.app.models.scheduled_drying import ScheduledDrying from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") printer_manager.disconnect_printer(printer_id) if delete_archives: # Delete all archives for this printer await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id)) else: # Orphan the archives instead of deleting them from sqlalchemy import update await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None)) # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades) await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)) # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades) await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id)) # Delete maintenance history and items for this printer # (SQLite doesn't enforce FK cascades, so do it explicitly) maintenance_ids = ( (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id))) .scalars() .all() ) if maintenance_ids: await db.execute( sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids)) ) await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id)) await db.delete(printer) await db.commit() return {"status": "deleted", "archives_deleted": delete_archives} @router.get("/{printer_id}/status", response_model=PrinterStatus) async def get_printer_status( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get real-time status of a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") state = printer_manager.get_status(printer_id) if not state: # No MQTT client state — the printer was never connected this run, or it # was disconnected manually. The plate-clear gate is Bambuddy-side and # persisted, so it still has a truthful value here (#2864); reporting the # schema default instead told clients the plate was clean and hid the # only control that can release the gate. return PrinterStatus( id=printer_id, name=printer.name, connected=False, awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id), ) # Determine cover URL if there's an active print (including paused) cover_url = None if state.state in ("RUNNING", "PAUSE") and state.gcode_file: cover_url = f"/api/v1/printers/{printer_id}/cover" # Convert HMS errors to response format hms_errors = [ HMSErrorResponse( code=e.code, attr=e.attr, module=e.module, severity=e.severity, actions=e.actions, job_id=e.job_id, full_code=e.full_code, description=e.description, ) for e in (state.hms_errors or []) ] # Parse AMS data from raw_data ams_units = [] vt_tray = [] ams_exists = False raw_data = state.raw_data or {} # K value for a slot's bound profile, resolved against its own nozzle. # # Keyed on more than cali_idx: the printer numbers its calibration table # per nozzle, so entry 16 exists on each and means a different profile on # each. A cali_idx-only map let whichever profile the printer happened to # list last overwrite the other, and the slot then displayed the wrong # nozzle's K — on the maintainer's H2C, 0.018 and 0.020 for the same spool. _kprofile_k = build_slot_k_resolver(state) # Cached active-cycle drying params (filament + target temp) we sent # last; Bambu doesn't echo them on the per-tick AMS push, so the badge # needs the cache to render " @ °C". drying_targets = printer_manager.get_drying_targets(printer_id) or {} if "ams" in raw_data and isinstance(raw_data["ams"], list): ams_exists = True for ams_data in raw_data["ams"]: # Skip if ams_data is not a dict (defensive check) if not isinstance(ams_data, dict): continue trays = [] for tray_data in ams_data.get("tray", []): # Filter out empty/invalid tag values tag_uid = tray_data.get("tag_uid", "") if tag_uid in ("", "0000000000000000"): tag_uid = None tray_uuid = tray_data.get("tray_uuid", "") if tray_uuid in ("", "00000000000000000000000000000000"): tray_uuid = None # Get K value: first try tray's k field, then lookup from K-profiles k_value = tray_data.get("k") cali_idx = tray_data.get("cali_idx") if k_value is None: k_value = _kprofile_k(cali_idx, int(ams_data.get("id", 0)), int(tray_data.get("id", 0))) trays.append( AMSTray( id=tray_data.get("id", 0), tray_color=tray_data.get("tray_color"), tray_type=tray_data.get("tray_type"), tray_sub_brands=tray_data.get("tray_sub_brands"), tray_id_name=tray_data.get("tray_id_name"), tray_info_idx=tray_data.get("tray_info_idx"), remain=tray_data.get("remain", 0), k=k_value, cali_idx=cali_idx, tag_uid=tag_uid, tray_uuid=tray_uuid, nozzle_temp_min=tray_data.get("nozzle_temp_min"), nozzle_temp_max=tray_data.get("nozzle_temp_max"), drying_temp=tray_data.get("drying_temp"), drying_time=tray_data.get("drying_time"), state=tray_data.get("state"), exists=tray_data.get("exists"), ) ) # Prefer humidity_raw (percentage) over humidity (index 1-5) # humidity_raw is the actual percentage value from the sensor humidity_raw = ams_data.get("humidity_raw") humidity_idx = ams_data.get("humidity") humidity_value = None if humidity_raw is not None: try: humidity_value = int(humidity_raw) except (ValueError, TypeError): pass # Skip unparseable humidity; will try index fallback if humidity_value is None and humidity_idx is not None: try: humidity_value = int(humidity_idx) except (ValueError, TypeError): pass # Skip unparseable humidity index; humidity remains None # AMS-HT has 1 tray, regular AMS has 4 trays is_ams_ht = len(trays) == 1 ams_id_int = int(ams_data.get("id", 0)) target = drying_targets.get(ams_id_int) or {} dry_target_temp: int | None = None dry_filament: str | None = None target_temp_val = target.get("temp") target_fil_val = target.get("filament") or "" if target_temp_val is not None: try: dry_target_temp = int(target_temp_val) except (TypeError, ValueError): dry_target_temp = None if target_fil_val: dry_filament = str(target_fil_val) # Fallback: name the filament from the loaded trays when there is no # cached target (drying started in a previous backend session, or # the cache wasn't seeded), and only when they agree. The # temperature has no fallback — see uniform_tray_filament_hint. if not dry_filament: dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays]) ams_units.append( AMSUnit( id=ams_id_int, humidity=humidity_value, temp=ams_data.get("temp"), is_ams_ht=is_ams_ht, tray=trays, # Serial number: Bambu MQTT uses "sn" key on AMS unit objects serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""), # Firmware version: populated by _handle_version_info from info.module ams/* entries sw_ver=str(ams_data.get("sw_ver") or ""), # Drying: dry_time > 0 means drying is active (minutes remaining) dry_time=int(ams_data.get("dry_time") or 0), dry_target_temp=dry_target_temp, dry_filament=dry_filament, module_type=str(ams_data.get("module_type") or ""), ) ) # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list) if "vt_tray" in raw_data: for vt_data in raw_data["vt_tray"]: # Filter out empty/invalid tag values for vt_tray vt_tag_uid = vt_data.get("tag_uid", "") if vt_tag_uid in ("", "0000000000000000"): vt_tag_uid = None vt_tray_uuid = vt_data.get("tray_uuid", "") if vt_tray_uuid in ("", "00000000000000000000000000000000"): vt_tray_uuid = None # Get K value: first try tray's k field, then lookup from K-profiles vt_k_value = vt_data.get("k") vt_cali_idx = vt_data.get("cali_idx") if vt_k_value is None: # External holder: id 254 is Ext-L, 255 is Ext-R. slot_extruder # takes the 0/1 tray index, so normalise before asking. vt_id = int(vt_data.get("id", 254)) vt_k_value = _kprofile_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id) tray_id = int(vt_data.get("id", 254)) vt_tray.append( AMSTray( id=tray_id, tray_color=vt_data.get("tray_color"), tray_type=vt_data.get("tray_type"), tray_sub_brands=vt_data.get("tray_sub_brands"), tray_id_name=vt_data.get("tray_id_name"), tray_info_idx=vt_data.get("tray_info_idx"), remain=vt_data.get("remain", 0), k=vt_k_value, cali_idx=vt_cali_idx, tag_uid=vt_tag_uid, tray_uuid=vt_tray_uuid, nozzle_temp_min=vt_data.get("nozzle_temp_min"), nozzle_temp_max=vt_data.get("nozzle_temp_max"), ) ) # Convert nozzle info to response format nozzles = [ NozzleInfoResponse( nozzle_type=n.nozzle_type, nozzle_diameter=n.nozzle_diameter, ) for n in (state.nozzles or []) ] # H2C nozzle rack (tool-changer dock positions) nozzle_rack = [ NozzleRackSlot( id=n.get("id", 0), nozzle_type=n.get("type", ""), nozzle_diameter=n.get("diameter", ""), wear=n.get("wear"), stat=n.get("stat"), max_temp=n.get("max_temp", 0), serial_number=n.get("serial_number", ""), filament_color=n.get("filament_color", ""), filament_id=n.get("filament_id", ""), filament_type=n.get("filament_type", ""), ) for n in (state.nozzle_rack or []) ] # Convert print options to response format print_options = PrintOptionsResponse( spaghetti_detector=state.print_options.spaghetti_detector, print_halt=state.print_options.print_halt, halt_print_sensitivity=state.print_options.halt_print_sensitivity, first_layer_inspector=state.print_options.first_layer_inspector, printing_monitor=state.print_options.printing_monitor, buildplate_marker_detector=state.print_options.buildplate_marker_detector, allow_skip_parts=state.print_options.allow_skip_parts, nozzle_clumping_detector=state.print_options.nozzle_clumping_detector, nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity, pileup_detector=state.print_options.pileup_detector, pileup_sensitivity=state.print_options.pileup_sensitivity, airprint_detector=state.print_options.airprint_detector, airprint_sensitivity=state.print_options.airprint_sensitivity, auto_recovery_step_loss=state.print_options.auto_recovery_step_loss, filament_tangle_detect=state.print_options.filament_tangle_detect, ) # Get AMS mapping from raw_data (which AMS is connected to which nozzle) ams_mapping = raw_data.get("ams_mapping", []) # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing) ams_extruder_map = state.ams_extruder_map or {} logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map) # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID # No conversion needed - just use the raw value directly tray_now = state.tray_now logger.debug("Using tray_now directly as global ID: %s", tray_now) # Filter out chamber temp for models that don't have a real sensor # P1P, P1S, A1, A1Mini report meaningless chamber_temper values temperatures = state.temperatures if not supports_chamber_temp(printer.model): temperatures = { k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating") } # Resolve the active print's archive + plate (#881 follow-up): lets the # printer card show the actual plate name for multi-plate 3MFs instead of # just the 3MF filename. Only attempted for active prints, since subtask_id # is only meaningful then. current_archive_id: int | None = None current_plate_id: int | None = None if state.state in ("RUNNING", "PAUSE"): current_plate_id = resolve_plate_id(state) if state.subtask_id: from backend.app.models.archive import PrintArchive archive_row = await db.execute( select(PrintArchive.id) .where(PrintArchive.subtask_id == state.subtask_id) .where(PrintArchive.printer_id == printer_id) .order_by(PrintArchive.created_at.desc()) .limit(1) ) current_archive_id = archive_row.scalar_one_or_none() return PrinterStatus( id=printer_id, name=printer.name, connected=state.connected, state=state.state, current_print=state.current_print, subtask_name=state.subtask_name, gcode_file=state.gcode_file, progress=state.progress, remaining_time=state.remaining_time, layer_num=state.layer_num, total_layers=state.total_layers, temperatures=temperatures, cover_url=cover_url, hms_errors=hms_errors, ams=ams_units, ams_exists=ams_exists, vt_tray=vt_tray, sdcard=state.sdcard, store_to_sdcard=state.store_to_sdcard, timelapse=state.timelapse, ipcam=state.ipcam, wifi_signal=state.wifi_signal, wired_network=state.wired_network, door_open=state.door_open, nozzles=nozzles, nozzle_rack=nozzle_rack, print_options=print_options, stg_cur=state.stg_cur, stg_cur_name=get_derived_status_name(state, printer.model), stg=state.stg, airduct_mode=state.airduct_mode, speed_level=state.speed_level, chamber_light=state.chamber_light, active_extruder=state.active_extruder, ams_mapping=ams_mapping, ams_extruder_map=ams_extruder_map, # Only meaningful alongside an installed switch; without one the map is # empty anyway, but gating it keeps a stale binding from outliving the # accessory being unplugged. ams_switch_inlet=(dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}), # Which hotend holds which slot. Same first-load reasoning as # fila_switch.ready below — the AMS slot menu reads it to decide which # hotend it may offer, and an empty default would offer both. extruder_slots={ str(ext_id): ExtruderSlotResponse( ams_id=slot.ams_id, slot_id=slot.slot_id, has_filament=slot.has_filament, ) for ext_id, slot in state.extruder_slots.items() }, tray_now=tray_now, # Runout guidance (#2587): resolve the firmware's target/previous slot to a # global tray ID, but only while PAUSED — the moment the operator needs it. expected_tray=( resolve_expected_tray( state.tray_tar, [(u.id, u.is_ams_ht) for u in ams_units], raw_data.get("mapping"), ) if state.state == "PAUSE" else None ), previous_tray=( resolve_expected_tray( state.tray_pre, [(u.id, u.is_ams_ht) for u in ams_units], raw_data.get("mapping"), ) if state.state == "PAUSE" else None ), ams_status_main=state.ams_status_main, ams_status_sub=state.ams_status_sub, mc_print_sub_stage=state.mc_print_sub_stage, last_ams_update=state.last_ams_update, printable_objects_count=len(state.printable_objects), cooling_fan_speed=state.cooling_fan_speed, big_fan1_speed=state.big_fan1_speed, big_fan2_speed=state.big_fan2_speed, heatbreak_fan_speed=state.heatbreak_fan_speed, left_aux_fan_speed=state.left_aux_fan_speed, exhaust_fan_present=state.exhaust_fan_present, firmware_version=state.firmware_version, developer_mode=state.developer_mode if state else None, ams_filament_backup=state.ams_filament_backup if state else None, awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id), supports_drying=supports_drying(printer.model, state.firmware_version), supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version), drying_screen_only=drying_screen_only(printer.model), supports_chamber_heater=supports_chamber_heater(printer.model), current_archive_id=current_archive_id, current_plate_id=current_plate_id, fila_switch=( FilaSwitchResponse( installed=state.fila_switch.installed, in_slots=list(state.fila_switch.in_slots), out_extruders=list(state.fila_switch.out_extruders), stat=state.fila_switch.stat, info=state.fila_switch.info, # Must be computed here as well as in printer_state_to_dict: this # is what the page gets on its first load, and the WebSocket only # corrects it on the next push. Defaulting it to False instead # would tell every correctly set-up machine that its switch is # not set up, until a push happened to arrive. ready=all(str(u.id) in state.ams_switch_inlet for u in ams_units), ) if state.fila_switch and state.fila_switch.installed else None ), ) @router.get("/{printer_id}/overlay-status") async def get_overlay_status( printer_id: int, _: None = RequireOverlayTokenIfAuthEnabled, db: AsyncSession = Depends(get_db), ) -> dict: """Everything the streaming overlay (#2613) draws for one printer. A token-authenticated sibling of ``get_printer_status`` for embeds with no login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it. Deliberately flat and minimal (name, camera rotation, live print state, and the one setting the overlay reads) rather than the full ``PrinterStatus``: a token holder gets exactly the fields the overlay renders, nothing more. Unlike the Cam Wall feed this *includes the print filename* — the overlay names the part on screen — which is why it sits behind its own ``overlay`` scope rather than ``camwall``. """ from backend.app.api.routes.settings import get_setting result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") time_format = await get_setting(db, "time_format") or "system" state = printer_manager.get_status(printer_id) if not state: # Never connected this run — mirror get_printer_status()'s disconnected # shape so the overlay renders its offline state rather than erroring. return { "id": printer_id, "name": printer.name, "camera_rotation": printer.camera_rotation or 0, "connected": False, "state": None, "current_print": None, "gcode_file": None, "progress": None, "remaining_time": None, "layer_num": None, "total_layers": None, "stg_cur_name": None, "temperatures": {}, "time_format": time_format, } return { "id": printer_id, "name": printer.name, "camera_rotation": printer.camera_rotation or 0, "connected": state.connected, "state": state.state, "current_print": state.current_print, "gcode_file": state.gcode_file, "progress": state.progress, "remaining_time": state.remaining_time, "layer_num": state.layer_num, "total_layers": state.total_layers, "stg_cur_name": get_derived_status_name(state, printer.model), # Nozzle / bed / chamber readings for the overlay's temperature fields # (#1422). Filtered rather than passed through: see display_temperatures. "temperatures": display_temperatures(state.temperatures, printer.model), "time_format": time_format, } @router.get("/{printer_id}/current-print-user") async def get_current_print_user( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get the user who started the current print (for reprint tracking). Returns user info if available, empty object otherwise. This tracks users for reprints (which bypass the queue). For queue-based prints, use the queue item's created_by field instead. """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") user_info = printer_manager.get_current_print_user(printer_id) return user_info or {} @router.post("/{printer_id}/refresh-status") async def refresh_printer_status( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Request a full status refresh from the printer (sends pushall command).""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") success = printer_manager.request_status_update(printer_id) if not success: raise HTTPException(400, "Printer not connected") return {"status": "refresh_requested"} @router.post("/{printer_id}/connect") async def connect_printer( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Manually connect to a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") success = await printer_manager.connect_printer(printer) return {"connected": success} @router.post("/{printer_id}/disconnect") async def disconnect_printer( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Manually disconnect from a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") printer_manager.disconnect_printer(printer_id) return {"connected": False} @router.post("/test") async def test_printer_connection( ip_address: str, serial_number: str, access_code: str, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE), ): """Test connection to a printer without saving.""" result = await printer_manager.test_connection( ip_address=ip_address, serial_number=serial_number, access_code=access_code, ) return result @router.post("/diagnostic", response_model=PrinterDiagnosticResult) async def diagnose_connection( req: DiagnosticRequest, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE), ): """Run connection diagnostics for the Add-Printer flow (printer not yet saved). When serial_number + access_code are supplied the MQTT credential check also runs; otherwise only the network-level checks are performed. """ return await run_connection_diagnostic( req.ip_address, serial_number=req.serial_number or None, access_code=req.access_code or None, ) @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult) async def diagnose_printer( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Run connection diagnostics for an existing saved printer. On-demand run from the UI: wait up to PUBLISH_WAIT_DEFAULT seconds for the printer to publish a status report so a fresh reconnect (counter reset to 0) isn't reported as `printer_publishing: fail` prematurely. The support package code path calls run_connection_diagnostic without the wait so bundling stays fast. """ from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") return await run_connection_diagnostic( printer.ip_address, printer=printer, wait_for_publish_seconds=PUBLISH_WAIT_DEFAULT, ) # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}). # Cleared on every print start by main.py::on_print_start, so re-dispatches with # different plates always fetch a fresh thumbnail without needing plate in the key. _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {} # Negative cache (#1420): when a cover lookup exhausts every FTP path with 550 # (file sliced on SD card, not on printer storage), remember the failure so the # next request short-circuits to 404 instead of re-hammering FTP 8 paths deep. # Cleared on print start alongside _cover_cache. _cover_404_cache: dict[int, set[tuple[str, str]]] = {} # In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572). # The farm dashboard mounts a cover tile per printer card, so several browsers # request the same printer's cover in the same instant, all miss the cache, and # each runs the full multi-path FTP lookup + 3MF extraction (one observed live # transfer pulled an 81 MB 3MF while real print uploads were in flight). The # first request to miss becomes the leader; concurrent requests await its future # and then serve from the positive/negative cache it filled. _cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {} def clear_cover_cache(printer_id: int) -> None: """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails.""" _cover_cache.pop(printer_id, None) _cover_404_cache.pop(printer_id, None) async def _running_print_archive_file(printer_id: int, state) -> Path | None: """Path to the 3MF of the print this printer is running, if we have it. Bambuddy archives the sliced file when the print starts, so the copy the printer is executing is usually already on disk. Anchored on ``subtask_id``, which the firmware mints per print: a leftover ``status="printing"`` row from a completion that was never seen must not lend its file to another job. Opens its own short-lived session, like the caller does, so the pooled connection is not held across the FTP work that follows. """ subtask_id = str(getattr(state, "subtask_id", "") or "").strip() if subtask_id in ("", "0"): return None from backend.app.models.archive import PrintArchive async with database.async_session() as db: archive = await db.scalar( select(PrintArchive) .where( PrintArchive.printer_id == printer_id, PrintArchive.status == "printing", PrintArchive.subtask_id == subtask_id, ) .order_by(PrintArchive.created_at.desc()) .limit(1) ) if archive is None or not archive.file_path: return None path = settings.base_dir / archive.file_path return path if path.is_file() and str(path).endswith(".3mf") else None @router.get("/{printer_id}/cover") async def get_printer_cover( printer_id: int, view: str | None = None, _: None = RequireCameraStreamTokenIfAuthEnabled, ): """Get the cover image for the current print job. Args: view: Optional view type. Use "top" for the top-down build plate view or "pick" for the slicer's object-ID mask used by skip objects. Default returns angled 3D perspective view. """ # Fetch the printer in a short-lived session and release the pooled DB # connection BEFORE the FTP download below. Previously this route took its # row via Depends(get_db), whose session stays open for the whole request — # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes # under FTP contention) pinned one pooled connection idle-in-transaction the # entire time (issue #2572). db is used only for this one SELECT; everything # after reads already-loaded printer.* scalars (expire_on_commit=False keeps # them readable), printer_manager, and FTP/zip — no lazy loads. # # Reference async_session via the module so the maker is looked up at call # time — keeps it in sync with reinitialize_database() and lets the test # harness's patch of backend.app.core.database.async_session take effect. async with database.async_session() as db: result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") state = printer_manager.get_status(printer_id) if not state: raise HTTPException(404, "Printer not connected") # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF) subtask_name = state.subtask_name if not subtask_name: raise HTTPException(404, f"No subtask_name in printer state (state={state.state})") # Resolve the active plate. Precedence (#1166): # 1. The plate Bambuddy dispatched (authoritative when we sent the print) # 2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that # reflects the full path, e.g. some X1C builds) # 3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers # per-plate archives sliced separately in Bambu Studio, where the # printer's gcode_file echo is just the .3mf filename) # 4. Fall back to plate 1 # The 3MF-scan fallback runs later — after the file is on disk. plate_num = resolve_plate_id(state) if plate_num is not None: logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name) # Normalize view parameter view_key = view or "default" # Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache() # runs on every print start, so a re-dispatch with a different plate gets # a fresh image regardless. Pre-#1166 the key included plate_num, but with # late plate resolution the cache check would always miss. cache_key = (subtask_name, view_key) if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]: return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png") # Negative-cache short-circuit (#1420): if a prior lookup for this same # subtask + view already failed, don't replay 8 FTP retries on every page # refresh. _cover_404_cache is cleared on print start. if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]: raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)") # Coalesce concurrent downloads for the same cover (#2572). The positive and # negative caches were just checked above; if another request is already # downloading this exact cover, wait for it and serve from the cache it fills # instead of launching a duplicate multi-path FTP + 3MF extraction. inflight_key = (printer_id, subtask_name, view_key) leader = _cover_inflight.get(inflight_key) if leader is not None: # shield() so our own cancellation can't cancel the shared leader. try: await asyncio.shield(leader) except Exception: pass if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]: return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png") if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]: raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)") # Leader finished without filling either cache (a transient 503) — fall # through and try the download ourselves. fut: asyncio.Future = asyncio.get_event_loop().create_future() _cover_inflight[inflight_key] = fut try: image_data = await _produce_cover_image( printer, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=await _running_print_archive_file(printer_id, state), ) return Response(content=image_data, media_type="image/png") finally: if not fut.done(): fut.set_result(None) _cover_inflight.pop(inflight_key, None) async def _produce_cover_image( printer: Printer, printer_id: int, subtask_name: str, view: str | None, view_key: str, plate_num: int | None, cache_key: tuple[str, str], archive_path: Path | None = None, ) -> bytes: """Download the active-print 3MF and extract its cover thumbnail (#2572). Split out of ``get_printer_cover`` so concurrent requests for the same cover can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes on success (also filling ``_cover_cache``) and raises ``HTTPException`` on failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB work — the caller already released the pooled connection before this runs, which is also why ``archive_path`` arrives resolved rather than looked up here. """ # Build possible 3MF filenames from subtask_name # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio) # or just "name.3mf" (uploaded directly) possible_filenames = [] if subtask_name.endswith(".3mf"): possible_filenames.append(subtask_name) else: # Try both naming patterns possible_filenames.append(f"{subtask_name}.gcode.3mf") possible_filenames.append(f"{subtask_name}.3mf") # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) if " " in subtask_name: normalized = subtask_name.replace(" ", "_") if normalized.endswith(".3mf"): possible_filenames.append(normalized) else: possible_filenames.append(f"{normalized}.gcode.3mf") possible_filenames.append(f"{normalized}.3mf") # Build list of all remote paths to try remote_paths = [] for filename in possible_filenames: remote_paths.extend( [ f"/{filename}", # Root directory (most common) f"/cache/{filename}", f"/model/{filename}", f"/data/{filename}", ] ) # Use first filename for temp path (will be reused) temp_filename = possible_filenames[0] temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}" temp_path.parent.mkdir(parents=True, exist_ok=True) storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id)) # Cache check (#972): the archive-metadata flow in main.py may have already # downloaded this 3MF during the print-start handler. Reusing that file # avoids a second 36MB transfer competing with the printer's single FTP # socket (which produces the 425 errors that feed the retry storm). # # The dispatch's own filename is a candidate too: it is what the archive # flow's probe cached the file under, and it does not always survive the # trip through subtask_name (#2856). downloaded = False using_cached = False def _cached_source() -> Path | None: """The 3MF another flow has already published for this print, if any.""" for candidate_name in (*possible_filenames, storage.probe_filename): if not candidate_name: continue cached = get_cached_3mf(printer_id, candidate_name) if cached: return cached return None cached = _cached_source() if cached: logger.info("Cover using cached 3MF from %s (avoided duplicate FTP)", cached) temp_path = cached downloaded = True using_cached = True if not downloaded: # Same idea, one step further back: that in-memory cache dies with the # process, but the archive of the print that is still running holds the # very 3MF on disk. Without this, reopening a card or the skip-objects # plate after a restart pulls the whole file back off a printer that is # mid-print — measured at three concurrent fan-outs, thirteen seconds # and a 0-byte read on the maintainer's H2C, which is exactly the # single-socket contention #972 was about. if archive_path is not None: logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path) temp_path = archive_path downloaded = True using_cached = True if not downloaded: # The cover lives inside the 3MF, so it is only reachable if the 3MF is. # When the printer kept the print on internal storage there is nothing # at any of these paths, and walking all sixteen of them just to end on # a 404 that reads as "this print has no cover" helps nobody (#2780). # # Unless the printer is wrong about that, which an H2D with a card in # routinely is (#2856). The dispatch names the file, so when it does, # trade the immediate 404 for a five-path probe of that one name — same # single connection, and it is the only way this endpoint ever recovers # a cover for a print the archive flow did not see start. max_retries = 2 if not storage.reachable: if not storage.probe_filename: _cover_404_cache.setdefault(printer_id, set()).add(cache_key) raise HTTPException( 404, f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS " f"({storage.reason}), so it has no cover to extract.", ) remote_paths = ftp_probe_paths(storage.probe_filename) # The dispatch's name is the authoritative one — a print whose # subtask_name has been normalized or truncated would otherwise be # cached under a key the archive flow never looks up. temp_filename = storage.probe_filename temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}" # One look, not three: the printer has already said this file is not # here, so a retry storm on top of a hunch is exactly what #2780 was. max_retries = 0 logger.info( f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)" ) # Retry logic for transient FTP failures last_error = None for attempt in range(max_retries + 1): if attempt: # Look again before spending another transfer. The entry check # above only settles the race when the two flows do not # overlap, and on a P1S at print start they overlap for # minutes: a reported run had the archive flow publish the file # 42 seconds into this endpoint's 2.5-minute retry sequence, # and the third attempt still pulled its own 5 MB copy of it # over the same socket the printer was serving the print from # (#2957). cached = _cached_source() if cached: logger.info( "Cover picked up the 3MF another flow finished downloading (%s) — skipping retry %s", cached, attempt + 1, ) temp_path = cached downloaded = True using_cached = True break if ftps_handshake_blocked(printer.ip_address): # Nothing to retry: the printer is not completing a TLS # handshake on port 990, so no path and no attempt reaches it # (#2780). Report the real cause instead of the 404 below, # which would read as "this print has no cover". raise HTTPException( 503, f"Printer {printer.ip_address} is not answering its file service over TLS. " "Bambuddy will try again shortly.", ) try: downloaded = await download_file_try_paths_async( printer.ip_address, printer.access_code, remote_paths, temp_path, printer_model=printer.model, ) if downloaded: break except Exception as e: last_error = e if attempt < max_retries: logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e) await asyncio.sleep(0.5 * (attempt + 1)) # Brief backoff else: logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e) if last_error and not downloaded: raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}") if not downloaded: # Remember this failure so subsequent requests for the same print # skip the 8-path FTP fan-out (#1420). _cover_404_cache.setdefault(printer_id, set()).add(cache_key) if not storage.reachable: # The probe looked and found nothing, so the printer's own # account of where the file went is the answer after all — # keep saying so rather than reporting a generic miss (#2780). raise HTTPException( 404, f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS " f"({storage.reason}), so it has no cover to extract.", ) raise HTTPException( 404, f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}", ) # Share the fresh download with the archive flow — unless the file is # already theirs, in which case re-registering it under this endpoint's # own name would only add a second key pointing at the same bytes. if not using_cached: cache_3mf_download(printer_id, temp_filename, temp_path) # Verify file actually exists and has content if not temp_path.exists(): raise HTTPException(500, f"Download reported success but file not found: {temp_path}") file_size = temp_path.stat().st_size logger.info("Downloaded file size: %s bytes", file_size) if file_size == 0: if not using_cached: temp_path.unlink() raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'") # Offer the file to the archive flow before extracting the thumbnail. When # the print started inside the printer's FTPS cool-off, the archive flow # gave up without a single connection and this endpoint holds the very file # it wanted — which used to be read for a thumbnail and then deleted at # print completion, leaving a permanently empty archive (#2957). Covers the # cached branch as well as a fresh download: whoever fetched it, the running # print's archive should have it. A no-op unless that archive is a fallback. from backend.app.main import try_recover_fallback_archive await try_recover_fallback_archive(printer_id, temp_filename, temp_path) try: # Extract thumbnail from 3MF (which is a ZIP file) try: zf = zipfile.ZipFile(temp_path, "r") except zipfile.BadZipFile: raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive") except OSError as e: logger.error("Failed to open 3MF file: %s", e, exc_info=True) raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.") try: # 3MF-scan fallback for plate detection (#1166). Per-plate archives # sliced separately in Bambu Studio contain a single # Metadata/plate_N.gcode for the active plate, even though # thumbnails for all plates are bundled. Using that gcode's plate # number prevents falling back to plate_1.png. if plate_num is None: plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)] if len(plate_gcodes) == 1: match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0]) if match: plate_num = int(match.group(1)) logger.info("Cover: detected plate %s from 3MF contents", plate_num) if plate_num is None: plate_num = 1 # Try common thumbnail paths in 3MF files # Use plate_num to get the correct plate's thumbnail for multi-plate projects # Use top-down view if requested (better for skip objects modal) if view == "pick": # Only the active plate's mask, with no fallback: every other view # falls back to plate 1 because a slightly wrong picture is better # than none, but a mask is coordinates, not decoration. Plate 1's # mask over plate 3's layout would resolve clicks to whichever # object happened to occupy that pixel on a different plate. thumbnail_paths = [f"Metadata/pick_{plate_num}.png"] elif view == "top": thumbnail_paths = [ f"Metadata/top_{plate_num}.png", # Fall back to plate 1 if specific plate not found "Metadata/top_1.png", f"Metadata/plate_{plate_num}.png", "Metadata/plate_1.png", "Metadata/thumbnail.png", ] else: thumbnail_paths = [ f"Metadata/plate_{plate_num}.png", # Fall back to plate 1 if specific plate not found "Metadata/plate_1.png", "Metadata/thumbnail.png", f"Metadata/plate_{plate_num}_small.png", "Metadata/plate_1_small.png", "Thumbnails/thumbnail.png", "thumbnail.png", ] for thumb_path in thumbnail_paths: try: image_data = zf.read(thumb_path) if printer_id not in _cover_cache: _cover_cache[printer_id] = {} _cover_cache[printer_id][(subtask_name, view_key)] = image_data return image_data except KeyError: continue # If no specific thumbnail found, try any PNG in Metadata. Never for # "pick": handing back a rendered thumbnail in place of the object-ID # mask is worse than nothing, because the caller can't tell the # difference and decodes the render's pixel colours as object IDs — # dark pixels yield small integers that collide with real IDs, so a # click would select an arbitrary object and skip it irreversibly. # A 404 is what tells the UI to fall back to the checklist. if view != "pick": for name in zf.namelist(): if name.startswith("Metadata/") and name.endswith(".png"): image_data = zf.read(name) if printer_id not in _cover_cache: _cover_cache[printer_id] = {} _cover_cache[printer_id][(subtask_name, view_key)] = image_data return image_data _cover_404_cache.setdefault(printer_id, set()).add(cache_key) raise HTTPException(404, "No thumbnail found in 3MF file") finally: zf.close() finally: # Only delete when this invocation owns the file. A cached path is # shared with the archive flow — removing it would force a refetch # the next time either flow needs the 3MF. if not using_cached and temp_path.exists(): temp_path.unlink() # ============================================ # File Manager Endpoints # ============================================ async def _load_printer_or_404(printer_id: int) -> Printer: """Load a printer in a short-lived session, releasing the pooled DB connection before the caller starts any FTP/network I/O (#2572). The file-manager and storage routes talk FTP to the printer, which can block for the full socket timeout — longer when a saturated FTP pool backs up. Holding the request's Depends(get_db) session across that FTP pinned one pooled connection idle-in-transaction per in-flight request, a top cause of pool exhaustion on large farms. The returned row's scalar columns stay readable after the session closes (expire_on_commit=False). Raises 404 when the printer doesn't exist. Reference async_session via the module so the maker is resolved at call time — keeps it in sync with reinitialize_database() and lets tests patch it. """ async with database.async_session() as db: result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") return printer @router.get("/{printer_id}/files") async def list_printer_files( printer_id: int, path: str = "/", _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """List files on the printer at the specified path.""" printer = await _load_printer_or_404(printer_id) listing = await list_files_result_async( printer.ip_address, printer.access_code, path, printer_model=printer.model, ) files = listing.files # Add full path to each file for f in files: f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}" return { "path": path, "files": files, "warnings": [] if listing.available else ["printer_unavailable"], } @router.get("/{printer_id}/files/download") async def download_printer_file( printer_id: int, path: str, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Download a file from the printer.""" printer = await _load_printer_or_404(printer_id) try: async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS): result = await build_printer_file( printer, path, None, bundle_key=f"single-{secrets.token_urlsafe(18)}", ) except PrinterFilesZipTooLargeError as exc: raise HTTPException(413, str(exc)) from exc except PrinterFilesZipInsufficientSpaceError as exc: raise HTTPException(507, str(exc)) from exc except FileNotFoundError: raise HTTPException(404, f"File not found: {path}") except TimeoutError as exc: raise HTTPException(504, "Printer download exceeded the 30-minute limit") from exc # Determine content type based on extension filename = path.split("/")[-1] ext = filename.lower().split(".")[-1] if "." in filename else "" content_types = { "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", "gcode": "text/plain", "mp4": "video/mp4", "avi": "video/x-msvideo", "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "json": "application/json", "txt": "text/plain", } content_type = content_types.get(ext, "application/octet-stream") return FileResponse( path=result.path, filename=filename, media_type=content_type, headers={"Content-Disposition": build_content_disposition(filename)}, background=BackgroundTask(remove_printer_files_zip, result.path), ) @router.get("/{printer_id}/files/gcode") async def get_printer_file_gcode( printer_id: int, path: str, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Get gcode for a file stored on a printer (for preview).""" import io printer = await _load_printer_or_404(printer_id) data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model) if data is None: raise HTTPException(404, f"File not found: {path}") filename = path.split("/")[-1] lower = filename.lower() if lower.endswith(".gcode"): return Response(content=data, media_type="text/plain") if lower.endswith(".3mf"): try: with zipfile.ZipFile(io.BytesIO(data), "r") as zf: gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")] if not gcode_files: raise HTTPException(status_code=404, detail="No gcode found in 3MF file") gcode_content = zf.read(gcode_files[0]) return Response(content=gcode_content, media_type="text/plain") except zipfile.BadZipFile: raise HTTPException(status_code=400, detail="Invalid 3MF file") raise HTTPException(status_code=400, detail="Unsupported file type") @router.get("/{printer_id}/files/plates") async def get_printer_file_plates( printer_id: int, path: str = Query(..., description="Full path to the 3MF file on the printer"), _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Get available plates from a multi-plate 3MF file stored on a printer.""" import io import json import defusedxml.ElementTree as ET printer = await _load_printer_or_404(printer_id) filename = path.split("/")[-1] if not filename.lower().endswith(".3mf"): return { "printer_id": printer_id, "path": path, "filename": filename, "plates": [], "is_multi_plate": False, } data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model) if data is None: raise HTTPException(404, f"File not found: {path}") plates = [] try: with zipfile.ZipFile(io.BytesIO(data), "r") as zf: namelist = zf.namelist() # Find all plate gcode files to determine available plates gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")] # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG plate_indices: list[int] = [] if gcode_files: for gf in gcode_files: try: plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode" plate_indices.append(int(plate_str)) except ValueError: pass # Skip gcode files with non-numeric plate indices else: plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")] plate_png_files = [ n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".png") and "_small" not in n and "no_light" not in n ] plate_name_candidates = plate_json_files + plate_png_files plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$") seen_indices: set[int] = set() for name in plate_name_candidates: match = plate_re.match(name) if match: try: index = int(match.group(1)) except ValueError: continue if index in seen_indices: continue seen_indices.add(index) plate_indices.append(index) if not plate_indices: return { "printer_id": printer_id, "path": path, "filename": filename, "plates": [], "is_multi_plate": False, } plate_indices.sort() # Parse model_settings.config for plate names plate_names = {} if "Metadata/model_settings.config" in namelist: try: model_content = zf.read("Metadata/model_settings.config").decode() model_root = ET.fromstring(model_content) for plate_elem in model_root.findall(".//plate"): plater_id = None plater_name = None for meta in plate_elem.findall("metadata"): key = meta.get("key") value = meta.get("value") if key == "plater_id" and value: try: plater_id = int(value) except ValueError: pass # Skip plate with unparseable ID elif key == "plater_name" and value: plater_name = value.strip() if plater_id is not None and plater_name: plate_names[plater_id] = plater_name except Exception: pass # Plate names are optional; continue without them # Parse slice_info.config for plate metadata plate_metadata = {} if "Metadata/slice_info.config" in namelist: content = zf.read("Metadata/slice_info.config").decode() root = ET.fromstring(content) for plate_elem in root.findall(".//plate"): plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []} plate_index = None for meta in plate_elem.findall("metadata"): key = meta.get("key") value = meta.get("value") if key == "index" and value: try: plate_index = int(value) except ValueError: pass # Skip plate with unparseable index elif key == "prediction" and value: try: plate_info["prediction"] = int(value) except ValueError: pass # Skip unparseable prediction; leave as None elif key == "weight" and value: try: plate_info["weight"] = float(value) except ValueError: pass # Skip unparseable weight; leave as None # Get filaments used in this plate for filament_elem in plate_elem.findall("filament"): filament_id = filament_elem.get("id") filament_type = filament_elem.get("type", "") filament_color = filament_elem.get("color", "") used_g = filament_elem.get("used_g", "0") used_m = filament_elem.get("used_m", "0") try: used_grams = float(used_g) except (ValueError, TypeError): used_grams = 0 if used_grams > 0 and filament_id: plate_info["filaments"].append( { "slot_id": int(filament_id), "type": filament_type, "color": filament_color, "used_grams": round(used_grams, 1), "used_meters": float(used_m) if used_m else 0, } ) plate_info["filaments"].sort(key=lambda x: x["slot_id"]) # Collect object names for obj_elem in plate_elem.findall("object"): obj_name = obj_elem.get("name") if obj_name and obj_name not in plate_info["objects"]: plate_info["objects"].append(obj_name) # Set plate name if plate_index is not None: custom_name = plate_names.get(plate_index) if custom_name: plate_info["name"] = custom_name elif plate_info["objects"]: plate_info["name"] = plate_info["objects"][0] plate_metadata[plate_index] = plate_info # Parse plate_*.json for object lists when slice_info is missing plate_json_objects: dict[int, list[str]] = {} for name in namelist: match = re.match(r"^Metadata/plate_(\d+)\.json$", name) if not match: continue try: plate_index = int(match.group(1)) except ValueError: continue try: payload = json.loads(zf.read(name).decode()) bbox_objects = payload.get("bbox_objects", []) names: list[str] = [] for obj in bbox_objects: obj_name = obj.get("name") if isinstance(obj, dict) else None if obj_name and obj_name not in names: names.append(obj_name) if names: plate_json_objects[plate_index] = names except Exception: continue # Build plate list for idx in plate_indices: meta = plate_metadata.get(idx, {}) has_thumbnail = f"Metadata/plate_{idx}.png" in namelist objects = meta.get("objects", []) if not objects: objects = plate_json_objects.get(idx, []) plate_name = meta.get("name") if not plate_name: plate_name = plate_names.get(idx) if not plate_name and objects: plate_name = objects[0] plates.append( { "index": idx, "name": plate_name, "objects": objects, "object_count": len(objects), "has_thumbnail": has_thumbnail, "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}", "print_time_seconds": meta.get("prediction"), "filament_used_grams": meta.get("weight"), "filaments": meta.get("filaments", []), } ) except Exception as e: logger.warning("Failed to parse plates from printer file %s: %s", path, e) return { "printer_id": printer_id, "path": path, "filename": filename, "plates": plates, "is_multi_plate": len(plates) > 1, } @router.get("/{printer_id}/files/plate-thumbnail/{plate_index}") async def get_printer_file_plate_thumbnail( printer_id: int, plate_index: int, path: str = Query(..., description="Full path to the 3MF file on the printer"), _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Get a plate thumbnail image from a printer-stored 3MF file.""" import io printer = await _load_printer_or_404(printer_id) data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model) if data is None: raise HTTPException(404, f"File not found: {path}") try: with zipfile.ZipFile(io.BytesIO(data), "r") as zf: thumb_path = f"Metadata/plate_{plate_index}.png" if thumb_path in zf.namelist(): image_data = zf.read(thumb_path) return Response(content=image_data, media_type="image/png") except Exception: pass # Corrupt or unreadable 3MF; fall through to 404 raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found") @router.post("/{printer_id}/files/download-zip") async def download_printer_files_as_zip( printer_id: int, request: PrinterFilesDownloadRequest, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Download multiple files using a disk-backed ZIP. Kept backward-compatible for API clients: relative paths are rooted, duplicate paths receive collision-safe names, and an all-failed request returns an empty ZIP as the historical endpoint did. The browser uses the asynchronous preparation endpoints below. """ if not request.paths: raise HTTPException(400, "No files specified") printer = await _load_printer_or_404(printer_id) normalized_paths = [path if path.startswith("/") else f"/{path}" for path in request.paths] normalized_sizes = {path if path.startswith("/") else f"/{path}": size for path, size in request.sizes.items()} try: async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS): result = await build_printer_files_zip( printer, normalized_paths, normalized_sizes, preserve_paths=False, allow_empty=True, ) except PrinterFilesZipTooLargeError as exc: raise HTTPException(413, str(exc)) from exc except PrinterFilesZipInsufficientSpaceError as exc: raise HTTPException(507, str(exc)) from exc except TimeoutError as exc: raise HTTPException(504, "Printer ZIP preparation exceeded the 30-minute limit") from exc return FileResponse( path=result.path, filename="printer-files.zip", media_type="application/zip", headers={ "X-Bambuddy-Files-Requested": str(result.requested), "X-Bambuddy-Files-Downloaded": str(result.successful), "X-Bambuddy-Files-Failed": str(len(result.failed_paths)), }, background=BackgroundTask(remove_printer_files_zip, result.path), ) @router.post("/{printer_id}/files/download-job") async def create_printer_files_download_job( printer_id: int, request: PrinterFilesJobRequest, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Start a cancellable disk-backed preparation without holding the request.""" if not request.paths: raise HTTPException(400, "No files specified") if len(set(request.paths)) != len(request.paths): raise HTTPException(400, "Selected printer paths must be unique") if not request.as_zip and len(request.paths) != 1: raise HTTPException(400, "Native downloads require exactly one file") printer = await _load_printer_or_404(printer_id) try: status = await start_printer_files_job( printer, request.paths, request.sizes, request.filename, as_zip=request.as_zip, ) except PrinterFilesZipTooLargeError as exc: raise HTTPException(413, str(exc)) from exc except PrinterFilesZipInsufficientSpaceError as exc: raise HTTPException(507, str(exc)) from exc except ValueError as exc: raise HTTPException(400, str(exc)) from exc return status.__dict__ @router.get("/{printer_id}/files/download-jobs/{job_id}") async def get_printer_files_download_job( printer_id: int, job_id: str, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): status = await get_printer_files_job(job_id, printer_id) if status is None: raise HTTPException(404, "Printer download job not found") return status.__dict__ @router.delete("/{printer_id}/files/download-jobs/{job_id}") async def cancel_printer_files_download_job( printer_id: int, job_id: str, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): if not await cancel_printer_files_job(job_id, printer_id): raise HTTPException(404, "Printer download job not found") return {"status": "cancelled"} @router.get("/{printer_id}/files/dl/{token}/{filename}") async def download_prepared_printer_files( printer_id: int, token: str, filename: str, ): """Consume a resource-bound token and stream a prepared file natively.""" from backend.app.core.auth import verify_slicer_download_token if not await verify_slicer_download_token(token, "printer-files", printer_id): return download_error_response(403, "This download link has already been used or has expired.") zip_path = printer_files_zip_path(printer_id, token) raw_path = printer_file_path(printer_id, token) if zip_path is not None and await asyncio.to_thread(zip_path.is_file): prepared_path = zip_path media_type = "application/zip" elif raw_path is not None and await asyncio.to_thread(raw_path.is_file): prepared_path = raw_path media_type = "application/octet-stream" else: return download_error_response(404, "The prepared download is no longer on the server.") safe_filename = safe_download_filename(filename, fallback="printer-download") return FileResponse( path=prepared_path, filename=safe_filename, media_type=media_type, headers={"Content-Disposition": build_content_disposition(safe_filename)}, background=BackgroundTask(remove_printer_files_zip, prepared_path), ) @router.delete("/{printer_id}/files") async def delete_printer_file( printer_id: int, path: str, _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES), ): """Delete a file from the printer.""" printer = await _load_printer_or_404(printer_id) from backend.app.services.bambu_ftp import DeleteResult result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model) if result == DeleteResult.NOT_FOUND: raise HTTPException(404, f"File not found on printer: {path}") if result == DeleteResult.FAILED: raise HTTPException(500, f"Failed to delete file: {path}") return {"status": "deleted", "path": path} @router.get("/{printer_id}/storage") async def get_printer_storage( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), ): """Get storage information from the printer.""" printer = await _load_printer_or_404(printer_id) storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model) return storage_info or {"used_bytes": None, "free_bytes": None} # ============================================ # MQTT Debug Logging Endpoints # ============================================ @router.post("/{printer_id}/logging/enable") async def enable_mqtt_logging( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Enable MQTT message logging for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") success = printer_manager.enable_logging(printer_id, True) if not success: raise HTTPException(400, "Printer not connected") return {"logging_enabled": True} @router.post("/{printer_id}/logging/disable") async def disable_mqtt_logging( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Disable MQTT message logging for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") success = printer_manager.enable_logging(printer_id, False) if not success: raise HTTPException(400, "Printer not connected") return {"logging_enabled": False} @router.get("/{printer_id}/logging") async def get_mqtt_logs( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get MQTT message logs for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") logs = printer_manager.get_logs(printer_id) return { "logging_enabled": printer_manager.is_logging_enabled(printer_id), "logs": [ { "timestamp": log.timestamp, "topic": log.topic, "direction": log.direction, "payload": log.payload, } for log in logs ], } @router.delete("/{printer_id}/logging") async def clear_mqtt_logs( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Clear MQTT message logs for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") printer_manager.clear_logs(printer_id) return {"status": "cleared"} # ============================================ # AMS Drying Endpoints # ============================================ # The P1 firmware acks `ams_filament_drying` with result: success and then ignores it # — Bambu's own P1 manual says drying "may only be controlled from the P1S screen" # (#2533). Refuse the command rather than let the caller believe it landed. _DRYING_SCREEN_ONLY_DETAIL = drying_preflight.SCREEN_ONLY_DETAIL @router.post("/{printer_id}/drying/start") async def start_drying( printer_id: int, ams_id: int, temp: int = 45, duration: int = 4, filament: str = "", rotate_tray: bool = False, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Send AMS drying start command. temp=45-85, duration=hours.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") # Server-side guard: reject if this model/firmware doesn't support drying live_state = printer_manager.get_status(printer_id) firmware = live_state.firmware_version if live_state else None unsupported = drying_preflight.check_drying_supported(printer.model, firmware) if unsupported: raise HTTPException(400, unsupported) if temp < 45 or temp > 85: raise HTTPException(400, "Temperature must be 45-85°C") if duration < 1 or duration > 24: raise HTTPException(400, "Duration must be 1-24 hours") # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the # firmware silently ignores the command — #971) and backfill an empty # filament field from the first loaded tray so the printer doesn't reject # the payload. target_ams = drying_preflight.find_ams_unit(live_state, ams_id) blocking = drying_preflight.blocking_reason_codes(target_ams) if blocking: # Same pick the scheduled path makes, so both describe one blocked AMS # the same way rather than differing on which code the firmware listed # first. raise HTTPException( 409, drying_preflight.DRY_SF_REASON_MESSAGES[drying_preflight.primary_reason_code(blocking)] ) filament = drying_preflight.resolve_filament(target_ams, filament) success = printer_manager.send_drying_command( printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray ) if not success: raise HTTPException(400, "Printer not connected") return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration} @router.post("/{printer_id}/drying/stop") async def stop_drying( printer_id: int, ams_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Send AMS drying stop command.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") # Screen-only models ignore stop just as they ignore start — a cycle running on a # P1S was started at the printer and has to be ended there too (#2533). if drying_screen_only(printer.model): raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL) success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0) if not success: raise HTTPException(400, "Printer not connected") # A cycle the user stopped by hand tells us nothing about whether drying can # move the humidity reading, so it must not count towards the auto-drying # suspension (#2770). Imported here rather than at module scope to keep the # existing routes/scheduler import direction. from backend.app.services.print_scheduler import scheduler as print_scheduler print_scheduler.forget_auto_dry_cycle(printer_id, ams_id) return {"status": "drying_stopped", "ams_id": ams_id} # ============================================ # Print Options (AI Detection) Endpoints # ============================================ @router.post("/{printer_id}/print-options") async def set_print_option( printer_id: int, module_name: str, enabled: bool, print_halt: bool = True, sensitivity: str = "medium", _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set an AI detection / print option on the printer. Valid module_name values: - spaghetti_detector: Spaghetti detection - first_layer_inspector: First layer inspection - printing_monitor: AI print quality monitoring - buildplate_marker_detector: Build plate marker detection - allow_skip_parts: Allow skipping failed parts """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client or not client.state.connected: raise HTTPException(400, "Printer not connected") # Validate module_name valid_modules = [ "spaghetti_detector", "first_layer_inspector", "printing_monitor", "buildplate_marker_detector", "allow_skip_parts", "pileup_detector", "clump_detector", "airprint_detector", "auto_recovery_step_loss", ] if module_name not in valid_modules: raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}") # Validate sensitivity valid_sensitivities = ["low", "medium", "high", "never_halt"] if sensitivity not in valid_sensitivities: raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}") success = client.set_xcam_option( module_name=module_name, enabled=enabled, print_halt=print_halt, sensitivity=sensitivity, ) if not success: raise HTTPException(500, "Failed to send command to printer") return { "success": True, "module_name": module_name, "enabled": enabled, "print_halt": print_halt, "sensitivity": sensitivity, } @router.post("/{printer_id}/ams-backup") async def set_ams_backup( printer_id: int, enabled: bool, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out).""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client or not client.state.connected: raise HTTPException(400, "Printer not connected") success = client.set_ams_filament_backup(enabled) if not success: raise HTTPException(500, "Failed to send command to printer") return {"success": True, "ams_filament_backup": enabled} @router.get("/{printer_id}/inventory-remain") async def get_inventory_remain( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Per-globalTrayId remaining grams for slots bound to an inventory spool. Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal client can apply the same two-tier "Prefer Lowest Remaining Filament" sort the dispatcher uses (#1766). Works for both internal inventory and Spoolman; unbound slots are absent from the map (client falls back to the printer's MQTT `remain` for those). `slot_materials` carries the same bindings with their material identity and extruder side attached, which is what the modal's pre-flight filament check needs to pool spools under AMS Filament Backup the way the dispatcher does. It is deliberately server-computed: the identity rule lives in `filament_deficit`, and a client-side reimplementation of it is exactly how the modal came to block prints the dispatcher would have accepted. Unlike `inventory_remain_g` it covers every binding, not just currently-loaded slots — again matching what the dispatcher pools. """ from backend.app.services.filament_deficit import build_slot_materials from backend.app.services.print_scheduler import PrintScheduler state = printer_manager.get_status(printer_id) if not state: return {"inventory_remain_g": {}, "slot_materials": []} scheduler = PrintScheduler() loaded = scheduler._build_loaded_filaments(state) overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded) slot_materials = await build_slot_materials(db, printer_id) return { "inventory_remain_g": {str(k): v for k, v in overrides.items()}, "slot_materials": [s.to_dict() for s in slot_materials], } # ============================================ # Calibration # ============================================ @router.post("/{printer_id}/calibration") async def start_calibration( printer_id: int, bed_leveling: bool = False, vibration: bool = False, motor_noise: bool = False, nozzle_offset: bool = False, high_temp_heatbed: bool = False, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Start printer calibration with selected options. At least one option must be selected. Options: - bed_leveling: Run bed leveling calibration - vibration: Run vibration compensation calibration - motor_noise: Run motor noise cancellation calibration - nozzle_offset: Run nozzle offset calibration (dual nozzle printers) - high_temp_heatbed: Run high-temperature heatbed calibration """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client or not client.state.connected: raise HTTPException(400, "Printer not connected") # Check that at least one option is selected if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]): raise HTTPException(400, "At least one calibration option must be selected") success = client.start_calibration( bed_leveling=bed_leveling, vibration=vibration, motor_noise=motor_noise, nozzle_offset=nozzle_offset, high_temp_heatbed=high_temp_heatbed, ) if not success: raise HTTPException(500, "Failed to send calibration command to printer") return { "success": True, "bed_leveling": bed_leveling, "vibration": vibration, "motor_noise": motor_noise, "nozzle_offset": nozzle_offset, "high_temp_heatbed": high_temp_heatbed, } # ============================================================================ # Slot Preset Mapping Endpoints # ============================================================================ def _slot_preset_key(ams_id: int, tray_id: int) -> int: # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed # by ams_id since each unit has a single slot and shares its global ID with # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id. if 128 <= ams_id <= 135: return ams_id return ams_id * 4 + tray_id @router.get("/{printer_id}/slot-presets") async def get_slot_presets( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get all saved slot-to-preset mappings for a printer.""" result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id)) mappings = result.scalars().all() return { _slot_preset_key(mapping.ams_id, mapping.tray_id): { "ams_id": mapping.ams_id, "tray_id": mapping.tray_id, "preset_id": mapping.preset_id, "preset_name": mapping.preset_name, } for mapping in mappings } @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}") async def get_slot_preset( printer_id: int, ams_id: int, tray_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get the saved preset for a specific slot.""" result = await db.execute( select(SlotPresetMapping).where( SlotPresetMapping.printer_id == printer_id, SlotPresetMapping.ams_id == ams_id, SlotPresetMapping.tray_id == tray_id, ) ) mapping = result.scalar_one_or_none() if not mapping: return None return { "ams_id": mapping.ams_id, "tray_id": mapping.tray_id, "preset_id": mapping.preset_id, "preset_name": mapping.preset_name, } @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}") async def save_slot_preset( printer_id: int, ams_id: int, tray_id: int, preset_id: str, preset_name: str, preset_source: str = "cloud", _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Save a preset mapping for a specific slot.""" # Check printer exists result = await db.execute(select(Printer).where(Printer.id == printer_id)) if not result.scalar_one_or_none(): raise HTTPException(404, "Printer not found") # Check for existing mapping result = await db.execute( select(SlotPresetMapping).where( SlotPresetMapping.printer_id == printer_id, SlotPresetMapping.ams_id == ams_id, SlotPresetMapping.tray_id == tray_id, ) ) mapping = result.scalar_one_or_none() if mapping: # Update existing mapping.preset_id = preset_id mapping.preset_name = preset_name mapping.preset_source = preset_source else: # Create new mapping = SlotPresetMapping( printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, preset_id=preset_id, preset_name=preset_name, preset_source=preset_source, ) db.add(mapping) await db.commit() await db.refresh(mapping) return { "ams_id": mapping.ams_id, "tray_id": mapping.tray_id, "preset_id": mapping.preset_id, "preset_name": mapping.preset_name, "preset_source": mapping.preset_source, } @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}") async def delete_slot_preset( printer_id: int, ams_id: int, tray_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Delete a saved preset mapping for a slot.""" result = await db.execute( select(SlotPresetMapping).where( SlotPresetMapping.printer_id == printer_id, SlotPresetMapping.ams_id == ams_id, SlotPresetMapping.tray_id == tray_id, ) ) mapping = result.scalar_one_or_none() if mapping: await db.delete(mapping) await db.commit() return {"success": True} @router.get("/{printer_id}/slots/{ams_id}/{tray_id}/spool-defaults") async def get_slot_spool_defaults( printer_id: int, ams_id: int, tray_id: int, db: AsyncSession = Depends(get_db), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), ): """What the spool assigned to this slot is configured to use here. The Configure AMS Slot dialog opens on a slot that usually already holds an assigned spool, and that spool carries a filament preset per printer model and a K profile per hotend. Without this the dialog offered defaults derived from the slot's last manual configuration or from the tray's RFID data -- ignoring the very values the spool was configured with, on the one screen that looks like it exists for them. Everything is resolved for the nozzle THIS slot feeds, so a dual-nozzle machine gets the answer for the correct hotend. Returns nulls rather than a 404 when the slot holds no known spool: "nothing configured" is an ordinary answer here and the dialog falls back to what it did before. """ from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment from backend.app.services.inventory_mode import spoolman_owns_assignments from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder from backend.app.services.slot_nozzle import resolve_slot_nozzle from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset state = printer_manager.get_status(printer_id) model = printer_manager.get_model(printer_id) slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model) profile = await find_slot_kprofile_for_extruder( db, printer_id, ams_id, tray_id, slot_nozzle.extruder_or_default, slot_nozzle.diameter, model, slot_nozzle.flow, ) slicer_filament: str | None = None slicer_filament_name: str | None = None spoolman_mode = await spoolman_owns_assignments(db) if spoolman_mode: sm_assignment = ( await db.execute( select(SpoolmanSlotAssignment).where( SpoolmanSlotAssignment.printer_id == printer_id, SpoolmanSlotAssignment.ams_id == ams_id, SpoolmanSlotAssignment.tray_id == tray_id, ) ) ).scalar_one_or_none() if sm_assignment is not None: slicer_filament, slicer_filament_name = await resolve_spoolman_preset( db, spoolman_spool_id=sm_assignment.spoolman_spool_id, printer_model=model, nozzle_diameter=slot_nozzle.diameter, fallback_filament=None, fallback_name=None, ) else: assignment = ( await db.execute( select(SpoolAssignment).where( SpoolAssignment.printer_id == printer_id, SpoolAssignment.ams_id == ams_id, SpoolAssignment.tray_id == tray_id, ) ) ).scalar_one_or_none() if assignment is not None: spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none() if spool is not None: slicer_filament, slicer_filament_name = await resolve_spool_preset( db, spool_id=spool.id, printer_model=model, nozzle_diameter=slot_nozzle.diameter, fallback_filament=spool.slicer_filament, fallback_name=spool.slicer_filament_name, ) return { "slicer_filament": slicer_filament, "slicer_filament_name": slicer_filament_name, "cali_idx": profile.cali_idx if profile else None, "k_value": profile.k_value if profile else None, "profile_name": profile.name if profile else None, "extruder": slot_nozzle.extruder, "nozzle_diameter": slot_nozzle.diameter, } @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure") async def configure_ams_slot( printer_id: int, ams_id: int, tray_id: int, tray_info_idx: str = Query(...), tray_type: str = Query(...), tray_sub_brands: str = Query(...), tray_color: str = Query(...), nozzle_temp_min: int = Query(...), nozzle_temp_max: int = Query(...), cali_idx: int = Query(-1), nozzle_diameter: str = Query("0.4"), setting_id: str = Query(""), kprofile_filament_id: str = Query(""), kprofile_setting_id: str = Query(""), k_value: float = Query(0.0), db: AsyncSession = Depends(get_db), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), ): """Configure an AMS slot with a specific filament setting and K profile. This sends two commands to the printer: 1. ams_filament_setting - sets filament type, color, temperature 2. extrusion_cali_sel - sets the K profile (pressure advance value) Args: printer_id: Database ID of the printer ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS) tray_id: Tray ID within the AMS (0-3) tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID tray_type: Filament type (e.g., "PLA", "PETG") tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF") tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF") nozzle_temp_min: Minimum nozzle temperature nozzle_temp_max: Maximum nozzle temperature cali_idx: K profile calibration index (-1 for default 0.020) nozzle_diameter: Nozzle diameter string (e.g., "0.4") setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional kprofile_filament_id: K profile's filament_id for proper K profile linking k_value: Direct K value to set (0.0 to skip direct K value setting) """ logger = logging.getLogger(__name__) logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id) logger.info( f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}" ) logger.info( f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}" ) # The modal derives tray_type from a preset name or a spool's material, so # it can be a product line rather than a type ("PLA+", "PolyTerra PLA"). # A slot carrying one of those satisfies nothing that asks for PLA, so the # slot gets the type and tray_sub_brands -- untouched here -- keeps the # name (issue #2902). The requested wording is kept for the id lookup # below, which knows some product lines the type table does not. requested_tray_type = tray_type tray_type = printer_filament_type(tray_type) if tray_type != requested_tray_type: logger.info("[configure_ams_slot] tray_type %r → %r", requested_tray_type, tray_type) # Get MQTT client for this printer client = printer_manager.get_client(printer_id) if not client: raise HTTPException(status_code=400, detail="Printer not connected") # Resolve tray_info_idx for the MQTT command. # Priority: # 1. Use the provided tray_info_idx if set (including cloud-synced # custom presets like PFUS* / P*). # 2. Reuse the slot's existing tray_info_idx if it's a specific # (non-generic) preset for the same material. # 3. Fall back to a generic Bambu filament ID. _GENERIC_FILAMENT_IDS = { "PLA": "GFL99", "PETG": "GFG99", "ABS": "GFB99", "ASA": "GFB98", "PC": "GFC99", "PA": "GFN99", "NYLON": "GFN99", "TPU": "GFU99", "PVA": "GFS99", "HIPS": "GFS98", "PLA-CF": "GFL98", "PETG-CF": "GFG98", "PA-CF": "GFN98", "PETG HF": "GFG96", } _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values()) effective_tray_info_idx = tray_info_idx if not tray_info_idx: # No preset provided — try slot reuse or generic fallback current_tray_info_idx = "" current_tray_type = "" state = printer_manager.get_status(printer_id) if state and state.raw_data: from backend.app.api.routes.inventory import _find_tray_in_ams_data if ams_id == 255: vt_tray = state.raw_data.get("vt_tray") or [] ext_id = tray_id + 254 for vt in vt_tray: if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id: current_tray_info_idx = vt.get("tray_info_idx", "") current_tray_type = vt.get("tray_type", "") break else: ams_data = state.raw_data.get("ams", {}) ams_list = ( ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else [] ) cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id) if cur_tray: current_tray_info_idx = cur_tray.get("tray_info_idx", "") current_tray_type = cur_tray.get("tray_type", "") if ( current_tray_info_idx and current_tray_info_idx not in _GENERIC_ID_VALUES and current_tray_type and current_tray_type.upper() == tray_type.upper() ): logger.info( "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)", current_tray_info_idx, tray_type, ) effective_tray_info_idx = current_tray_info_idx elif tray_type: # Requested wording first, reduced type only as a further fallback, # so a material that already resolves keeps resolving to the same # id: "PETG HF" has its own generic preset (GFG96) that reducing it # to "PETG" would trade away for GFG99. material = requested_tray_type.upper().strip() generic = ( _GENERIC_FILAMENT_IDS.get(material) or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0]) or _GENERIC_FILAMENT_IDS.get(tray_type.upper()) or "" ) if generic: logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type) effective_tray_info_idx = generic # Send filament setting + K-profile commands filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx # Realign the slot's filament context to the K-profile's calibration # context. The printer's calibration table is keyed by (filament_id, # cali_idx) — so for the cali_idx selected via extrusion_cali_sel to # actually stick to the slot, ams_filament_setting must declare the # slot under the SAME filament_id. # # Without this, configure_ams_slot would send: # ams_filament_setting → tray_info_idx=GFL99 (generic from material) # extrusion_cali_sel → filament_id=P4d64437 (kp's preset) # ...and the cali_idx would silently be dropped to default because the # slot's filament context (GFL99) doesn't match the kp's (P4d64437). # # This realignment fires only when the kp is targeted at a different # preset than the user's filament selection AND the kp's preset is a # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user # which the slicer rejects in tray_info_idx). effective_setting_id = setting_id if ( kprofile_filament_id and kprofile_filament_id != effective_tray_info_idx and not kprofile_filament_id.startswith("PFUS") ): logger.info( "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r", effective_tray_info_idx, kprofile_filament_id, setting_id, kprofile_setting_id or setting_id, ) effective_tray_info_idx = kprofile_filament_id if kprofile_setting_id: effective_setting_id = kprofile_setting_id # Back-fill setting_id from the resolved filament id when the client sent # none. Built-in / local / Orca-generic presets in the Configure AMS Slot # modal leave setting_id empty (they carry only a GF* tray_info_idx), and # the printer treats a filament-id-without-setting-id slot as half # configured: it shows the new material briefly, then reverts to its # previously stored profile (#2604). This mirrors the derivation the # inventory/assignment path already does (inventory.py). filament_id_to_ # setting_id leaves P* user presets and already-GFS* values unchanged, so # only the empty-setting_id generic paths are affected. if effective_tray_info_idx and not effective_setting_id: effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx) # Always send ams_set_filament_setting — the user explicitly clicked # "Configure Slot", so honor that. Previous versions skipped this for # RFID-tagged slots to preserve the slicer eye icon, but printers cache # stale tag_uid/tray_uuid after a BL spool is removed, causing the check # to false-positive on non-RFID slots and silently drop the command. success = client.ams_set_filament_setting( ams_id=ams_id, tray_id=tray_id, tray_info_idx=effective_tray_info_idx, tray_type=tray_type, tray_sub_brands=tray_sub_brands, tray_color=tray_color, nozzle_temp_min=nozzle_temp_min, nozzle_temp_max=nozzle_temp_max, setting_id=effective_setting_id, ) if not success: raise HTTPException(status_code=500, detail="Failed to send filament configuration command") # Method 1: Select existing calibration profile by cali_idx # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel, # and including it causes the firmware to mislink the profile on X1C/P1S. client.extrusion_cali_sel( ams_id=ams_id, tray_id=tray_id, cali_idx=cali_idx, filament_id=filament_id_for_kprofile, nozzle_diameter=nozzle_diameter, ) # Method 2: Only send extrusion_cali_set when NO existing profile was selected # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id), # corrupting it — e.g., overwriting a High Flow extruder 1 profile with # hardcoded extruder_id=0 and nozzle_id=HS00. if k_value > 0 and cali_idx < 0: # Calculate global tray ID for extrusion_cali_set if ams_id <= 3: global_tray_id = ams_id * 4 + tray_id elif ams_id >= 128 and ams_id <= 135: global_tray_id = (ams_id - 128) * 4 + tray_id else: global_tray_id = tray_id client.extrusion_cali_set( tray_id=global_tray_id, k_value=k_value, nozzle_diameter=nozzle_diameter, nozzle_temp=nozzle_temp_max, filament_id=filament_id_for_kprofile, setting_id=kprofile_setting_id or "", name=tray_sub_brands or "", cali_idx=cali_idx, ) # Persist the user's K-profile choice so it survives RFID re-reads and # session restarts. Pre-Phase-13 this was ephemeral — the MQTT command # took effect on the printer but bambuddy never recorded it, so the next # `_apply_pa_after_refresh` cycle had no stored profile to re-assert. if cali_idx >= 0: try: from sqlalchemy.orm import selectinload from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.spool_k_profile import SpoolKProfile from backend.app.models.spoolman_k_profile import SpoolmanKProfile from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment # Resolve the slot's extruder for the K-profile match key. On a # Filament Track Switch machine this comes from the AMS's inlet # binding, because every unit reports extruder 0xE there — without # that, the `else 0` below filed every profile under the right-hand # nozzle and a left-nozzle calibration was stored as a right one. slot_state = printer_manager.get_status(printer_id) resolved_extruder = slot_extruder( ams_id, tray_id, slot_state.ams_extruder_map if slot_state else None, slot_state.ams_switch_inlet if slot_state else None, ) # Still 0 when nothing is known, which is right for a single-nozzle # printer — the resolver only returns None when it genuinely cannot # tell, and on those machines extruder 0 is the only one there is. kp_extruder = resolved_extruder if resolved_extruder is not None else 0 # Only the active mode's assignment table decides where this # K-profile is stored. Reading Spoolman first and falling through # was safe while the inactive table was emptied on every mode # toggle; nothing is emptied since #2812, so a leftover Spoolman # row in built-in mode would file the calibration against a spool # the printer is not using and never write the local profile — # the calibration would appear to succeed and then not apply. from backend.app.services.inventory_mode import spoolman_owns_assignments spoolman_mode = await spoolman_owns_assignments(db) sm_assignment = None if spoolman_mode: # Spoolman SlotAssignment — has UniqueConstraint, idempotent. sm_result = await db.execute( select(SpoolmanSlotAssignment).where( SpoolmanSlotAssignment.printer_id == printer_id, SpoolmanSlotAssignment.ams_id == ams_id, SpoolmanSlotAssignment.tray_id == tray_id, ) ) sm_assignment = sm_result.scalar_one_or_none() if sm_assignment: existing = await db.execute( select(SpoolmanKProfile).where( SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id, SpoolmanKProfile.printer_id == printer_id, SpoolmanKProfile.extruder == kp_extruder, SpoolmanKProfile.nozzle_diameter == nozzle_diameter, ) ) kp = existing.scalar_one_or_none() if kp: kp.cali_idx = cali_idx kp.k_value = k_value or 0.0 kp.setting_id = kprofile_setting_id or None kp.name = tray_sub_brands or None else: db.add( SpoolmanKProfile( spoolman_spool_id=sm_assignment.spoolman_spool_id, printer_id=printer_id, extruder=kp_extruder, nozzle_diameter=nozzle_diameter, k_value=k_value or 0.0, name=tray_sub_brands or None, cali_idx=cali_idx, setting_id=kprofile_setting_id or None, ) ) await db.commit() logger.info( "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d", sm_assignment.spoolman_spool_id, printer_id, ams_id, tray_id, cali_idx, ) elif not spoolman_mode: # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first()). # Skipped in Spoolman mode even when a local row survives: the # profile would be filed against a spool this printer is not # drawing on, and the mode's own table has nothing to bind to. local_result = await db.execute( select(SpoolAssignment) .options(selectinload(SpoolAssignment.spool)) .where( SpoolAssignment.printer_id == printer_id, SpoolAssignment.ams_id == ams_id, SpoolAssignment.tray_id == tray_id, ) ) local_assignment = local_result.scalar_one_or_none() if local_assignment and local_assignment.spool: existing = await db.execute( select(SpoolKProfile).where( SpoolKProfile.spool_id == local_assignment.spool.id, SpoolKProfile.printer_id == printer_id, SpoolKProfile.extruder == kp_extruder, SpoolKProfile.nozzle_diameter == nozzle_diameter, ) ) # SpoolKProfile has no unique constraint on this tuple, so # multiple rows could theoretically exist (shouldn't, but # don't crash if they do). Update the first match, leave # any duplicates alone. kp = existing.scalars().first() if kp: kp.cali_idx = cali_idx kp.k_value = k_value or 0.0 kp.setting_id = kprofile_setting_id or None kp.name = tray_sub_brands or None else: db.add( SpoolKProfile( spool_id=local_assignment.spool.id, printer_id=printer_id, extruder=kp_extruder, nozzle_diameter=nozzle_diameter, k_value=k_value or 0.0, name=tray_sub_brands or None, cali_idx=cali_idx, setting_id=kprofile_setting_id or None, ) ) await db.commit() logger.info( "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d", local_assignment.spool.id, printer_id, ams_id, tray_id, cali_idx, ) except Exception: # MQTT command was already sent successfully — DB persist is best-effort. logger.exception( "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)", printer_id, ams_id, tray_id, cali_idx, ) try: await db.rollback() except Exception: pass # Register a read-back verification (#2582) so the tray telemetry that the # status push below returns can confirm the printer accepted this manual # slot configuration. Mirrors the inventory/assignment path. client.register_assignment_verification( ams_id=ams_id, tray_id=tray_id, tray_info_idx=effective_tray_info_idx, tray_color=tray_color, cali_idx=cali_idx, ) # Request fresh status push from printer so frontend gets updated data via WebSocket logger.info("[configure_ams_slot] Requesting status update from printer") update_result = client.request_status_update() logger.info("[configure_ams_slot] Status update request result: %s", update_result) return { "success": True, "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}", } @router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset") async def reset_ams_slot( printer_id: int, ams_id: int, tray_id: int, db: AsyncSession = Depends(get_db), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), ): """Reset an AMS slot to empty/unconfigured state. This clears the filament configuration from the slot. """ # Get MQTT client for this printer client = printer_manager.get_client(printer_id) if not client: raise HTTPException(status_code=400, detail="Printer not connected") # Reset the slot success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id) if not success: raise HTTPException(status_code=500, detail="Failed to send reset command") # Also delete any saved slot preset mapping result = await db.execute( select(SlotPresetMapping).where( SlotPresetMapping.printer_id == printer_id, SlotPresetMapping.ams_id == ams_id, SlotPresetMapping.tray_id == tray_id, ) ) mapping = result.scalar_one_or_none() if mapping: await db.delete(mapping) await db.commit() # Request fresh status push from printer so frontend gets updated data via WebSocket client.request_status_update() return { "success": True, "message": f"Reset AMS {ams_id} tray {tray_id}", } @router.get("/{printer_id}/ams-labels") async def get_ams_labels( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get all user-defined AMS labels for a printer, keyed by AMS unit ID. Labels are stored by AMS serial number. This endpoint resolves the current serial-to-ams_id mapping from the live printer state so the response is still keyed by ams_id for UI compatibility. """ # Build serial -> ams_id map from live printer state serial_to_ams_id: dict[str, int] = {} state = printer_manager.get_status(printer_id) if state and state.raw_data: for ams_unit in state.raw_data.get("ams", []): sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "") if sn: serial_to_ams_id[sn] = int(ams_unit.get("id", 0)) # Collect all known serials for this printer (live + synthetic fallback keys) serials_to_query = set(serial_to_ams_id.keys()) # Fetch labels for all known serials labels: dict[int, str] = {} if serials_to_query: result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query))) for lbl in result.scalars().all(): aid = serial_to_ams_id.get(lbl.ams_serial_number) if aid is not None: labels[aid] = lbl.label # Also fetch labels stored under synthetic keys for this printer (backward compat) # Collect all synthetic keys first, then query with a single IN clause. if state and state.raw_data: synthetic_key_to_aid: dict[str, int] = { f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0)) for ams_unit in state.raw_data.get("ams", []) if int(ams_unit.get("id", 0)) not in labels } if synthetic_key_to_aid: result = await db.execute( select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys())) ) for lbl in result.scalars().all(): aid = synthetic_key_to_aid.get(lbl.ams_serial_number) if aid is not None: labels[aid] = lbl.label return labels @router.put("/{printer_id}/ams-labels/{ams_id}") async def save_ams_label( printer_id: int, ams_id: int, body: AmsLabelBody, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Create or update the friendly name for a specific AMS unit. When ``ams_serial`` is provided the label is stored under that serial number so it survives the AMS being moved to a different printer. When it is absent (e.g. older firmware that does not report a serial) a synthetic key based on the printer_id and ams_id is used as a fallback. """ # Verify printer exists result = await db.execute(select(Printer).where(Printer.id == printer_id)) if not result.scalar_one_or_none(): raise HTTPException(404, "Printer not found") # Determine the serial key to store under stripped = body.ams_serial.strip() if body.ams_serial else "" serial_key = stripped if stripped else f"p{printer_id}a{ams_id}" result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key)) existing = result.scalar_one_or_none() if existing: existing.label = body.label existing.ams_id = ams_id else: db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label)) await db.commit() return {"ams_id": ams_id, "label": body.label} @router.delete("/{printer_id}/ams-labels/{ams_id}") async def delete_ams_label( printer_id: int, ams_id: int, ams_serial: str = Query(default="", max_length=50), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Delete the friendly name for a specific AMS unit, reverting to the auto label.""" stripped = ams_serial.strip() if ams_serial else "" serial_key = stripped if stripped else f"p{printer_id}a{ams_id}" result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key)) existing = result.scalar_one_or_none() if existing: await db.delete(existing) await db.commit() return {"success": True} @router.post("/{printer_id}/debug/simulate-print-complete") async def debug_simulate_print_complete( printer_id: int, db: AsyncSession = Depends(get_db), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), ): """DEBUG: Simulate print completion to test freeze behavior. This triggers the same code path as a real print completion, without needing to wait for an actual print to finish. """ from backend.app.main import _active_prints, on_print_complete from backend.app.models.archive import PrintArchive # Get the most recent archive for this printer result = await db.execute( select(PrintArchive) .where(PrintArchive.printer_id == printer_id) .order_by(PrintArchive.created_at.desc()) .limit(1) ) archive = result.scalar_one_or_none() if not archive: raise HTTPException(status_code=404, detail="No archives found for this printer") # Register this archive as "active" so on_print_complete can find it filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf" subtask_name = archive.print_name or "Test Print" _active_prints[(printer_id, filename)] = archive.id _active_prints[(printer_id, subtask_name)] = archive.id # Simulate print completion data data = { "status": "completed", "filename": filename, "subtask_name": subtask_name, "timelapse_was_active": False, } logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id) # Call the actual on_print_complete handler await on_print_complete(printer_id, data) return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"} # ============================================================================= # Print Control Endpoints # ============================================================================= @router.post("/{printer_id}/print/stop") async def stop_print( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Stop/cancel the current print job.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.stop_print() if not success: raise HTTPException(500, "Failed to stop print") # Mark this printer as user-stopped so on_print_complete reclassifies # the resulting "failed"/"aborted" MQTT status as "cancelled" — otherwise # the HMS heuristic in _dispatch_archive_update mislabels user-cancels # (e.g. the H2D's cancel-sequence module-0x0C HMS) as "Layer shift". try: from backend.app.main import mark_printer_stopped_by_user mark_printer_stopped_by_user(printer_id) except Exception as _mark_err: logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err) return {"success": True, "message": "Print stop command sent"} @router.post("/{printer_id}/clear-plate") async def clear_plate( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE), db: AsyncSession = Depends(get_db), ): """Acknowledge that the build plate has been cleared after a finished/failed print. Sets a plate-cleared flag so the scheduler can start the next queued print. No MQTT command is sent to the printer — the scheduler's start_print command will override the FINISH/FAILED state when it sends the next job. """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") # Deliberately NOT gated on the printer being connected. Acknowledging the plate # only mutates Bambuddy-side state — no MQTT command is sent — and with Auto Power # Off the normal end-of-print state is exactly this: gate up, printer powered down. # The guard this replaces was inherited from the sibling stop/pause/resume handlers, # where reaching the printer IS required, and left farms with no way to release the # gate short of powering each printer back on by hand (#2864). # Accept the acknowledgment whenever the printer is awaiting it — not only when the # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE # but the awaiting flag persists, and the user still needs a way to ack it (#961). state = printer_manager.get_status(printer_id) awaiting = printer_manager.is_awaiting_plate_clear(printer_id) if not awaiting and (not state or state.state not in ("FINISH", "FAILED")): raise HTTPException( 400, f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})", ) printer_manager.set_awaiting_plate_clear(printer_id, False) return {"success": True, "message": "Plate cleared, next print will start shortly"} @router.post("/{printer_id}/print/pause") async def pause_print( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Pause the current print job.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.pause_print() if not success: raise HTTPException(500, "Failed to pause print") return {"success": True, "message": "Print pause command sent"} @router.post("/{printer_id}/print/resume") async def resume_print( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Resume a paused print job.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.resume_print() if not success: raise HTTPException(500, "Failed to resume print") return {"success": True, "message": "Print resume command sent"} @router.post("/{printer_id}/print-speed") async def set_print_speed( printer_id: int, mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set the print speed mode.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.set_print_speed(mode) if not success: raise HTTPException(500, "Failed to set print speed") speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"} return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"} @router.post("/{printer_id}/temperature/nozzle") async def set_nozzle_temperature( printer_id: int, target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"), nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set a nozzle target temperature.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.set_nozzle_temperature(target, nozzle) if not success: raise HTTPException(500, "Failed to set nozzle temperature") return {"success": True, "message": f"Nozzle temperature set to {target}°C"} @router.post("/{printer_id}/temperature/bed") async def set_bed_temperature( printer_id: int, target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set the bed target temperature.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.set_bed_temperature(target) if not success: raise HTTPException(500, "Failed to set bed temperature") return {"success": True, "message": f"Bed temperature set to {target}°C"} @router.post("/{printer_id}/temperature/chamber") async def set_chamber_temperature( printer_id: int, target: int = Query( ..., ge=0, le=MAX_CHAMBER_TEMP_C, description="Target chamber temperature in Celsius; 0 turns heating off", ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set the chamber target temperature. Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S, and X2D have an active chamber heater. Sensor-only models (X1C, X1E, P2S) report chamber temp but silently swallow M141, so we 400 here rather than send a no-op. """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") if not supports_chamber_heater(printer.model): raise HTTPException(400, f"Model {printer.model or 'unknown'} does not have an active chamber heater") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.set_chamber_temperature(target) if not success: raise HTTPException(500, "Failed to set chamber temperature") return {"success": True, "message": f"Chamber temperature set to {target}°C"} @router.post("/{printer_id}/fan-speed") async def set_fan_speed( printer_id: int, fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"), speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set a fan speed by percentage. Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine profile gcode does. It only exists when the printer reports airduct part 10, so the request is rejected rather than sending M106 P10 into the void on a machine that has no such fan. That gate also rejects for the short window between connecting and the first airduct push, when nothing is known about the fan yet. The card hides the badge over the same window, so there is no control to click; a direct API caller gets a 400 and should retry once the status reports the fan. """ fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10} fan_id = fan_ids.get(fan) if fan_id is None: raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") # Presence gate for the accessory fan. Without this, aux2 is accepted for # every model and an A1 would be sent M106 P10 for a fan it does not have. # The UI already hides the badge; this closes the same hole on the API. if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None: raise HTTPException( 400, "This printer does not report a left auxiliary fan " "(no airduct part 10). The fan is an accessory kit on the P2S " "and factory-fitted on the X2D.", ) pwm_speed = round(speed * 255 / 100) success = client.set_fan_speed(fan_id, pwm_speed) if not success: raise HTTPException(500, "Failed to set fan speed") # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere; # match whatever the printer card badge shows so the toast agrees with the # control the user just clicked. fan_names = { "part": "Part cooling fan", "aux": "Auxiliary fan", "aux2": "Left auxiliary fan", "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan", } return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"} @router.post("/{printer_id}/select-extruder") async def select_extruder( printer_id: int, extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Select the active extruder/nozzle on dual-nozzle printers.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.select_extruder(extruder) if not success: raise HTTPException(500, "Failed to select nozzle") return {"success": True, "message": f"{'Left' if extruder == 1 else 'Right'} nozzle selected"} @router.post("/{printer_id}/airduct-mode") async def set_airduct_mode( printer_id: int, mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*).""" if mode not in ("cooling", "heating"): raise HTTPException(400, "Mode must be 'cooling' or 'heating'") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.set_airduct_mode(mode) if not success: raise HTTPException(500, "Failed to set airduct mode") return {"success": True, "message": f"Airduct mode set to {mode}"} @router.post("/{printer_id}/chamber-light") async def set_chamber_light( printer_id: int, on: bool = Query(..., description="True to turn on, False to turn off"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Turn the chamber light on or off.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.set_chamber_light(on) if not success: raise HTTPException(500, "Failed to control chamber light") return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"} @router.post("/{printer_id}/bed-jog") async def bed_jog( printer_id: int, distance: float = Query( ..., description=( "Signed nozzle-bed gap adjustment in mm. Negative = decrease gap " '("up" arrow in the UI: bed up on bed-on-Z models, toolhead down ' "on A1 bed-slingers). Positive = increase gap. The backend " "translates this into the right G-code Z sign per printer model." ), ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Adjust the nozzle-bed gap by a relative distance. Emits a short G-code sequence via MQTT. Soft-endstop policy (#2579). The printer's software travel limits are the only thing between a jog button and a bed crash — on Bambu machines the physical endstops are homing-only (there is no runtime limit switch in the travel path), so once they are disabled nothing stops the move. The old code disabled them (``M211 S0``) around every forced jog, and the UI sent ``force`` on every jog, so the limits were off on every bed move — that is what let a jog drive the nozzle into the bed on all models (#2579). This endpoint now emits a **bare relative move and never touches ``M211`` at all** — byte-for-byte what the printer's own touchscreen jog sends, which stops at the travel limit. Bambuddy no longer disables the firmware's soft endstops, and it no longer sends ``M211 S1`` either: that was an unverified attempt to re-enable a printer left disabled by an older build, and on real hardware the jog moved past the limit *with* it. If a printer still jogs past its limits, its endstops were disabled at the firmware level by the old build — power-cycle it once to restore them; from then on Bambuddy leaves them alone. Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed is the Z-axis, and Bambu's home convention puts Z=0 at the top with Z+ moving the bed down — so a frontend "Up" (decrease gap) maps naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the bed (#1334 reported exactly that crash). For those models we invert the sign before emitting the G-code, so the UI semantics stay the same regardless of which part physically moves. """ if distance == 0 or abs(distance) > 200: raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") from backend.app.services.printer_manager import is_bed_slinger gcode_distance = -distance if is_bed_slinger(printer.model) else distance # Bare relative move — exactly what the touchscreen sends. Never touch M211 # (#2579): the firmware keeps its soft endstops on by default and clamps the # move at the travel limit. lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"] if not client.send_gcode("\n".join(lines)): raise HTTPException(500, "Failed to send bed-jog command") return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"} @router.post("/{printer_id}/xy-jog") async def xy_jog( printer_id: int, x: float = Query(0, description="Signed relative X movement in mm"), y: float = Query(0, description="Signed relative Y movement in mm"), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Move the toolhead by a relative X/Y distance.""" if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200: raise HTTPException(400, "X/Y movement must be non-zero and ≤ 200 mm per axis") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") axes = [] if x: axes.append(f"X{x:.2f}") if y: axes.append(f"Y{y:.2f}") # Bare relative move — never touch M211 (#2579). The firmware keeps its soft # endstops on by default and clamps the move at the travel limit; a printer # left disabled by an older build is recovered with a power cycle. if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])): raise HTTPException(500, "Failed to send XY jog command") return {"success": True, "message": f"XY jog X{x:+.1f} Y{y:+.1f} mm sent"} @router.post("/{printer_id}/extruder-jog") async def extruder_jog( printer_id: int, distance: float = Query( ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts." ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Extrude or retract filament by a relative distance. No client-side cold-extrude guard: Bambu firmware refuses extrusion below its min-extrude temperature, so a cold call is rejected at the printer, not silently damaging the extruder gear. """ if distance == 0 or abs(distance) > 100: raise HTTPException(400, "Extruder movement must be non-zero and ≤ 100 mm") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") if not client.send_gcode("\n".join(["M83", f"G1 E{distance:.2f} F300", "M82"])): raise HTTPException(500, "Failed to send extruder jog command") return {"success": True, "message": f"Extruder jog {distance:+.1f} mm sent"} @router.post("/{printer_id}/home-axes") async def home_axes( printer_id: int, axes: str = Query( "all", description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.", ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Run the printer's full auto-home sequence via bare `G28`. Bambu printers (H2C / H2D / H2S / X1 family) home the Z axis by moving the BED UP toward an endstop at the top of travel. If the toolhead is not already parked out of the way, a bare `G28 Z` will crash the bed into the toolhead — #1052 reported exactly that on H2C: the bed rose without stopping at a safe height because `G28 Z` skipped the toolhead-park step that a full `G28` runs first. The endpoint therefore ignores the `axes` argument and always sends a bare `G28`, which the firmware expands into a safe multi-step sequence (park toolhead → home XY → home Z). The argument is kept only for backward-compat with existing clients; sending an invalid value still returns 400 so typos surface instead of silently proceeding. """ axes = axes.lower() if axes not in ("z", "xy", "all"): raise HTTPException(400, "axes must be 'z', 'xy', or 'all'") result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") if not client.send_gcode("G28"): raise HTTPException(500, "Failed to send home command") return {"success": True, "message": "Full auto-home sequence sent"} @router.post("/{printer_id}/hms/clear") async def clear_hms_errors( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Clear HMS/print errors on the printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.clear_hms_errors() if not success: raise HTTPException(500, "Failed to clear HMS errors") return {"success": True, "message": "HMS errors cleared"} @router.get("/{printer_id}/print/objects") async def get_printable_objects( printer_id: int, reload: bool = False, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get the list of printable objects for the current print. Returns a list of objects with id, name, position (if available), and skip status. Objects that have already been skipped are marked in the skipped_objects list. Args: reload: If True, reload objects from the archive file (useful after restart) """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") # Reload objects from 3MF if requested or no objects loaded if reload or not client.state.printable_objects: # The archive of a running print normally holds the very file the # printer is executing, so ask the disk before asking the printer: # the fan-out below pulls the whole 3MF over FTPS from a machine that # is mid-print — 15 MB on the print this was written for — and on a # printer that kept the file on internal storage it cannot succeed at # all. skipped_objects is deliberately left alone: a reload is # not a new print, and the list of what the user already skipped only # lives here. from backend.app.models.archive import PrintArchive from backend.app.services.archive import extract_printable_objects_from_archive subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip() if subtask_id not in ("", "0"): archive = await db.scalar( select(PrintArchive) .where( PrintArchive.printer_id == printer_id, PrintArchive.status == "printing", PrintArchive.subtask_id == subtask_id, ) .order_by(PrintArchive.created_at.desc()) .limit(1) ) if archive is not None: objects, bbox_all = extract_printable_objects_from_archive( settings.base_dir / archive.file_path, plate_number=resolve_plate_id(client.state), ) if objects: client.state.printable_objects = objects client.state.printable_objects_bbox_all = bbox_all logger.info( "Reloaded %s objects for printer %s from archive %s", len(objects), printer_id, archive.id, ) # Only when the disk could not answer: a `reload=true` that the archive # satisfied has already refreshed from the file the printer is running. if not client.state.printable_objects: subtask_name = client.state.subtask_name if subtask_name: from backend.app.services.archive import extract_printable_objects_from_3mf from backend.app.services.bambu_ftp import download_file_try_paths_async # Build possible 3MF filenames (try both .gcode.3mf and .3mf) possible_filenames = [] if subtask_name.endswith(".3mf"): possible_filenames.append(subtask_name) else: possible_filenames.append(f"{subtask_name}.gcode.3mf") possible_filenames.append(f"{subtask_name}.3mf") # Also try with spaces converted to underscores (Bambu Studio may normalize filenames) if " " in subtask_name: normalized = subtask_name.replace(" ", "_") if normalized.endswith(".3mf"): possible_filenames.append(normalized) else: possible_filenames.append(f"{normalized}.gcode.3mf") possible_filenames.append(f"{normalized}.3mf") # Download 3MF from printer temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}" temp_path.parent.mkdir(parents=True, exist_ok=True) # Build list of all remote paths to try remote_paths = [] for filename in possible_filenames: remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"]) try: downloaded = await download_file_try_paths_async( printer.ip_address, printer.access_code, remote_paths, temp_path, printer_model=printer.model, ) if downloaded and temp_path.exists(): with open(temp_path, "rb") as f: data = f.read() # Scope to the running plate: an all-plates 3MF lists every # plate's objects, and offering plate 1's while the printer # runs plate 2 makes every skip a misfire (#2522). objects, bbox_all = extract_printable_objects_from_3mf( data, plate_number=resolve_plate_id(client.state), include_positions=True, ) if objects: client.state.printable_objects = objects client.state.printable_objects_bbox_all = bbox_all logger.info("Reloaded %s objects for printer %s", len(objects), printer_id) except Exception as e: logger.debug("Failed to reload objects from printer: %s", e) finally: if temp_path.exists(): temp_path.unlink() # Return objects with their skip status and position data objects = [] for obj_id, obj_data in client.state.printable_objects.items(): # Handle both old format (string name) and new format (dict with name, x, y) if isinstance(obj_data, dict): obj_entry = { "id": obj_id, "name": obj_data.get("name", f"Object {obj_id}"), "x": obj_data.get("x"), "y": obj_data.get("y"), "skipped": obj_id in client.state.skipped_objects, } else: # Legacy format: obj_data is just the name string obj_entry = { "id": obj_id, "name": obj_data, "x": None, "y": None, "skipped": obj_id in client.state.skipped_objects, } objects.append(obj_entry) return { "objects": objects, "total": len(objects), "skipped_count": len(client.state.skipped_objects), "is_printing": client.state.state in ("RUNNING", "PAUSE"), "bbox_all": getattr(client.state, "printable_objects_bbox_all", None), } @router.post("/{printer_id}/print/skip-objects") async def skip_objects( printer_id: int, object_ids: list[int], _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Skip specific objects during the current print. Args: object_ids: List of object identify_id values to skip """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") if not object_ids: raise HTTPException(400, "No object IDs provided") # Validate object IDs exist in printable_objects invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects] if invalid_ids: raise HTTPException(400, f"Invalid object IDs: {invalid_ids}") success = client.skip_objects(object_ids) if not success: raise HTTPException(500, "Failed to skip objects") # Get names of skipped objects for response (handle both old and new format) skipped_names = [] for oid in object_ids: obj_data = client.state.printable_objects.get(oid, str(oid)) if isinstance(obj_data, dict): skipped_names.append(obj_data.get("name", str(oid))) else: skipped_names.append(obj_data) return { "success": True, "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}", "skipped_objects": object_ids, } # ============================================================================= # AMS Control Endpoints # ============================================================================= @router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh") async def refresh_ams_slot( printer_id: int, ams_id: int, slot_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID), db: AsyncSession = Depends(get_db), ): """Re-read RFID for an AMS slot (triggers filament info refresh).""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success, message = client.ams_refresh_tray(ams_id, slot_id) if not success: raise HTTPException(400, message) # Apply PA profile after delay (RFID re-read takes a few seconds) spawn_background_task( _apply_pa_after_refresh(printer_id, ams_id, slot_id), name=f"apply-pa-after-refresh-{printer_id}-{ams_id}-{slot_id}", ) return {"success": True, "message": message} async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int): """Apply PA profile after RFID re-read completes. Waits for the printer to finish processing the RFID data, then selects the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting because that would overwrite the RFID-provided filament data. """ await asyncio.sleep(5) try: from backend.app.api.routes.inventory import _find_tray_in_ams_data from backend.app.core.database import async_session from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment as SA from backend.app.models.spoolman_k_profile import SpoolmanKProfile from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment from backend.app.services.spool_tag_matcher import ( ZERO_TAG_UID, ZERO_TRAY_UUID, is_bambu_tag, ) from backend.app.utils.tag_normalization import ( normalize_tag_uid, normalize_tray_uuid, ) client = printer_manager.get_client(printer_id) if not client: return state = printer_manager.get_status(printer_id) if not state or not state.raw_data: return # Find current tray data (should have RFID data by now) ams_data = state.raw_data.get("ams", {}) ams_list = ( ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else [] ) tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id) if not tray or not tray.get("tray_type"): logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id) return tag_uid = tray.get("tag_uid", "") tray_uuid = tray.get("tray_uuid", "") tray_info_idx = tray.get("tray_info_idx", "") if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx): return # Compute nozzle/extruder once — used by both local and Spoolman lookup. # Shared with every other slot-configuring path (services.slot_nozzle), # so the diameter this cascade filters on is the one the slot's own # hotend actually has. slot_nozzle = resolve_slot_nozzle(state, ams_id, slot_id, printer_manager.get_model(printer_id)) nozzle_diameter = slot_nozzle.diameter resolved_extruder = slot_nozzle.extruder # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path # and exited silently if no SpoolKProfile match; Spoolman-assigned slots # were ignored entirely and live cali_idx was never re-asserted. matching_cali_idx: int | None = None matching_filament_id: str = tray_info_idx async with async_session() as db: from sqlalchemy import or_, select as sa_select from sqlalchemy.orm import selectinload # Stage 1: local SpoolAssignment + SpoolKProfile match result = await db.execute( sa_select(SA) .options(selectinload(SA.spool).selectinload(Spool.k_profiles)) .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id) ) assignment = result.scalar_one_or_none() spool: Spool | None = assignment.spool if assignment else None # Stage 1b: tag-based fallback. The slot may have just been reset # (SpoolAssignment row deleted) before the user triggered a re-read. # The live tray already carries the spool's tray_uuid/tag_uid from # the RFID re-read, but the SA row hasn't been re-created yet. # Without this fallback we miss the stored SpoolKProfile and Stage 3 # ends up re-asserting whatever cali_idx the firmware reset to # (typically the default profile). if spool is None: norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else "" norm_tag = normalize_tag_uid(tag_uid) if tag_uid else "" tag_filters = [] if norm_uuid and norm_uuid != ZERO_TRAY_UUID: tag_filters.append(Spool.tray_uuid == norm_uuid) if norm_tag and norm_tag != ZERO_TAG_UID: tag_filters.append(Spool.tag_uid == norm_tag) if tag_filters: tag_lookup = await db.execute( select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1) ) spool = tag_lookup.scalar_one_or_none() if spool is not None: logger.info( "PA re-apply AMS%d-T%d: matched spool %d via tag fallback " "(SpoolAssignment row missing, likely after slot reset)", ams_id, slot_id, spool.id, ) if spool is not None and spool.k_profiles: # Prefer exact extruder match, fall back to extruder-agnostic kp # for the same printer + nozzle. Hard-skipping on extruder # mismatch made the cascade refuse perfectly valid stored # profiles whenever the AMS-extruder mapping had shifted since # calibration time, falling all the way through to Stage 3 and # re-asserting the firmware default. exact_kp = None fallback_kp = None for kp in spool.k_profiles: if not slot_nozzle.flow_matches(kp.nozzle_type): continue if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None: continue if resolved_extruder is not None and kp.extruder is not None and kp.extruder == resolved_extruder: exact_kp = kp break if fallback_kp is None: fallback_kp = kp chosen_kp = exact_kp or fallback_kp if chosen_kp is not None: matching_cali_idx = chosen_kp.cali_idx # The filament_id in extrusion_cali_sel must match the preset # under which the K-profile was calibrated. Prefer the spool's # slicer_filament setting, falling back to the tray's RFID value. matching_filament_id = spool.slicer_filament or tray_info_idx # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match # (only when no local spool was matched — local takes priority, # including the tag-based fallback above) if matching_cali_idx is None and spool is None: sm_result = await db.execute( select(SpoolmanSlotAssignment).where( SpoolmanSlotAssignment.printer_id == printer_id, SpoolmanSlotAssignment.ams_id == ams_id, SpoolmanSlotAssignment.tray_id == slot_id, ) ) sm_assignment = sm_result.scalar_one_or_none() if sm_assignment: kp_result = await db.execute( sa_select(SpoolmanKProfile).where( SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id, SpoolmanKProfile.printer_id == printer_id, ) ) for kp in kp_result.scalars().all(): if kp.nozzle_diameter == nozzle_diameter: if ( resolved_extruder is not None and kp.extruder is not None and kp.extruder != resolved_extruder ): continue if kp.cali_idx is not None: matching_cali_idx = kp.cali_idx # Spoolman has no slicer_filament — use the tray's RFID value matching_filament_id = tray_info_idx break # Stage 3: live tray.cali_idx fallback. Re-asserts the printer's current # selection so the value sticks across the RFID re-read (otherwise some # firmwares clear cali_idx back to -1 mid-cycle). if matching_cali_idx is None: live_cali_idx = tray.get("cali_idx") if live_cali_idx is not None and live_cali_idx >= 0: matching_cali_idx = live_cali_idx if matching_cali_idx is None: logger.debug( "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT", ams_id, slot_id, ) return logger.info( "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s", ams_id, slot_id, matching_cali_idx, matching_filament_id, ) # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware # "this is a manual config" which destroys the RFID-detected spool state # (changes eye icon to pen icon in slicer). client.extrusion_cali_sel( ams_id=ams_id, tray_id=slot_id, cali_idx=matching_cali_idx, filament_id=matching_filament_id, nozzle_diameter=nozzle_diameter, ) # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already # selected the correct profile by cali_idx. Sending extrusion_cali_set with # the same cali_idx would MODIFY the existing profile's metadata (extruder_id, # nozzle_id, name), corrupting it. logger.info( "Applied PA profile cali_idx=%d to printer %d AMS%d-T%d", matching_cali_idx, printer_id, ams_id, slot_id, ) except Exception as e: logger.warning("Failed to apply PA profile after RFID re-read: %s", e) # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15. _LOAD_TRAY_ID_ERROR = "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)" def _is_valid_load_tray_id(tray_id: int) -> bool: """Whether ``tray_id`` names a slot the load/unload commands can address.""" return tray_id in range(16) or tray_id in range(24, 28) or tray_id in (254, 255) @router.post("/{printer_id}/ams/load") async def ams_load( printer_id: int, tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"), extruder_id: int | None = Query( None, ge=0, le=1, description=( "Hotend to feed: 0 = right/main, 1 = left/deputy. Only meaningful " "on a printer with a Filament Track Switch fitted, where the AMS is " "bound to a switch inlet rather than a hotend and the firmware " "cannot work the target out for itself. Omit on every other printer " "— the field is absent from BambuStudio's own command there too." ), ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Load filament from a specific AMS slot or external spool. Tray ID encoding (matches Bambu firmware convention): - 0..15: AMS slot, computed as ams_id * 4 + slot_id - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D) - 255: Ext-R on dual-nozzle H2D """ if not _is_valid_load_tray_id(tray_id): raise HTTPException(400, _LOAD_TRAY_ID_ERROR) result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.ams_load_filament(tray_id, extruder_id=extruder_id) if not success: raise HTTPException(500, "Failed to send load command") if tray_id == 254: target = "external spool" elif tray_id == 255: target = "Ext-R" else: target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}" return {"success": True, "message": f"Loading filament from {target}"} @router.post("/{printer_id}/ams/unload") async def ams_unload( printer_id: int, tray_id: int | None = Query( None, description=( "Tray ID of the slot to unload, same encoding as the load endpoint. " "Identifies which hotend to unload on a dual-nozzle printer, where " "both can hold filament at once and the printer's single tray_now " "field names only one of them. Omit to unload whatever tray_now " "names, which is the only option a single-nozzle printer has." ), ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Unload the filament in a given slot, or the currently loaded one.""" if tray_id is not None and not _is_valid_load_tray_id(tray_id): raise HTTPException(400, _LOAD_TRAY_ID_ERROR) result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") success = client.ams_unload_filament(tray_id) if not success: # A named slot that no hotend is fed from is a no-op, not a fault: the # menu is per-slot and the operator may well have clicked one that is # not loaded. Say so instead of returning a 500 they cannot act on. if tray_id is not None: raise HTTPException(409, "No hotend is loaded from that slot") raise HTTPException(500, "Failed to send unload command") return {"success": True, "message": "Unloading filament"} @router.get("/{printer_id}/runtime-debug") async def get_runtime_debug( printer_id: int, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Debug endpoint: Get runtime tracking status for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") state = printer_manager.get_status(printer_id) return { "printer_name": printer.name, "runtime_seconds": printer.runtime_seconds, "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0, "print_hours_offset": printer.print_hours_offset, "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0) + (printer.print_hours_offset or 0), "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None, "mqtt_state": { "connected": state.connected if state else False, "state": state.state if state else None, "progress": state.progress if state else None, "gcode_file": state.gcode_file if state else None, } if state else None, "is_active": printer.is_active, } @router.post("/{printer_id}/hms/execute-action") async def execute_hms_action( printer_id: int, body: HmsActionBody, _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Execute an HMS action on the printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(404, "Printer not found") client = printer_manager.get_client(printer_id) if not client: raise HTTPException(400, "Printer not connected") # Snapshot pre-state so we can verify the printer actually acted on the # command. publish() success is NOT the same as printer-ack: Bambu's # firmware silently rejects malformed HMS commands at QoS 1 (the broker # ACKs the publish, but the printer drops it). Verified end-to-end against # a live H2D — see #1830 §(3). # # We probe `_last_message_time` (bumped on every MQTT push) rather than a # (gcode_state, hms_errors-length) diff. The old diff missed the # wrong-plate IGNORE_RESUME case where the printer briefly resumes and # re-pauses with the same fault inside the 2.5s window: both fields # round-trip to their pre-publish values → false 502 even though the # firmware fully ack'd the resume. Every accepted command triggers a # pushall response within ~100-500ms, so a fresh inbound message after # the publish is the robust ack signal. pre_last_message = client._last_message_time success = client.execute_hms_action(body.print_error, body.action, body.job_id) if not success: raise HTTPException(400, "Failed to execute HMS action") # Give the printer time to push a state update. The dispatch helper already # publishes a pushall after every command, so a fresh status should arrive # within ~1s; the default 2.5s covers slower firmware variants without # making the UI feel hung. Plain sleep is fine — paho's MQTT callback # runs in its own thread and updates state regardless of whether this # coroutine is awaiting. await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS) acked = client._last_message_time > pre_last_message if not acked: # Publish succeeded but the printer sent nothing back. Almost always # firmware-side silent rejection (err mismatch, command/state mismatch) # or a dropped MQTT route. 502 makes it visible at the UI instead of # the 200-but-broken loop #1830 reported. raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s") return {"success": True, "message": "HMS action executed"}