| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764 |
- import asyncio
- import logging
- import re
- import traceback
- from collections.abc import Callable
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.printer import Printer
- from backend.app.services.bambu_mqtt import (
- STAGE_NAMES,
- BambuMQTTClient,
- MQTTLogEntry,
- PrinterState,
- get_stage_name,
- )
- from backend.app.utils.kprofile_lookup import build_slot_k_resolver
- logger = logging.getLogger(__name__)
- # Models that have a real chamber temperature sensor
- # Based on Home Assistant Bambu Lab integration
- # P1P/P1S and A1/A1Mini do NOT have chamber temp sensors
- # Includes both display names and internal codes from MQTT/SSDP
- CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
- [
- # Display names
- "X1",
- "X1C",
- "X1E", # X1 series
- "X2D", # X2 series
- "P2S", # P2 series
- "H2C",
- "H2D",
- "H2DPRO",
- "H2S", # H2 series
- # Internal codes (from MQTT/SSDP)
- "BL-P001", # X1/X1C
- "C13", # X1E
- "N6", # X2D
- "O1D", # H2D
- "O1C", # H2C
- "O1C2", # H2C (dual nozzle variant)
- "O1S", # H2S
- "O1E", # H2D Pro
- "O2D", # H2D Pro (alternate code)
- "N7", # P2S
- ]
- )
- # Models that may incorrectly report stg_cur=0 when idle (firmware bug)
- # Based on Home Assistant Bambu Lab integration observations
- # See: https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py
- A1_MODELS = frozenset(
- [
- # Display names
- "A1",
- "A1 MINI",
- "A1-MINI",
- "A1MINI",
- # Internal codes (from MQTT/SSDP)
- "N1", # A1 Mini
- "N2S", # A1
- ]
- )
- # Models affected by the stg_cur=0 idle bug (firmware reports stg_cur=0 when idle,
- # which maps to "Printing" in STAGE_NAMES and overrides the correct IDLE state)
- STG_CUR_IDLE_BUG_MODELS = A1_MODELS | frozenset(
- [
- # Display names
- "P1P",
- "P1S",
- # Internal codes (from MQTT/SSDP)
- "C11", # P1P
- "C12", # P1S
- ]
- )
- def supports_chamber_temp(model: str | None) -> bool:
- """Check if a printer model has a real chamber temperature sensor.
- P1P, P1S, A1, and A1Mini do NOT have chamber temp sensors.
- The 'chamber_temper' value they report is meaningless.
- """
- if not model:
- return False
- # Normalize model name (uppercase, strip whitespace)
- model_upper = model.strip().upper()
- return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
- # Models with an ACTIVE chamber heater (M141 has an effect).
- # Many printers in CHAMBER_TEMP_SUPPORTED_MODELS only have a passive sensor —
- # X1C, X1E, P2S report chamber temperature but cannot actively heat it. Only
- # the models below ship a PTC heater that responds to M141.
- CHAMBER_HEATER_MODELS = frozenset(
- [
- # Display names
- "H2C",
- "H2D",
- "H2DPRO",
- "H2S",
- "X2D",
- # Internal codes (from MQTT/SSDP)
- "O1C", # H2C
- "O1C2", # H2C dual-nozzle variant
- "O1D", # H2D
- "O1E", # H2D Pro
- "O2D", # H2D Pro alternate code
- "O1S", # H2S
- "N6", # X2D
- ]
- )
- def supports_chamber_heater(model: str | None) -> bool:
- """Check if a printer model has an active chamber heater (responds to M141).
- The chamber temperature SENSOR is more widely deployed than the chamber
- HEATER — X1C/X1E/P2S report chamber temp but ignore M141. Only H2C, H2D,
- H2D Pro, H2S, X2D actually heat. Sensor-only models silently swallow the
- command at the firmware level, so we 400 at the route to surface that.
- """
- if not model:
- return False
- return model.strip().upper() in CHAMBER_HEATER_MODELS
- # Models with a cooling / heating airduct flap. Same set as the frontend
- # PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2C, H2S, H2D Pro).
- # X1E has a chamber heater but NO airduct flap — the warm-air recirculation
- # happens via the fixed front-door inlet, so no `set_airduct` command is
- # needed (and the firmware ignores it). P2S has an airduct but no heater —
- # the flap manages chamber airflow even without an active heater. The
- # intersection (chamber heater AND airduct) is what the preheat stage cares
- # about: when M141 fires we also need to assert heating mode, otherwise the
- # default cooling mode actively fights the chamber heater.
- CHAMBER_AIRDUCT_MODELS = frozenset(
- [
- # Display names
- "P2S",
- "X2D",
- "H2C",
- "H2D",
- "H2DPRO",
- "H2S",
- # Internal codes (from MQTT/SSDP)
- "N7", # P2S
- "N6", # X2D
- "O1C", # H2C
- "O1C2", # H2C dual-nozzle variant
- "O1D", # H2D
- "O1E", # H2D Pro
- "O2D", # H2D Pro alternate code
- "O1S", # H2S
- ]
- )
- def supports_airduct(model: str | None) -> bool:
- """Check if a printer model has a cooling / heating airduct mode toggle.
- Mirrors the frontend PrintersPage `['P2S', 'X2D', 'H2D', 'H2C', 'H2S']`
- + H2D Pro whitelist. Distinct from `supports_chamber_heater` — P2S has
- the airduct toggle but no active heater, and X1E has the heater but no
- airduct. The preheat stage cares about the intersection (heater AND
- airduct) so it can flip the flap to heating before energising M141.
- """
- if not model:
- return False
- return model.strip().upper() in CHAMBER_AIRDUCT_MODELS
- def has_stg_cur_idle_bug(model: str | None) -> bool:
- """Check if a printer model may incorrectly report stg_cur=0 when idle.
- Some firmware versions report stg_cur=0 (which maps to "Printing")
- even when the printer is idle. Originally observed on A1/A1 Mini via the
- Home Assistant Bambu Lab integration, also confirmed on P1S.
- """
- if not model:
- return False
- model_upper = model.strip().upper()
- return model_upper in STG_CUR_IDLE_BUG_MODELS
- def is_bed_slinger(model: str | None) -> bool:
- """Whether the printer's Z axis controls the *toolhead*, not the bed.
- Bambu's A1 family (A1, A1 Mini; internal codes N1 / N2S) are open-frame
- bed-slingers: the bed moves on Y, the toolhead moves on X+Z. On every
- other current model (X1, P1, H2, H2C, H2D, H2S, P2S, ...) the bed moves
- on Z and the toolhead is fixed in Z.
- G-code direction is opposite on these two families. `G1 Z-10` reduces
- the nozzle-bed gap on both, but on bed-on-Z machines it does so by
- moving the BED up, while on bed-slingers it does so by moving the
- TOOLHEAD down — which is what crashed the nozzle in #1334.
- """
- if not model:
- return False
- return model.strip().upper() in A1_MODELS
- # Minimum firmware versions for AMS drying support (confirmed via capture testing)
- # Keys are exact model names (upper-cased). Do NOT use substring matching — it would
- # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
- _DRYING_MIN_FIRMWARE: dict[str, str] = {
- "H2D": "01.02.30.00",
- "H2S": "01.02.00.00",
- "H2C": "01.02.00.00",
- "O1C": "01.02.00.00", # H2C SSDP model code
- "O1C2": "01.02.00.00", # H2C dual-nozzle SSDP model code
- "X1": "01.09.00.00",
- "X1C": "01.09.00.00",
- "P2S": "01.02.00.00",
- "N7": "01.02.00.00", # P2S internal model code
- }
- # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
- _DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "O1S", "N1", "N2S"})
- # Models whose AMS can dry, but only from the printer's own touchscreen. Bambu's P1
- # manual is explicit: "P1S connected AMS drying functions may only be controlled from
- # the P1S screen." The firmware still answers `ams_filament_drying` with
- # result: success and then does nothing — the reporter of #2533 sent it three times
- # on an idle P1S with an AMS 2 Pro and the unit never left dry_status 0. Bambuddy
- # originally listed P1P/P1S here as fw-gated (01.08+, #292); that version is when P1
- # firmware gained AMS 2 Pro *support*, not remote drying, and it was never verified
- # against a live P1. Nothing we can send will start a cycle, so we don't offer to.
- _DRYING_SCREEN_ONLY_MODELS = frozenset({"P1P", "P1S"})
- def drying_screen_only(model: str | None) -> bool:
- """True when the model's AMS dries only via the printer's own screen (#2533).
- Distinct from "unsupported": these printers *can* dry, and Bambuddy still shows
- a cycle started on the printer. They just can't be commanded to start or stop
- one remotely, so the UI explains that instead of silently dropping the control.
- """
- if not model:
- return False
- return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
- # Temperature keys the UI actually draws. `state.temperatures` is also working
- # memory: it carries private bookkeeping (`_nozzle_target_set_time`) and derived
- # flags (`nozzle_heating`) that no consumer outside this module should see. The
- # full-status path hands out the whole dict to logged-in callers; the streaming
- # overlay gets only this list, because an overlay token is a narrower grant than
- # a login and should not pick up fields by accident as the dict grows.
- DISPLAY_TEMPERATURE_KEYS = (
- "nozzle",
- "nozzle_target",
- "nozzle_2",
- "nozzle_2_target",
- "bed",
- "bed_target",
- "chamber",
- "chamber_target",
- )
- def display_temperatures(temperatures: dict | None, model: str | None) -> dict[str, float]:
- """Filter `state.temperatures` down to the readings a viewer is shown.
- Drops chamber readings on models without a real chamber sensor — P1P, P1S,
- A1 and A1 mini all report a meaningless `chamber_temper` — matching what
- ``printer_state_to_dict`` already does for the full status payload.
- """
- if not temperatures:
- return {}
- allow_chamber = supports_chamber_temp(model)
- out: dict[str, float] = {}
- for key in DISPLAY_TEMPERATURE_KEYS:
- if key.startswith("chamber") and not allow_chamber:
- continue
- value = temperatures.get(key)
- if value is None:
- continue
- try:
- out[key] = float(value)
- except (TypeError, ValueError):
- continue
- return out
- def uniform_tray_filament_hint(loaded_types: list[str]) -> str | None:
- """Guess an active cycle's filament from the loaded trays.
- Bambu never echoes back which filament or temperature a drying cycle is
- running, so the badge normally reads the target we cached when we sent the
- command. This is the fallback for when we have no record — drying started in
- a previous backend lifetime, or from the printer's own screen.
- It answers only when every loaded tray holds the same filament type. On a
- mixed unit the first tray is evidence of nothing: an AMS holding two PETG
- and two PLA spools, drying PLA at the 45°C the user picked, was labelled
- "PETG @ 65°C" purely because slot 1 happened to be PETG (#2759).
- Deliberately no temperature. The RFID-recommended ``drying_temp`` used to be
- returned alongside a uniform filament, which narrowed #2759 to units whose
- spools disagree but left the uniform case stating a temperature just as
- invented: a unit loaded entirely with PLA, drying at the 45°C the user
- picked, read "PLA @ 55°C" the moment the cached target went missing. The
- filament type is real evidence — every spool in the unit agrees on it, and
- the dryer heats all of them — but the temperature is a free choice in the
- popover, so a recommendation is never evidence of what is running. The badge
- shows the filament and the countdown, and names a temperature only when we
- actually sent it.
- Args:
- loaded_types: ``tray_type`` for each tray, in slot order. Empty slots
- (falsy) are ignored.
- Returns:
- The shared filament type, or None if the loaded trays disagree or the
- unit is empty.
- """
- types = {str(tray_type) for tray_type in loaded_types if tray_type}
- if len(types) != 1:
- return None
- return next(iter(types))
- def supports_drying(model: str | None, firmware: str | None) -> bool:
- """Check if a printer model accepts remote AMS drying commands.
- Known models with confirmed min firmware get version-gated.
- Known unsupported models, and models that only dry from their own screen,
- are blocked.
- All other models (H2D Pro, X1E, future models) are allowed —
- the command fails gracefully with result: "fail" if unsupported.
- """
- if not model:
- return False
- model_upper = model.strip().upper()
- if model_upper in _DRYING_UNSUPPORTED_MODELS or model_upper in _DRYING_SCREEN_ONLY_MODELS:
- return False
- if model_upper in _DRYING_MIN_FIRMWARE:
- return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
- # For all other models: allow
- return True
- # Minimum firmware versions for AMS "Print While Drying" — drying that runs CONCURRENTLY
- # with an active print. Strictly stricter than _DRYING_MIN_FIRMWARE (idle drying). Verified
- # against Bambu wiki release notes — the canonical phrasing on every supported model is
- # "printing while filament is drying" / "Print While Drying". Models absent from the wiki
- # release notes (A1, A1 Mini, P1*, X1 non-C, X1E) are intentionally excluded — the firmware
- # will reject the command in those cases anyway via dry_sf_reason=[0] (TaskOccupied).
- _DRY_WHILE_PRINTING_MIN_FIRMWARE: dict[str, str] = {
- "H2D": "01.03.00.00",
- "H2D PRO": "01.02.00.00",
- "H2DPRO": "01.02.00.00",
- "O1E": "01.02.00.00", # H2D Pro SSDP code
- "O2D": "01.02.00.00", # H2D Pro alternate code
- "H2C": "01.02.00.00",
- "O1C": "01.02.00.00", # H2C SSDP code
- "O1C2": "01.02.00.00", # H2C dual-nozzle SSDP code
- "H2S": "01.02.00.00",
- "X2D": "01.01.00.00",
- "N6": "01.01.00.00", # X2D internal code
- "X1C": "01.11.02.00",
- "BL-P001": "01.11.02.00", # X1C internal code
- "P2S": "01.02.00.00",
- "N7": "01.02.00.00", # P2S internal code
- "A2L": "01.01.00.00",
- "N9": "01.01.00.00", # A2L internal code
- }
- def supports_drying_while_printing(model: str | None, firmware: str | None) -> bool:
- """Check if a printer model+firmware supports running AMS drying CONCURRENTLY
- with an active print.
- Distinct from supports_drying() — that gates idle drying. This gate is strict:
- only models explicitly confirmed by Bambu wiki release notes are allowed.
- On unsupported models the firmware returns dry_sf_reason=[0] (TaskOccupied)
- while a print is running, so being conservative here costs nothing — the
- firmware is the ultimate arbiter, this gate just hides UI affordances.
- """
- if not model:
- return False
- model_upper = model.strip().upper()
- if model_upper not in _DRY_WHILE_PRINTING_MIN_FIRMWARE:
- return False
- return bool(firmware and firmware >= _DRY_WHILE_PRINTING_MIN_FIRMWARE[model_upper])
- class PrinterInfo:
- """Basic printer info for callbacks."""
- def __init__(self, name: str, serial_number: str):
- self.name = name
- self.serial_number = serial_number
- class PrinterManager:
- """Manager for multiple printer connections."""
- def __init__(self):
- self._clients: dict[int, BambuMQTTClient] = {}
- self._models: dict[int, str | None] = {} # Cache printer models for feature detection
- self._printer_info: dict[int, PrinterInfo] = {} # Cache printer name/serial for callbacks
- # Last AMS / external-spool reading of a printer whose client has been
- # dropped, so the queue can still tell which machine holds which colour
- # (#2876). Deliberately outside the client's own state: it answers
- # "what did this printer last have loaded", not "what is it reporting
- # now", and the two must not be confused by anything that displays or
- # merges live status.
- self._last_trays: dict[int, dict] = {}
- self._on_print_start: Callable[[int, dict], None] | None = None
- self._on_print_complete: Callable[[int, dict], None] | None = None
- self._on_print_running_observed: Callable[[int, dict], None] | None = None
- self._on_finish_photo_moment: Callable[[int, dict], None] | None = None
- self._on_status_change: Callable[[int, PrinterState], None] | None = None
- self._on_ams_change: Callable[[int, list], None] | None = None
- self._on_fts_inlet_change: Callable[[int, int, str], None] | None = None
- self._on_layer_change: Callable[[int, int], None] | None = None
- self._on_print_progress: Callable[[int, int], None] | None = None
- self._on_bed_temp_update: Callable[[int, float], None] | None = None
- self._on_drying_complete: Callable[[int, int], None] | None = None
- self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
- self._on_tray_change: Callable[[int, int, int], None] | None = None
- self._loop: asyncio.AbstractEventLoop | None = None
- # Track who started the current print (Issue #206)
- self._current_print_user: dict[int, dict] = {} # {printer_id: {"user_id": int, "username": str}}
- # Track printers awaiting plate-clear acknowledgment after a finished/failed print.
- # Persisted to DB (printers.awaiting_plate_clear) so the gate survives restarts/power
- # cycles — see issue #961. Loaded into this set at startup via load_awaiting_plate_clear_from_db().
- self._awaiting_plate_clear: set[int] = set()
- def get_printer(self, printer_id: int) -> PrinterInfo | None:
- """Get printer info by ID."""
- return self._printer_info.get(printer_id)
- def set_current_print_user(self, printer_id: int, user_id: int, username: str):
- """Track who started the current print (Issue #206)."""
- self._current_print_user[printer_id] = {"user_id": user_id, "username": username}
- def get_current_print_user(self, printer_id: int) -> dict | None:
- """Get the user who started the current print (Issue #206)."""
- return self._current_print_user.get(printer_id)
- def clear_current_print_user(self, printer_id: int):
- """Clear the current print user when print completes (Issue #206)."""
- self._current_print_user.pop(printer_id, None)
- def is_awaiting_plate_clear(self, printer_id: int) -> bool:
- """Return True when the printer finished/failed a print and is waiting for the
- user to acknowledge the plate is cleared before the queue may dispatch the next job.
- """
- return printer_id in self._awaiting_plate_clear
- def set_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
- """Set/clear the awaiting-plate-clear gate and persist it to DB.
- Persisted so the gate survives Bambuddy/printer restarts (#961): after Auto Off
- cycles the printer, the printer boots into IDLE with no memory of the previous
- finish, and without persistence the queue would bypass the confirmation prompt.
- Also broadcasts an updated ``printer_status`` over the WebSocket (#1128).
- ``awaiting_plate_clear`` is a Bambuddy-side flag — toggling it does not
- produce an MQTT push from the printer, so without an explicit broadcast
- any UI subscriber that's NOT the originating tab would stay stale until
- the next coincidental status refresh. The plate-clear button on the
- printer card disappeared "immediately" only because of an optimistic
- React Query cache update on the click path; clearing the flag through
- any other route (an admin script, a second tab, an automation that
- hits ``POST /printers/{id}/clear-plate`` directly) silently broke the
- UI without it. Centralised here so every current AND future caller is
- covered without each one having to remember to broadcast.
- """
- # Callers re-assert the current value routinely (the queue clears the gate
- # on every dispatch, whether or not it was up), so the outward-facing
- # emissions below are edge-triggered — an MQTT subscriber or a phone
- # notification must not see a "plate cleared" for a plate that was never
- # dirty. Persistence and the WebSocket broadcast stay unconditional: they
- # are idempotent and predate this (#961/#1128).
- changed = awaiting != (printer_id in self._awaiting_plate_clear)
- if awaiting:
- self._awaiting_plate_clear.add(printer_id)
- else:
- self._awaiting_plate_clear.discard(printer_id)
- # Only create the coroutine when there is a loop to run it on — otherwise Python
- # emits "coroutine was never awaited" warnings (e.g. in sync unit tests).
- if self._loop and self._loop.is_running():
- self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
- self._schedule_async(self._broadcast_status_change(printer_id))
- if changed:
- self._schedule_async(self._emit_plate_clear_change(printer_id, awaiting))
- async def _emit_plate_clear_change(self, printer_id: int, awaiting: bool) -> None:
- """Relay a plate-clear gate transition to MQTT and notifications (#2525).
- The flag is Bambuddy-side, so nothing about it reaches an external
- automation on its own — the printer's own MQTT push knows only
- RUNNING/PAUSE/FAILED/FINISH/IDLE. Emitted from here rather than from the
- three call sites so every current and future caller is covered, the same
- reasoning as the WebSocket broadcast above.
- Imports are local: ``mqtt_relay`` and ``notification_service`` both sit
- above this module in the dependency order.
- """
- printer = self.get_printer(printer_id)
- if not printer:
- # No cached info means no client is registered — the printer was
- # disconnected outright rather than merely powered off. The gate is
- # still releasable from the API in that state (#2864), and a retained
- # MQTT topic left saying "awaiting" would outlive the truth, so fall
- # back to the row rather than dropping the emission.
- printer = await self._printer_info_from_db(printer_id)
- if not printer:
- return
- try:
- from backend.app.services.mqtt_relay import mqtt_relay
- await mqtt_relay.on_plate_clear_state(printer_id, printer.name, printer.serial_number, awaiting)
- except Exception as e:
- logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
- # Only the rising edge is worth a notification — "the bed is now free"
- # is not an action item, and the queue clears the gate by itself.
- if not awaiting:
- return
- try:
- from backend.app.core.database import async_session
- from backend.app.services.notification_service import notification_service
- async with async_session() as db:
- await notification_service.on_plate_clear_required(printer_id, printer.name, db)
- except Exception as e:
- logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
- async def _printer_info_from_db(self, printer_id: int) -> PrinterInfo | None:
- """Name and serial for a printer with no registered client."""
- from backend.app.core.database import async_session
- try:
- async with async_session() as db:
- row = (
- await db.execute(select(Printer.name, Printer.serial_number).where(Printer.id == printer_id))
- ).first()
- except Exception as e:
- logger.warning("Failed to load printer %d info from DB: %s", printer_id, e)
- return None
- return PrinterInfo(row[0], row[1]) if row else None
- async def _broadcast_status_change(self, printer_id: int) -> None:
- """Emit a ``printer_status`` WebSocket update for this printer (#1128).
- Used for state changes that don't come from MQTT — currently just the
- ``awaiting_plate_clear`` flag, but any future Bambuddy-side flag added
- to ``printer_state_to_dict`` should plumb through here too. The
- existing MQTT-driven broadcast in ``main.on_printer_status_change``
- deduplicates on a status_key that intentionally excludes Bambuddy
- flags (so e.g. queue-state changes don't get echoed as printer
- events), which is precisely why those flags need their own emit.
- Lazy-imports ``ws_manager`` to keep ``printer_manager`` clean of
- application-layer infra at module-import time — the broadcast is the
- only thing here that needs it.
- """
- state = self.get_status(printer_id)
- if not state:
- # Printer disconnected or unknown — nothing to broadcast. The
- # next reconnect will produce a fresh status push anyway, so the
- # UI eventually catches up without us forcing a stale snapshot
- # on subscribers now.
- return
- try:
- from backend.app.core.websocket import ws_manager
- await ws_manager.send_printer_status(
- printer_id,
- printer_state_to_dict(
- state,
- printer_id,
- self.get_model(printer_id),
- self.get_drying_targets(printer_id),
- ),
- )
- except Exception as e:
- logger.warning(
- "Failed to broadcast printer_status after Bambuddy-side state change for printer %d: %s",
- printer_id,
- e,
- )
- async def _persist_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
- from backend.app.core.database import run_with_retry
- async def _do(db):
- printer = await db.get(Printer, printer_id)
- if printer is not None:
- printer.awaiting_plate_clear = awaiting
- await db.commit()
- try:
- await run_with_retry(_do, label=f"persist awaiting_plate_clear printer={printer_id}")
- except Exception as e:
- logger.warning("Failed to persist awaiting_plate_clear for printer %d: %s", printer_id, e)
- async def load_awaiting_plate_clear_from_db(self):
- """Rehydrate the awaiting-plate-clear set from the printers table on startup."""
- from backend.app.core.database import async_session
- try:
- async with async_session() as db:
- result = await db.execute(select(Printer.id).where(Printer.awaiting_plate_clear.is_(True)))
- ids = {row[0] for row in result.all()}
- self._awaiting_plate_clear = ids
- if ids:
- logger.info("Loaded %d printer(s) awaiting plate-clear acknowledgment: %s", len(ids), sorted(ids))
- except Exception as e:
- logger.warning("Failed to load awaiting_plate_clear from DB: %s", e)
- def set_event_loop(self, loop: asyncio.AbstractEventLoop):
- """Set the event loop for async callbacks."""
- self._loop = loop
- def set_print_start_callback(self, callback: Callable[[int, dict], None]):
- """Set callback for print start events."""
- self._on_print_start = callback
- def set_print_complete_callback(self, callback: Callable[[int, dict], None]):
- """Set callback for print completion events."""
- self._on_print_complete = callback
- def set_print_running_observed_callback(self, callback: Callable[[int, dict], None]):
- """Set callback for restart-recovery RUNNING-state observations (#1485
- follow-up). Fires the first time we see ``state == RUNNING`` for a
- printer that started its print before Bambuddy came up — the #1304
- guard suppresses ``on_print_start`` for these, so anything that
- normally hangs off it (e.g. timelapse baseline capture) needs this
- hook to recover."""
- self._on_print_running_observed = callback
- def set_finish_photo_moment_callback(self, callback: Callable[[int, dict], None]):
- """Set callback for the #1721 finish-photo moment.
- Fires on the stage-22 (\"Filament unloading\") edge at end-of-print
- — the framing window where the toolhead is parked but the bed
- hasn't dropped yet. Falls back to firing at the FINISH-state
- transition for prints that skip stage 22 (cancel, external-spool-
- only, HMS halt, firmware variants). Payload includes the
- ``trigger`` key (``\"stage_22\"`` or ``\"finish_state\"``) and
- ``timelapse_was_active`` so the photo path can choose between
- live-camera capture and timelapse last-frame extraction."""
- self._on_finish_photo_moment = callback
- def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
- """Set callback for status change events."""
- self._on_status_change = callback
- def set_ams_change_callback(self, callback: Callable[[int, list], None]):
- """Set callback for AMS data change events."""
- self._on_ams_change = callback
- def set_fts_inlet_change_callback(self, callback: Callable[[int, int, str], None]):
- """Set callback for Filament Track Switch inlet moves.
- Receives ``(printer_id, ams_id, inlet)``. Fired only when an AMS moves
- between inlets, not on the first sighting of a binding.
- """
- self._on_fts_inlet_change = callback
- def set_layer_change_callback(self, callback: Callable[[int, int], None]):
- """Set callback for layer change events. Receives (printer_id, layer_num)."""
- self._on_layer_change = callback
- def set_print_progress_callback(self, callback: Callable[[int, int], None]):
- """Set callback for print-progress advances (#2547).
- Receives (printer_id, percent) each time `mc_percent` increases during a
- running print — including the final layer, where layer-change events
- have already stopped.
- """
- self._on_print_progress = callback
- def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
- """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
- self._on_bed_temp_update = callback
- def set_drying_complete_callback(self, callback: Callable[[int, int], None]):
- """Set callback for AMS drying completion events (#1349).
- Receives ``(printer_id, ams_id)``. Fires once per falling edge of
- ``dry_time`` (>0 → 0) for each AMS unit.
- """
- self._on_drying_complete = callback
- def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
- """Set callback for spool-assignment read-back verification (#2582).
- Receives ``(printer_id, ams_id, tray_id, verified, detail)``. Fires once
- per assignment either when the tray telemetry confirms the pushed
- filament id or when the verification window elapses without it.
- """
- self._on_assignment_verified = callback
- def set_tray_change_callback(self, callback: Callable[[int, int, int], None]):
- """Set callback for mid-print tray changes.
- Receives ``(printer_id, global_tray_id, layer_num)`` for every entry
- appended to the printer's tray-change log, so it can be persisted for
- the completion-time weight split.
- """
- self._on_tray_change = callback
- def _schedule_async(self, coro):
- """Schedule an async coroutine from a sync context.
- Captures exceptions from the coroutine and logs them to prevent
- silent failures in callbacks.
- """
- if self._loop and self._loop.is_running():
- future = asyncio.run_coroutine_threadsafe(coro, self._loop)
- def handle_exception(f):
- try:
- # This will re-raise any exception from the coroutine
- f.result()
- except Exception as e:
- import logging
- logging.getLogger(__name__).error(f"Exception in scheduled callback: {e}", exc_info=True)
- future.add_done_callback(handle_exception)
- def last_known_trays(self, printer_id: int) -> dict:
- """What this printer last had loaded, for a printer with no live client.
- Only the tray keys, and only as history: a caller that wants to know
- what a printer is reporting *now* must use :meth:`get_status`. This
- exists because dropping a client drops its status with it, and the
- queue reads the loaded filament to decide which offline printer is
- worth switching on (#2876) — ``_power_on_and_wait`` replaces the client
- on every attempt, so without this each attempt erased the reading the
- next one needs.
- """
- return self._last_trays.get(printer_id, {})
- def _remember_trays(self, printer_id: int) -> None:
- """Keep the tray reading of a client that is about to be dropped."""
- client = self._clients.get(printer_id)
- if not client:
- return
- raw = client.state.raw_data or {}
- remembered = {key: raw[key] for key in ("ams", "vt_tray") if raw.get(key)}
- if remembered:
- self._last_trays[printer_id] = remembered
- async def connect_printer(self, printer: Printer) -> bool:
- """Connect to a printer."""
- if printer.id in self._clients:
- self.disconnect_printer(printer.id)
- printer_id = printer.id
- def on_state_change(state: PrinterState):
- if self._on_status_change:
- self._schedule_async(self._on_status_change(printer_id, state))
- def on_print_start(data: dict):
- if self._on_print_start:
- self._schedule_async(self._on_print_start(printer_id, data))
- def on_print_complete(data: dict):
- if self._on_print_complete:
- self._schedule_async(self._on_print_complete(printer_id, data))
- def on_print_running_observed(data: dict):
- if self._on_print_running_observed:
- self._schedule_async(self._on_print_running_observed(printer_id, data))
- def on_finish_photo_moment(data: dict):
- if self._on_finish_photo_moment:
- self._schedule_async(self._on_finish_photo_moment(printer_id, data))
- def on_ams_change(ams_data: list):
- if self._on_ams_change:
- self._schedule_async(self._on_ams_change(printer_id, ams_data))
- def on_fts_inlet_change(ams_id: int, inlet: str):
- if self._on_fts_inlet_change:
- self._schedule_async(self._on_fts_inlet_change(printer_id, ams_id, inlet))
- def on_layer_change(layer_num: int):
- if self._on_layer_change:
- self._schedule_async(self._on_layer_change(printer_id, layer_num))
- def on_print_progress(percent: int):
- if self._on_print_progress:
- self._schedule_async(self._on_print_progress(printer_id, percent))
- def on_bed_temp_update(bed_temp: float):
- if self._on_bed_temp_update:
- self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
- def on_drying_complete(ams_id: int):
- if self._on_drying_complete:
- self._schedule_async(self._on_drying_complete(printer_id, ams_id))
- def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
- if self._on_assignment_verified:
- self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
- def on_tray_change(tray_global: int, layer_num: int):
- if self._on_tray_change:
- self._schedule_async(self._on_tray_change(printer_id, tray_global, layer_num))
- client = BambuMQTTClient(
- ip_address=printer.ip_address,
- serial_number=printer.serial_number,
- access_code=printer.access_code,
- model=printer.model,
- on_state_change=on_state_change,
- on_print_start=on_print_start,
- on_print_complete=on_print_complete,
- on_ams_change=on_ams_change,
- on_fts_inlet_change=on_fts_inlet_change,
- on_layer_change=on_layer_change,
- on_print_progress=on_print_progress,
- on_bed_temp_update=on_bed_temp_update,
- on_drying_complete=on_drying_complete,
- on_print_running_observed=on_print_running_observed,
- on_finish_photo_moment=on_finish_photo_moment,
- on_assignment_verified=on_assignment_verified,
- on_tray_change=on_tray_change,
- )
- client.connect()
- self._clients[printer_id] = client
- self._models[printer_id] = printer.model # Cache model for feature detection
- self._printer_info[printer_id] = PrinterInfo(printer.name, printer.serial_number)
- # Wait a moment for connection
- await asyncio.sleep(1)
- return client.state.connected
- def disconnect_printer(self, printer_id: int, timeout: float = 0):
- """Disconnect from a printer."""
- if printer_id in self._clients:
- self._remember_trays(printer_id)
- self._clients[printer_id].disconnect(timeout=timeout)
- del self._clients[printer_id]
- self._models.pop(printer_id, None) # Clean up model cache
- self._printer_info.pop(printer_id, None) # Clean up printer info cache
- def disconnect_all(self, timeout: float = 0):
- """Disconnect from all printers."""
- for printer_id in list(self._clients.keys()):
- self.disconnect_printer(printer_id, timeout=timeout)
- def get_status(self, printer_id: int) -> PrinterState | None:
- """Get the current status of a printer (checks for stale connections)."""
- if printer_id in self._clients:
- client = self._clients[printer_id]
- # Check staleness and update connected state if needed
- client.check_staleness()
- return client.state
- return None
- # Gcode states in which a job is loaded / in progress and cutting power
- # would ruin the print. PAUSE is included on purpose — a paused print is
- # still loaded on the bed. Used by the smart-plug auto-off guard (#1890) so
- # a re-print started from the touchscreen isn't killed mid-print.
- ACTIVE_PRINT_STATES = ("RUNNING", "PAUSE", "PREPARE", "SLICING")
- def is_print_active(self, printer_id: int) -> bool:
- """True when the printer currently has a print loaded / in progress.
- Returns False when disconnected or in any idle/terminal state
- (IDLE / FINISH / FAILED / unknown), so callers fail *open* only for
- the safe "nothing is printing" case. #1890.
- """
- state = self.get_status(printer_id)
- if not state or not state.connected:
- return False
- return state.state in self.ACTIVE_PRINT_STATES
- def get_model(self, printer_id: int) -> str | None:
- """Get the cached model for a printer."""
- return self._models.get(printer_id)
- def get_drying_targets(self, printer_id: int) -> dict[int, dict] | None:
- """Get cached active drying target params keyed by AMS id.
- Returned dict shape: ``{ams_id: {"filament": str, "temp": int}}``.
- Returns ``None`` when the printer is not connected. The cache is
- seeded by ``send_drying_command(mode=1)`` and cleared when drying
- stops or on the ``dry_time`` falling edge (handled inside
- ``BambuMQTTClient``).
- """
- client = self._clients.get(printer_id)
- return client._drying_targets if client else None
- def get_all_statuses(self) -> dict[int, PrinterState]:
- """Get status of all connected printers (checks for stale connections)."""
- result = {}
- for printer_id, client in self._clients.items():
- # Check staleness and update connected state if needed
- client.check_staleness()
- result[printer_id] = client.state
- return result
- def is_connected(self, printer_id: int) -> bool:
- """Check if a printer is connected (checks for stale connections)."""
- if printer_id in self._clients:
- client = self._clients[printer_id]
- # Check staleness and update connected state if needed
- return client.check_staleness()
- return False
- def get_client(self, printer_id: int) -> BambuMQTTClient | None:
- """Get the MQTT client for a printer."""
- return self._clients.get(printer_id)
- def mark_printer_offline(self, printer_id: int):
- """Mark a printer as offline and trigger status callback.
- This is used when we know the printer power was cut (e.g., smart plug turned off)
- to immediately update the UI without waiting for MQTT timeout.
- The mark is a presumption, not a fact: the plug may not actually feed
- the printer. ``BambuMQTTClient.mark_power_off`` records the state it
- overwrites so the client can undo it as soon as the printer sends
- another report (#2629).
- """
- import logging
- logger = logging.getLogger(__name__)
- if printer_id in self._clients:
- client = self._clients[printer_id]
- if client.mark_power_off():
- logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
- # Trigger the status change callback to broadcast via WebSocket
- if self._on_status_change:
- self._schedule_async(self._on_status_change(printer_id, client.state))
- def start_print(
- self,
- printer_id: int,
- filename: str,
- plate_id: int = 1,
- ams_mapping: list[int] | None = None,
- bed_levelling: str = "auto",
- flow_cali: str = "auto",
- vibration_cali: bool = True,
- layer_inspect: bool = False,
- timelapse: bool = False,
- use_ams: bool = True,
- nozzle_offset_cali: str = "auto",
- nozzle_mapping: str | None = None,
- nozzle_slot_extruders: str | None = None,
- ) -> bool:
- """Start a print on a connected printer.
- ``nozzle_mapping`` is an opaque JSON string captured from BambuStudio's
- project_file MQTT command (H2C rack-swap slicer pick preservation,
- #1780). It rides through to the MQTT client untouched; the dispatch
- builder there parses + injects it only on dual-nozzle models.
- ``nozzle_slot_extruders`` is the fallback for a job that never passed
- through BambuStudio (#2800): per-slot extruder indices the MQTT layer
- resolves into physical rack positions, and only on rack models.
- """
- caller = traceback.extract_stack(limit=3)[0]
- logger.info(
- "PRINT COMMAND: printer=%s, file=%s, caller=%s:%s:%s",
- printer_id,
- filename,
- caller.filename.split("/")[-1],
- caller.lineno,
- caller.name,
- )
- if printer_id in self._clients:
- return self._clients[printer_id].start_print(
- filename,
- plate_id,
- ams_mapping=ams_mapping,
- timelapse=timelapse,
- bed_levelling=bed_levelling,
- flow_cali=flow_cali,
- vibration_cali=vibration_cali,
- layer_inspect=layer_inspect,
- use_ams=use_ams,
- nozzle_offset_cali=nozzle_offset_cali,
- nozzle_mapping=nozzle_mapping,
- nozzle_slot_extruders=nozzle_slot_extruders,
- )
- return False
- def stop_print(self, printer_id: int) -> bool:
- """Stop the current print on a connected printer."""
- if printer_id in self._clients:
- return self._clients[printer_id].stop_print()
- return False
- async def wait_for_cooldown(
- self,
- printer_id: int,
- target_temp: float = 50.0,
- timeout: int = 600,
- check_interval: int = 10,
- ) -> bool:
- """Wait for the nozzle to cool down to a safe temperature.
- Args:
- printer_id: The printer to monitor
- target_temp: Target temperature to wait for (default 50°C)
- timeout: Maximum seconds to wait (default 600s = 10 min)
- check_interval: Seconds between temperature checks (default 10s)
- Returns:
- True if cooled down, False if timeout or not connected
- """
- import logging
- logger = logging.getLogger(__name__)
- elapsed = 0
- while elapsed < timeout:
- state = self.get_status(printer_id)
- if not state or not state.connected:
- logger.warning("Printer %s disconnected during cooldown wait", printer_id)
- return False
- # Check nozzle temperature (and nozzle_2 for dual extruders)
- nozzle_temp = state.temperatures.get("nozzle", 0)
- nozzle_2_temp = state.temperatures.get("nozzle_2", 0)
- max_temp = max(nozzle_temp, nozzle_2_temp)
- if max_temp <= target_temp:
- logger.info("Printer %s cooled down to %s°C", printer_id, max_temp)
- return True
- logger.debug("Printer %s nozzle at %s°C, waiting for %s°C...", printer_id, max_temp, target_temp)
- await asyncio.sleep(check_interval)
- elapsed += check_interval
- logger.warning("Printer %s cooldown timeout after %ss", printer_id, timeout)
- return False
- def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:
- """Enable or disable MQTT logging for a printer."""
- if printer_id in self._clients:
- self._clients[printer_id].enable_logging(enabled)
- return True
- return False
- def get_logs(self, printer_id: int) -> list[MQTTLogEntry]:
- """Get MQTT logs for a printer."""
- if printer_id in self._clients:
- return self._clients[printer_id].get_logs()
- return []
- def clear_logs(self, printer_id: int) -> bool:
- """Clear MQTT logs for a printer."""
- if printer_id in self._clients:
- self._clients[printer_id].clear_logs()
- return True
- return False
- def is_logging_enabled(self, printer_id: int) -> bool:
- """Check if logging is enabled for a printer."""
- if printer_id in self._clients:
- return self._clients[printer_id].logging_enabled
- return False
- def send_drying_command(
- self,
- printer_id: int,
- ams_id: int,
- temp: int,
- duration: int,
- mode: int = 1,
- filament: str = "",
- rotate_tray: bool = False,
- ) -> bool:
- """Send AMS drying command to printer."""
- if printer_id not in self._clients:
- return False
- return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament, rotate_tray)
- def request_status_update(self, printer_id: int) -> bool:
- """Request a full status update from the printer.
- This sends a 'pushall' command to get the latest data including nozzle info.
- """
- if printer_id in self._clients:
- return self._clients[printer_id].request_status_update()
- return False
- # Probe budget for test_connection (#1445). Was a fixed 2s sleep, which was
- # too short for P1S firmware whose broker / TLS handshake routinely takes
- # 3–5s to surface a CONNACK on a cold MQTT session. We now poll up to
- # PROBE_TIMEOUT_SECONDS and early-return the moment we see connected=True,
- # so happy-path connections still finish in ~1–2s and slow brokers get the
- # headroom they need instead of getting falsely rejected.
- PROBE_TIMEOUT_SECONDS = 8.0
- PROBE_POLL_INTERVAL_SECONDS = 0.2
- async def test_connection(
- self,
- ip_address: str,
- serial_number: str,
- access_code: str,
- ) -> dict:
- """Test connection to a printer without persisting.
- Polls for up to PROBE_TIMEOUT_SECONDS and tears the probe client down
- off-loop. The teardown matters: `client.disconnect()` ends in paho's
- `loop_stop()` which `join()`s the network thread — if the thread is
- still mid-TLS-handshake to a slow printer, that join blocks the
- asyncio event loop and every other HTTP request queues behind it. The
- original synchronous teardown produced the #1445 "Docker container
- hangs" symptom on P1S when called from POST /printers/.
- """
- client = BambuMQTTClient(
- ip_address=ip_address,
- serial_number=serial_number,
- access_code=access_code,
- )
- try:
- client.connect()
- deadline = asyncio.get_running_loop().time() + self.PROBE_TIMEOUT_SECONDS
- while not client.state.connected and asyncio.get_running_loop().time() < deadline:
- await asyncio.sleep(self.PROBE_POLL_INTERVAL_SECONDS)
- result = {
- "success": client.state.connected,
- "state": client.state.state if client.state.connected else None,
- "model": client.state.raw_data.get("device_model"),
- # Why the probe failed, when the printer told us: one of the
- # CONNECT_ERROR_* slugs, else None. Lets the add-printer flow
- # and the connection diagnostic say "the printer rejected the
- # access code" instead of an unqualified failure (#2698).
- "reason": None if client.state.connected else client.last_connect_error,
- }
- finally:
- # Off-loop teardown — see docstring. paho's loop_stop() joins the
- # network thread which may still be in a slow TLS handshake.
- await asyncio.to_thread(client.disconnect)
- return result
- def get_derived_status_name(state: PrinterState, model: str | None = None) -> str | None:
- """
- Compute a human-readable status name based on printer state.
- Uses stg_cur when available, otherwise derives status from temperature data
- when the printer is heating before a print starts.
- Args:
- state: The printer state to analyze
- model: Optional printer model for model-specific workarounds
- """
- # Firmware bug: some models (A1, P1P, P1S) report stg_cur=0 when not printing.
- # stg_cur=0 maps to "Printing" in STAGE_NAMES, which incorrectly overrides the
- # real state (IDLE, FINISH, FAILED, etc.). Only trust stg_cur when the printer
- # is actually in an active print state (RUNNING or PAUSE).
- if state.state not in ("RUNNING", "PAUSE") and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
- return None
- # If we have a valid calibration stage, use it
- # X1 models use -1 for idle, A1/P1 models use 255 for idle
- # Valid stage numbers are 0-254
- if 0 <= state.stg_cur < 255:
- # A stage number the table does not cover is named "Preparing" rather
- # than "Unknown stage (72)". New models report stages before Bambuddy
- # learns their names -- the H2C still has several -- and the card is
- # the wrong place to say so: the number means nothing to the person
- # reading it, and every stage that has ever turned out to be unnamed
- # was part of the run-up to printing, so "Preparing" is both the more
- # useful answer and the more likely one.
- #
- # This is display only, and deliberately not pushed down into
- # `get_stage_name`. That function also feeds the stage-transition log
- # line and the once-per-session warning that exists precisely to
- # capture unnamed stages so they can be named later (bambu_mqtt.py
- # ~4100) -- there the number is the entire diagnostic value, and
- # replacing it with "Preparing" would hide the very thing that
- # reports these.
- if state.stg_cur not in STAGE_NAMES:
- return "Preparing"
- return get_stage_name(state.stg_cur)
- # If not in RUNNING state, no derived status needed
- if state.state != "RUNNING":
- return None
- # Check if we're in an early phase where temperatures are heating
- temps = state.temperatures or {}
- progress = state.progress or 0
- # Only derive heating status when progress is very low (< 2%)
- # This indicates we're in the preparation phase, not actually printing
- if progress >= 2:
- return None
- # Check bed temperature - if target is set and current is significantly below
- bed_temp = temps.get("bed", 0)
- bed_target = temps.get("bed_target", 0)
- # Check nozzle temperature
- nozzle_temp = temps.get("nozzle", 0)
- nozzle_target = temps.get("nozzle_target", 0)
- # Temperature thresholds: consider "heating" if more than 10°C below target
- TEMP_THRESHOLD = 10
- # Determine what's heating (prioritize bed since it takes longer)
- if bed_target > 30 and (bed_target - bed_temp) > TEMP_THRESHOLD:
- return "Heating heatbed"
- elif nozzle_target > 30 and (nozzle_target - nozzle_temp) > TEMP_THRESHOLD:
- return "Heating nozzle"
- # If targets are set but we're close to them, we might be in final prep
- if bed_target > 30 or nozzle_target > 30:
- if progress == 0 and state.layer_num == 0:
- return "Preparing"
- return None
- _PLATE_ID_RE = re.compile(r"plate_(\d+)\.gcode")
- def parse_plate_id(gcode_file: str | None) -> int | None:
- """Extract the 1-indexed plate number from a Bambu gcode_file path.
- Returns None when the path is missing or has no `plate_N.gcode` segment.
- Shared by the REST status route and the WebSocket push path so both agree
- on the value sent to the frontend (#881 follow-up).
- """
- if not gcode_file:
- return None
- match = _PLATE_ID_RE.search(gcode_file)
- return int(match.group(1)) if match else None
- def resolve_plate_id(state) -> int | None:
- """Resolve the active plate number from a PrinterState.
- Some firmware versions (e.g. P1S 01.10.00.00, #1166) put only the .3mf
- filename in print.gcode_file, so parse_plate_id() returns None and the
- printer card falls back to plate 1 — wrong thumbnail. When Bambuddy
- dispatched the print itself we already know the right plate, so we prefer
- that over the gcode_file echo. The subtask check prevents stale values
- from a previous Bambuddy-dispatched print bleeding into a Studio-direct
- print on the same printer.
- """
- dispatched_plate = getattr(state, "dispatched_plate_id", None)
- dispatched_subtask = getattr(state, "dispatched_subtask", None)
- if (
- dispatched_plate is not None
- and dispatched_subtask is not None
- and state.subtask_name
- and dispatched_subtask == state.subtask_name
- ):
- return dispatched_plate
- return parse_plate_id(state.gcode_file)
- def resolve_expected_tray(
- raw_slot: int | None,
- ams_layout: list[tuple[int, bool]],
- mapping_raw: object,
- ) -> int | None:
- """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
- The firmware reports the target/previous slot as a bare number whose meaning
- depends on the AMS layout (see ``PrinterState.tray_tar``). This mirrors the
- ``tray_now`` handling so the resolved ID lines up with what the AMS graphic
- already highlights via ``ams_id*4 + slot``.
- ``ams_layout`` is a list of ``(ams_id, is_ams_ht)`` for the connected units.
- - ``255``/``-1`` (none/idle) -> ``None``
- - ``254`` (external spool) -> ``254``
- - ``128``-``135`` (AMS-HT) -> already global, returned as-is
- - ``0``-``3`` local slot:
- * exactly one regular AMS -> ``ams_id*4 + slot``
- * several regular AMS -> resolved via the snow-encoded ``mapping`` field
- (each entry = ``ams_hw_id*256 + slot``; ``65535`` = unmapped), or
- ``None`` when it stays ambiguous (honest "can't determine")
- * no regular AMS -> ``None``
- - ``4``-``15`` -> already a global regular-AMS ID, returned as-is
- Returns ``None`` for anything it can't place, so the caller surfaces a
- "check the printer" message instead of pointing at the wrong slot.
- """
- if raw_slot is None or raw_slot in (255, -1):
- return None
- if raw_slot == 254:
- return 254
- if 128 <= raw_slot <= 135:
- return raw_slot
- if 0 <= raw_slot <= 3:
- regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
- if len(regular) == 1:
- return regular[0] * 4 + raw_slot
- if len(regular) > 1:
- if not isinstance(mapping_raw, list):
- return None
- candidates: set[int] = set()
- for value in mapping_raw:
- if not isinstance(value, int) or value >= 65535:
- continue
- ams_hw_id = value >> 8
- slot = value & 0xFF
- if 0 <= ams_hw_id <= 3 and (slot & 0x03) == raw_slot:
- candidates.add(ams_hw_id * 4 + raw_slot)
- elif 128 <= ams_hw_id <= 135 and raw_slot == 0:
- candidates.add(ams_hw_id)
- return candidates.pop() if len(candidates) == 1 else None
- return None
- if 4 <= raw_slot <= 15:
- return raw_slot
- # 24-27 = A2L AMS-Lite (normalised unit 6) global tray ids, already resolved.
- if 24 <= raw_slot <= 27:
- return raw_slot
- return None
- def printer_state_to_dict(
- state: PrinterState,
- printer_id: int | None = None,
- model: str | None = None,
- drying_targets: dict[int, dict] | None = None,
- ) -> dict:
- """Convert PrinterState to a JSON-serializable dict.
- Args:
- state: The printer state to convert
- printer_id: Optional printer ID for generating cover URLs
- model: Optional printer model for filtering unsupported features
- drying_targets: Optional per-AMS active-cycle params
- (``{ams_id: {"filament": str, "temp": int}}``) sourced from the
- BambuMQTTClient cache so the badge can display "PETG @ 65°C".
- """
- # Parse AMS data from raw_data
- ams_units = []
- vt_tray = []
- raw_data = state.raw_data or {}
- # K value for a slot's bound profile. Shared with the REST serializer of
- # the same card (routes/printers.py) so the two cannot answer differently:
- # this one used to key on cali_idx alone, which on a dual-nozzle machine
- # meant whichever nozzle's table was listed last won the slot.
- resolve_slot_k = build_slot_k_resolver(state)
- if "ams" in raw_data and isinstance(raw_data["ams"], list):
- for ams_data in raw_data["ams"]:
- trays = []
- for tray in ams_data.get("tray", []):
- tag_uid = tray.get("tag_uid")
- if tag_uid in ("", "0000000000000000"):
- tag_uid = None
- tray_uuid = tray.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.get("k")
- cali_idx = tray.get("cali_idx")
- if k_value is None:
- k_value = resolve_slot_k(cali_idx, int(ams_data.get("id", 0)), int(tray.get("id", 0)))
- # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
- # @RosdasHH): for a truly empty slot the firmware sends only
- # {"id": N} — no state, no tray_type, no anything else. Treat
- # that as the firmware's "no spool" indicator (state=9) so the
- # assign-spool path in inventory.py can short-circuit a MQTT
- # publish the firmware would silently drop anyway. The
- # post-"Reset Slot" A1 Mini BMCU case sends a populated payload
- # (state=3, tray_type="") — different shape, doesn't match this
- # guard, still attempts the MQTT push per the #1322 fix.
- state_val = tray.get("state")
- if state_val is None and len(tray) == 1 and "id" in tray:
- state_val = 9
- trays.append(
- {
- "id": int(tray.get("id", 0)),
- "tray_color": tray.get("tray_color"),
- "tray_type": tray.get("tray_type"),
- "tray_sub_brands": tray.get("tray_sub_brands"),
- "tray_id_name": tray.get("tray_id_name"),
- "tray_info_idx": tray.get("tray_info_idx"),
- "remain": tray.get("remain", 0),
- "k": k_value,
- "cali_idx": cali_idx,
- "tag_uid": tag_uid,
- "tray_uuid": tray_uuid,
- "nozzle_temp_min": tray.get("nozzle_temp_min"),
- "nozzle_temp_max": tray.get("nozzle_temp_max"),
- "drying_temp": tray.get("drying_temp"),
- "drying_time": tray.get("drying_time"),
- "state": state_val,
- # Firmware's authoritative presence bit (tray_exist_bits),
- # set by apply_tray_exist_bits. The REST serializer already
- # emits it (routes/printers.py); without it here the WS
- # shallow-merge drops `exists` after the first frame and
- # getEmptySlotKind falls back to the firmware-variant state
- # 9/10 heuristic — wrong for AMS-HT in both directions (#2670).
- "exists": tray.get("exists"),
- }
- )
- # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
- 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
- # Fall back to index if no raw value (index is 1-5, not percentage)
- 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
- # Active-cycle filament + target temperature for the badge.
- # Bambu does not echo the cycle's chosen filament/temp on the
- # per-tick AMS push, so prefer the cached target from the last
- # ``send_drying_command``. When we have no record (drying
- # started in a previous backend lifetime, or the cache was
- # never seeded), the loaded trays can still name the filament
- # if they agree — but never the temperature, which only the
- # cache knows. See uniform_tray_filament_hint.
- ams_id_int = int(ams_data.get("id", 0))
- target = (drying_targets or {}).get(ams_id_int)
- dry_target_temp: int | None = None
- dry_filament: str | None = None
- if target:
- temp_val = target.get("temp")
- fil_val = target.get("filament") or ""
- if temp_val is not None:
- try:
- dry_target_temp = int(temp_val)
- except (TypeError, ValueError):
- dry_target_temp = None
- if fil_val:
- dry_filament = str(fil_val)
- if not dry_filament:
- dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
- ams_units.append(
- {
- "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 get_version
- "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),
- # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)
- "dry_status": int(ams_data.get("dry_status") or 0),
- "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),
- # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)
- "dry_sf_reason": list(ams_data.get("dry_sf_reason") or []),
- # Active-cycle filament name + target temperature
- "dry_target_temp": dry_target_temp,
- "dry_filament": dry_filament,
- # Module type: "ams", "n3f", "n3s" (from get_version)
- "module_type": str(ams_data.get("module_type") or ""),
- }
- )
- # Parse virtual tray (external spool) — now a list
- if "vt_tray" in raw_data:
- vt_tray_raw = raw_data["vt_tray"]
- # Defensive: MQTT sends vt_tray as a dict; normalize to list
- if isinstance(vt_tray_raw, dict):
- vt_tray_raw = [vt_tray_raw]
- elif not isinstance(vt_tray_raw, list):
- vt_tray_raw = []
- for vt_data in vt_tray_raw:
- 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 for vt_tray
- 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. The resolver
- # takes the 0/1 tray index, so normalise before asking.
- vt_id = int(vt_data.get("id", 254))
- vt_k_value = resolve_slot_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(
- {
- "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"),
- }
- )
- # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
- ams_extruder_map = raw_data.get("ams_extruder_map", {})
- # 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(model):
- temperatures = {
- k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
- }
- result = {
- "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,
- "hms_errors": [
- {
- "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,
- # Same field as the status response carries (#2926) — a relay
- # watching the stream should not have to poll REST to find out
- # what a fault means.
- "description": e.description,
- }
- for e in (state.hms_errors or [])
- ],
- # AMS data for filament colors
- "ams": ams_units if ams_units else None,
- "vt_tray": vt_tray,
- # AMS status for filament change tracking
- "ams_status_main": state.ams_status_main,
- "ams_status_sub": state.ams_status_sub,
- "tray_now": state.tray_now,
- # Runout / filament-replacement guidance (#2587). Only meaningful while
- # PAUSED — resolve the firmware's target/previous slot to a global tray ID
- # so the AMS graphic can highlight the slot the print now expects and name
- # the one that ran out. None when idle, not paused, or unresolvable.
- "expected_tray": (
- resolve_expected_tray(
- state.tray_tar,
- [(u["id"], u.get("is_ams_ht", False)) 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.get("is_ams_ht", False)) for u in ams_units],
- raw_data.get("mapping"),
- )
- if state.state == "PAUSE"
- else None
- ),
- # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
- "ams_extruder_map": ams_extruder_map,
- # Filament Track Switch. Both fields have to travel on the WebSocket, not
- # only on the REST status: the frontend shallow-merges each push over its
- # cached status, so a field that is absent here keeps whatever the last
- # full fetch left behind. Omitting them meant the AMS inlet badges only
- # ever changed on a page reload.
- "fila_switch": (
- {
- "installed": True,
- "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,
- # Mirrors BambuStudio's DevFilaSwitch::IsReady — every AMS has to
- # be bound to an inlet before the switch can route anything. Until
- # the operator has done that on the printer's Manual AMS Setup
- # screen, Studio refuses a load outright rather than sending a
- # command the firmware cannot act on, and so do we.
- # An empty AMS list is "ready", as it is in Studio: there is then
- # no slot to load from, so nothing can reach the check anyway, and
- # reporting not-ready would only mean a confusing toast on a
- # payload that has not carried the AMS block yet.
- #
- # An AMS still reporting a real extruder id rather than 0xE has no
- # inlet entry, so a machine with one hard-wired unit reads as not
- # ready. That looks harsh but is exactly Studio's own rule —
- # IsReady() requires a switcher position on *every* AMS, and only
- # the 0xE branch ever sets one (DevFilaSystem.cpp:596-615).
- "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
- ),
- # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Gated on the accessory
- # so a stale binding cannot outlive it being unplugged.
- "ams_switch_inlet": (dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
- # Which AMS slot each hotend is fed from: {extruder_id: {...}}. Travels on
- # the WebSocket for the same reason as fila_switch above — the frontend
- # shallow-merges pushes over its cached status, so an absent field keeps a
- # stale value forever. Empty on printers that do not report it.
- "extruder_slots": {
- str(ext_id): {
- "ams_id": slot.ams_id,
- "slot_id": slot.slot_id,
- "has_filament": slot.has_filament,
- }
- for ext_id, slot in state.extruder_slots.items()
- },
- # WiFi signal strength
- "wifi_signal": state.wifi_signal,
- "wired_network": state.wired_network,
- "door_open": state.door_open,
- # AMS Filament Backup state (auto-switch to second spool). Tri-state:
- # True / False / None. None = unknown or unsupported (A1 family). UI
- # uses this to drive the small status icon next to the AMS drying icon.
- "ams_filament_backup": state.ams_filament_backup,
- # Calibration stage tracking
- "stg_cur": state.stg_cur,
- "stg_cur_name": get_derived_status_name(state, model),
- # Printable objects count for skip objects feature
- "printable_objects_count": len(state.printable_objects),
- # Fan speeds (0-100 percentage, None if not available)
- "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,
- # Chamber light state
- "chamber_light": state.chamber_light,
- # Active extruder for dual-nozzle printers (0=right, 1=left)
- "active_extruder": state.active_extruder,
- # Print speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)
- "speed_level": state.speed_level,
- # H2C nozzle rack (tool-changer dock positions)
- # Map raw MQTT field names (type/diameter) to schema names (nozzle_type/nozzle_diameter)
- "nozzle_rack": [
- {
- "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", ""),
- }
- for n in (state.nozzle_rack or [])
- ],
- # AMS drying support
- "supports_drying": supports_drying(model, state.firmware_version),
- "supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
- "drying_screen_only": drying_screen_only(model),
- # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
- # Pushed via WebSocket so the printer card picks up plate transitions within
- # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).
- # current_archive_id is intentionally REST-only — it's stable for the life
- # of a print and needs a DB lookup the WebSocket path shouldn't pay for.
- "current_plate_id": resolve_plate_id(state),
- # Plate-clear gate (#939). Lives on the PrinterManager rather than PrinterState,
- # so surface it here — without this, WebSocket merges drop the flag and the
- # "Clear Plate" button only appears when the 30 s REST fallback poll runs.
- "awaiting_plate_clear": printer_manager.is_awaiting_plate_clear(printer_id) if printer_id else False,
- }
- # Add cover URL if there's an active print and printer_id is provided
- # Include PAUSE state so skip objects modal can show cover
- if printer_id and state.state in ("RUNNING", "PAUSE") and state.gcode_file:
- result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
- else:
- result["cover_url"] = None
- # Surface the display name + model so WS consumers (gcode viewer printer
- # selector) can render proper labels on the initial snapshot without racing
- # a separate /api/v1/printers fetch (#963 follow-up). PrinterInfo only
- # carries name/serial_number; the model comes through via the `model` arg.
- if printer_id:
- _printer_info = printer_manager.get_printer(printer_id)
- if _printer_info is not None:
- result["name"] = _printer_info.name
- if model:
- result["model"] = model
- return result
- # Global printer manager instance
- printer_manager = PrinterManager()
- async def init_printer_connections(db: AsyncSession):
- """Initialize connections to all active printers.
- Connections are started concurrently. ``connect_printer()`` is non-blocking
- apart from a fixed 1-second settle wait — ``BambuMQTTClient.connect()`` only
- calls ``connect_async()`` + ``loop_start()``, so the handshake happens on a
- background thread and the coroutine's only real cost is that ``sleep(1)``. A
- serial loop therefore spent one whole second per printer inside the FastAPI
- lifespan *before* the ASGI server begins serving: on a large farm that was
- ~100s of dead air before port 8000 responded (issue #2572, reporter's
- 93-printer farm). Gathering overlaps the settle waits so the whole step takes
- ~1s regardless of fleet size. Exceptions are isolated per printer with
- ``return_exceptions=True`` so one unreachable row can't abort the rest — or
- startup itself, which the old serial loop's un-caught await would have done.
- All columns ``connect_printer`` reads are eagerly loaded by the SELECT above
- and touched synchronously before its trailing ``await``, so no concurrent
- lazy-load is triggered on the shared session.
- """
- result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
- printers = result.scalars().all()
- outcomes = await asyncio.gather(
- *(printer_manager.connect_printer(printer) for printer in printers),
- return_exceptions=True,
- )
- for printer, outcome in zip(printers, outcomes, strict=True):
- if isinstance(outcome, Exception):
- logger.warning(
- "Failed to connect printer %s (%s) at startup: %s",
- printer.id,
- printer.name,
- outcome,
- )
|