Commit Graph
101 Commits
Author SHA1 Message Date
maziggy 33ab5f1ead Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.

Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.

The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.

Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.

Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.

The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
2026-08-04 12:38:36 +02:00
maziggy 48c231d8ce Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
and picking PLA showed "PLA @ 45°C" for about a minute and then switched
to "PETG @ 65°C" for the remaining twelve hours.

Bambu never echoes back which filament or temperature a cycle is
running, so the badge reads the target cached when the command went out,
and that cache had been dropped. Between accepting the command and
settling its countdown the firmware publishes one update with the
remaining time at zero while the unit is still in its Checking phase --
the reporter's log has 720, then 0, then 719, and four seconds later the
same unit's info hex decodes to dry_status 2, Drying. The falling-edge
detector read that zero as the cycle ending. Losing the cached target
left the badge to guess from the first loaded slot, which was PETG, and
its RFID-recommended 65°C. The same false ending fired
on_drying_complete, so anyone with smart-plug auto-off-after-drying
switched on had power scheduled to cut one minute into a twelve-hour
dry; the reporter had it off, which is the only reason this reads as a
cosmetic bug.

A remaining time of zero now ends a cycle only when the unit is not also
reporting an active phase. dry_status comes from the same info hex
already parsed a few lines above, so this costs nothing to check.
Stopping and Error are deliberately not treated as active -- those
should end it -- and a unit that reports no phase at all still ends its
cycles, so the gate can only ever suppress on positive evidence that the
cycle is live. A suppressed edge leaves the remembered dry_time alone,
exactly as the #1462 absent-value skip does, so the push that really
ends the cycle still sees a non-zero previous.

The fallback guess is tightened to match. On a mixed unit the first tray
is evidence of nothing, and naming a temperature the cycle is not using
is worse than naming none, so it now answers only when every loaded
spool is the same filament and otherwise leaves the badge showing the
countdown alone. Both the websocket and REST status builders carried
their own copy of that loop; they now share one helper, which also takes
the temperature from the first slot that carries an RFID one rather than
giving up when slot 1 holds a third-party spool.
2026-08-04 11:47:17 +02:00
maziggy db6cdb0745 fix(camera): take the finish photo when the print ends, not when its last layer starts (#2547)
The photo fired the moment layer_num reached total_layer_num. That edge is
where the printer *starts* its final layer, not where it finishes it: the
reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
three minutes and seventeen seconds and one filament change before the print
actually ended, so the frame caught the toolhead mid-print over the model.

The trigger also latched _finish_photo_captured, which locked out both the
stage-22 and FINISH triggers for the rest of the print — so on firmware that
never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
nothing could replace the bad frame.

Remove the last-layer trigger. The photo is now taken at the FINISH-state
trigger, which every model sends and which lands after the toolhead parks.

Since Bambu's end G-code drops the plate ~100mm just before that, restore the
framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
settle, capture, then drop it back so the print is as reachable as the printer
left it. Absolute is the safety argument — that Z is a height the toolhead
occupied seconds earlier, so it is inside the travel limits by construction and
leaves the nozzle above the part, and it is unambiguous across model families
because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.

The height is only trusted when two independent sources agree: the archive is
matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
layer count the printer reported over MQTT. Matching on "most recent archive
for this printer" was not safe — on_print_complete pops the _active_prints
binding concurrently, and a print Bambuddy failed to archive would have
resolved to its predecessor. A wrong height is the one failure that could drive
the nozzle into the model.

The move is additionally skipped when the print height is unknown, when a queue
item is pending for the printer, when the printer has left FINISH, and when the
new finish_photo_restore_plate setting is off.

for every FINISH-state capture — which is what shipped the mid-print photo —
the bank is used only when the dispatcher recorded that it injected End G-code
into this print, since a SwapMod snippet may have ejected the plate. The flag is
handed over in two steps (mark_pending at dispatch, adopt at print start) so it
can never outlive its print: a job started from the slicer or SD card adopts
False rather than inheriting its predecessor's answer. Those prints also skip
the plate move outright, bank or no bank.

The bank now refreshes on mc_percent advances as well as layer changes, via a
new on_print_progress callback. Layer changes stop the instant the final layer
begins, which left the #1867 fallback frame stale by the whole length of that
layer; progress keeps ticking there and freezes before the End G-code runs, so
a swapped plate still cannot reach the bank. The last-layer throttle exemption
is dropped, since it would now fire a grab on every percent tick.

On the timelapse path the moment producer returns early, so the consumer does
the restore itself before its live-grab fallback — the documented usual outcome
on P1-series, where the video has not transferred by the time the notification
goes out and the shipped photo was of an already-dropped plate. The two waits
are now derived from the settle window and the video poll timeout rather than
hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
mid-settle.

extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
It returns None for missing, unparseable, zero and negative values so callers
must treat "don't know" as such rather than defaulting.
2026-07-31 12:55:05 +02:00
MartinNYHC f2babc9027 Merge branch 'dev' into feature/p2s-x2d-accessory-fans 2026-07-29 12:36:45 +02:00
maziggy 91269f14fe fix(mqtt): report why a printer refused the connection instead of looping silently
A printer with a wrong access code gave no explanation anywhere. The connect
callback's failure branch was a bare `state.connected = False`, discarding the
CONNACK reason code the printer had just sent, so the only trace was paho's
follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
which is exactly what a powered-off printer produces. In the report behind this
fix one of three printers had been in that loop for the whole capture, and
neither the log nor the support bundle could say why.

Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
reason codes 134 and 135. Both are now logged with the printer's own reason
string and, for those two, the remedy: the access code is regenerated whenever
LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
The access code itself is never logged -- it would land in every bundle.

The reason is kept on the client as a stable slug and plumbed through
test_connection into the connection diagnostic, which now distinguishes two
cases it previously conflated. "The printer refused our credentials" is
asserted only when the printer said so; when all Bambuddy knows is that there
is no session, the text hedges and names the alternatives (rebooting, or
already at its limit of simultaneous connections). The old wording claimed the
access code was most likely wrong in both cases.

Frontend needed no change -- ConnectionDiagnostic already renders
`<status>_<reason>` variants with fallback to the plain per-status text, so an
unrecognised slug degrades to today's wording rather than a missing key.
2026-07-29 09:02:39 +02:00
Gabe 9ee162d51a feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)
The P2S/X2D have two fans bambuddy didn't fully handle. On the P2S both are
add-on kits; on the X2D they ship from the factory.

1. Left auxiliary part cooling fan — not shown or controllable. It is reported
   ONLY as device.airduct part id 10 (raw id 160 >> 4; FAN_REMOTE_COOLING_1 in
   Bambu Studio's DevFan::ParseV3_0) and is never mirrored into a flat
   big_fanX_speed field, which is why it was invisible. This is the gap
   identified in #2576, where the single 'Auxiliary' fan (big_fan1 / M106 P2)
   only reaches the right-hand aux fan.

2. Chamber exhaust fan — shown on every P2S labelled 'Chamber Fan'. On P2S/X2D
   Bambu's firmware/UI (and Bambu Studio's FAN_CHAMBER_0_IDX) call it 'Exhaust',
   and it is a kit on the P2S rather than built in.

Both are now detected from device.airduct.parts, which lists only the fans that
physically exist, so each tile appears only when the hardware is present.

- bambu_mqtt: parse airduct part 10 -> left_aux_fan_speed (None when absent) and
  part 3 presence -> exhaust_fan_present; set_fan_speed() accepts index 10 plus a
  set_left_aux_fan() helper
- schema / status route / printer_manager broadcast / mqtt_relay expose both fields
- POST /printers/{id}/fan-speed accepts fan=aux2 -> M106 P10, the command Bambu's
  official P2S machine profiles use
- frontend: 'Left Auxiliary Fan' tile shown when reported; big_fan2 tile labelled
  'Exhaust' and presence-gated on P2S/X2D, unchanged 'Chamber Fan' elsewhere
- i18n: leftAuxiliary + exhaust for all 12 locales

Verified fan -> field map on a live P2S (fw 01.02.00.00), stable across cooling
and heating airduct modes:
  Part cooling -> cooling_fan_speed / airduct id 1  (built in)
  Aux          -> big_fan1_speed    / airduct id 2  (built in)
  Exhaust      -> big_fan2_speed    / airduct id 3  (kit)
  Left aux     -> airduct id 10 only (kit; forced off in heating by mode config)

Tests: airduct id-10 parsing (raw 160 -> id 10, not literal 160), id-3 presence,
base-P2S absence, diff-push survival, clamping, malformed entries, M106 P10
emission, invalid-index rejection; fan-speed API aux2->10 mapping; frontend tile
presence and labelling per model/kit.
2026-07-28 11:16:22 -05:00
maziggy 8fd1f884dc feat(mqtt): publish the plate-clear gate and add a notification for it (#2525)
When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".

The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.

Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.

A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.

Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract

Translated in all locales; wiki updated. Covered by backend and
frontend tests.
2026-07-28 13:36:55 +02:00
maziggy 13183e365a fix(ams): clear empty AMS-HT slot via its real tray_exist_bits position (#2670)
After #2594 every empty-slot clearing path skipped AMS-HT units, so a removed
HT spool never cleared on the printer card while Bambu Studio correctly showed
Empty. Root cause: the HT presence bit is packed as a single consecutive bit in
tray_exist_bits at 16 + (ams_id - 128) (HT-A=16, HT-B=17, ...), not the regular
ams_id*4 position -- so the bitmask cleanup, which skipped id>=128, never
touched it. The HT's state field is firmware-variant (loaded reports 11 on H2D,
9 on the #2594 firmware) and it keeps echoing stale tray_type after removal, so
the bitmask is the only reliable, firmware-independent signal. Confirmed against
a live H2D capture (loaded=0x10f7f, empty=0xf7f) and the OrcaSlicer
DevFilaSystem.cpp reference (is_exists = tray_exist_bits >> (16 + (ams_id-128))).

- apply_tray_exist_bits: handle AMS-HT (128-135) at 16+(ams_id-128) instead of
  skipping it; clears an empty HT and, because a loaded HT keeps its bit set,
  never wrongly clears one (keeps the #2594 fix intact). Ids outside the known
  regular (0-15) and HT (128-135) ranges are left untouched rather than guessed.
- Build the AMS change-hash from the merged state, not the raw payload, so a
  removal signalled only by the bitmask (firmware still echoing tray_type) still
  flips the hash and fires on_ams_change to unbind the spool_assignment row.
- Emit the exists presence bit in the websocket status serializer (REST already
  did) so the card renders "Empty" instead of "?" where state is ambiguous.
2026-07-27 10:34:56 +02:00
maziggy 56accd24de fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)
An end-of-print auto-off on a plug that powers a filter fan marked the linked
printer offline and forced its state to "unknown". The mark was unrecoverable:
connected heals on the next MQTT message but state does not (only frames
carrying gcode_state rewrite it, and steady-state push_status frames are
partial), so the printer stayed "unknown" until a manual Force Refresh and the
queue never dispatched to it again.

The offline mark is now an explicit presumption: mark_power_off records the
state it overwrites and _on_message undoes it as soon as the printer sends
another report on its own topic, since inbound traffic proves the power was
never cut. A reconnect discards the saved state, so a genuine power cut is
unaffected. Each plug also gains a controls_printer_power flag (default true,
backfilled) that gates all five power-off paths, and the queue's power-on step
now picks the flagged plug instead of whichever linked plug came first.
2026-07-22 09:57:27 +02:00
maziggy fb11adc8fb fix(a2l): normalise AMS Lite unit 16->6 so slots load and deduct (#a2l-am-unit-16)
The A2L reports its 4-slot AMS Lite as unit id 16, but its slot-presence
bitmasks sit at bit base 24 (id 6) and it reports tray_now as a local 0-3
slot. Fed the raw id 16, the ams_id*4+slot convention probed bits 64-67
(always zero) and marked loaded slots empty; the local tray_now was read as
global, so usage deducted from the wrong spool (or not at all); and the
ams_id<=7 DB constraint rejected id-16 Spoolman links.

Normalise the Lite 16->6 at the MQTT ingest boundary so global tray ids land
at 24-27 - matching the firmware's own bit base, working with every existing
ams_id*4+slot consumer, colliding with nothing, and passing the DB
constraint. Globalise tray_now to 24+slot, widen the valid-tray guards, label
the unit "AMS Lite", and build the confirmed ams_mapping2 {ams_id:16,
slot_id:0-3} / flat 0-3 for dispatch. Outbound slot commands translate 6->16
on the wire via a single helper. Self-scoping: only unit id 16 is touched, so
all other printers/AMS types are unaffected. One uncaptured wire field (the
physical global tray on load/cali) is extrapolated and isolated to the helper.
2026-07-21 10:21:11 +02:00
maziggy 2e74f2ad41 feat(ams): confirm spool assignments landed instead of fire-and-forget (#2582)
Assigning a spool to an AMS tray pushed ams_filament_setting +
extrusion_cali_sel and reported success immediately, whether or not the
tray accepted it. A silently-dropped assignment never surfaced, and since
a print only deducts from the spool on the exact tray it pulls from, it
also recorded no filament usage - which made the whole thing feel random.

Read the AMS telemetry back after every assign (inventory assign_spool and
the Configure Slot modal) and toast the outcome: loaded when the tray
echoes the pushed tray_info_idx, a warning when the filament loaded but the
K-profile (cali_idx) did not, or not-confirmed after ~30s. Verification
uses the periodic per-tray push (the command ack hardcodes sequence_id 0
and can't be correlated); an on-demand pushall is nudged so it lands
quickly. Covers regular AMS, AMS-HT and external slots; stays silent rather
than inventing a failure if the printer goes quiet. The read-back check
runs on every AMS push because the change-hash excludes tray_info_idx.
2026-07-21 08:46:02 +02:00
maziggy 2e45893dd5 feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration
Bed levelling, flow calibration, and nozzle-offset calibration were on/off
only, so the sole way to run bed levelling was to force a full level before
every print. Bambu Studio has always offered a third "Auto" state that lets
the printer skip the calibration when it was done recently -- the state most
users actually want. Make these three options tri-state (off/on/auto),
defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off
(Bambu Studio exposes no auto for those).

Wire encoding follows Bambu Studio's source exactly: each option sends a JSON
bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The
bool fields stay booleans (the #1478 H2S regression); only the companion int
widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays
queued when sending 2 is the auto contract (queued, skipped at runtime if
recent), not a broken "off".

- schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing
  legacy bool / 0-1 / true-false so old clients and un-migrated rows validate
- model + migration: boolean columns -> String; SQLite via column affinity +
  data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema
  (verified on both dialects); settings rows normalised true/false -> on/off
- MQTT: start_print takes the tri-state strings and emits the paired bool+int
- Virtual Printer: reconstructs the slicer's auto/on/off from the int companion
  (auto_bed_leveling / extrude_cali_flag) in both capture paths
- frontend: CalibrationMode type; off/auto/on segmented controls in the print
  dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings
  in all 11 locales
2026-07-20 17:55:56 +02:00
maziggy e77e10896f feat(ams): name the expected slot when a paused print hits an AMS runout (#2587)
The firmware's runout HMS text says "insert into the same AMS slot", which is
wrong under AMS Filament Backup: the firmware won't re-accept the depleted slot
and advances to the next compatible one. Bambuddy parsed print.ams.tray_now only
and dropped tray_tar/tray_pre, so the expected slot never reached the UI.

Capture tray_tar/tray_pre on PrinterState and, while paused, resolve them to
global tray IDs (expected_tray/previous_tray) on both the REST and WebSocket
status payloads via a shared resolver: single-AMS passthrough, multi-AMS
snow-mapping resolution, AMS-HT/external passthrough, and an honest null when the
slot can't be placed. The AMS graphic highlights the expected slot (amber) and
the ran-out slot (red); the HMS modal re-describes runout codes to name both,
falling back to "check the printer" when unresolved. Runout copy translated in
all 11 locales.

Reporter @Jostxxl confirmed tray_pre=1/tray_tar=2 during the pause (ran out in
Slot 2, printer expected Slot 3).
2026-07-18 13:06:01 +02:00
maziggy b8cd1ab22d fix(cover): release the DB connection before the FTP thumbnail download (#2572)
GET /printers/{id}/cover took its printer row via Depends(get_db), whose
yield-dependency session stays open for the whole request — including the
3MF cover download (up to 8 remote paths x retries with backoff, minutes
under FTP contention). One pooled connection sat idle-in-transaction the
entire time; on a large farm a wall of dashboards drained the pool. The
route now fetches the printer in a short-lived async_session() and releases
the connection before the download (expire_on_commit=False keeps printer.*
readable). Pinned by a signature-inspection guard that fails if get_db is
ever re-added.

fix(print-start): release the DB connection across plate detection and 3MF download (#2572)

on_print_start held one session from top to bottom of the handler, across
two slow I/O blocks that need no database: the plate-detection camera grab
and, on the new-archive path, the multi-path 3MF FTP download (its own
comments cite worst cases of tens of minutes). The connection sat idle-in-
transaction for both, once per starting print. It now commits at each
boundary — only read SELECTs have run on those paths (every write branch
returns earlier), so the commit persists nothing and simply returns the
connection to the pool for the I/O; the next query re-acquires, and
expire_on_commit=False keeps printer.* readable.

fix(startup): connect to printers concurrently so the API serves within seconds (#2572)

init_printer_connections awaited each printer's connection serially, and
connect_printer ends in a fixed 1s settle wait. The MQTT connect is non-
blocking (connect_async + loop_start), so that 1s x fleet size was pure
serial dead air the FastAPI lifespan blocked on before uvicorn began
serving — ~100s before port 8000 responded on a 93-printer farm. The
connections are now started with asyncio.gather, so the step takes ~1s
regardless of fleet size. return_exceptions=True isolates each result: one
unreachable printer no longer aborts the rest, or startup itself.
2026-07-17 08:27:59 +02:00
maziggy ca3f6e5ee0 fix(drying): P1 AMS drying is screen-only — stop offering it (#2533)
The reporter found what his P1S was doing, and it is in Bambu's P1 manual:
"P1S connected AMS drying functions may only be controlled from the P1S screen."
The firmware acks ams_filament_drying with result: success and then discards it,
which is why three commands on an idle printer left the AMS 2 Pro at dry_status 0.
No command can start a cycle on a P1, on any firmware, so don't offer one.

supports_drying() now excludes the P1 series outright, replacing the 01.08+ gate
carried since #292 — that version is when P1 firmware gained AMS 2 Pro support,
not remote drying, and it was never checked against a live P1. Both drying routes
refuse with a specific 400 instead of publishing a message the printer will drop;
queue and ambient auto-drying skip P1s via the same helper.

A new drying_screen_only flag keeps the control on the card, disabled, saying why
— a P1 owner needs to learn where to dry, not watch the button disappear. A cycle
started at the printer still shows with its countdown; only Stop goes away, since
a P1 ignores stop exactly as it ignores start.

Also corrects the wiki firmware matrix, which listed P1P/P1S as supported and
(separately) P2S/H2S/H2C as unsupported. 8 tests.
2026-07-11 09:33:52 +02:00
maziggy d568307eac fix(smart-plug): don't cut power when a print restarts, honor per-plug cooldown setting (#1890)
The print-queue "auto off after this job" trigger used a second, inline
auto-off implementation (main.py, print_scheduler.py, print_queue.py)
that hardcoded wait_for_cooldown(50C, 600s) — ignoring each plug's
configured off_delay_mode / off_delay_minutes / off_temp_threshold — and
ignored the return value, powering off on the 600s timeout regardless of
print state. A print that failed and was reprinted from the touchscreen
got its power cut mid-print. The inline tasks were also uncancellable, so
a reprint couldn't abort a pending off.

Consolidate all three into SmartPlugManager.schedule_off_after_queue_job,
which schedules via the plug's configured strategy (shared with
on_print_complete through _schedule_off_per_mode) and is cancellable via
_pending_off. Add printer_manager.is_print_active() and guard the actual
power-off in _delayed_off and _temp_based_off so no path cuts power on a
loaded print. Move the on_print_start cancellation ahead of the auto_on
gate so a reprint always aborts a pending off.
2026-07-03 08:32:51 +02:00
maziggy 61a7f2e4ac feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)
New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.

Resolution order at dispatch:

1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
   'off' skips entirely; 'inherit' falls back to the global
   preheat_enabled toggle; 'on' forces the stage even when the
   global is off.

2. chamber_target = item.preheat_chamber_target_override
                 ?? max(filament_map[normalize(t.tray_type)] for loaded slots)
                 ?? 0.
   Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
   requirement is binding, PLA doesn't suffer being warm). PLA-only
   derives 0 and skips the chamber phase automatically.

3. Three hardware tiers for chamber heat:
   - Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
     chamber-sensor wait
   - Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
     wait with hard max-wait cap
   - No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only

4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
   match the chamber target — heating mode for engineering
   filaments, cooling mode for PLA. Bambu firmware does NOT
   auto-switch the flap with M141, so without this an ABS print
   on a previously-cooling flap fights the open exhaust, and a
   PLA print on a previously-hot flap recirculates ABS heat.
   Idempotent: only fires set_airduct_mode when current ≠ desired.

Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
master enable toggle (default off — disabled installs see no change),
per-filament chamber-target editor (replaces a single global int that
shipped in the first cut and couldn't serve PA + PLA in the same
config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
Options panel in PrintModal gets a Preheat sub-section with the
tri-state Inherit/On/Off control and an optional chamber-target
override input.

DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
Idempotent via _safe_execute. Existing rows behave exactly as before
the migration.

Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
2026-06-29 12:35:43 +02:00
maziggy d4ad41d850 fix(hms): action buttons actually reach the printer (#1830)
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).

(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.

(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.

(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.

(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
2026-06-27 09:18:57 +02:00
Zelda 3ddf8d847e [Feature]: HMS Actions (#1743) 2026-06-26 14:40:25 +02:00
maziggy fd61812d01 feat(drying): show active-cycle filament + target temperature on the AMS drying badge
Bambu's per-tick AMS push carries only the dry_time countdown — the
  filament name and target temperature the user chose are never echoed on
  the wire. The AMS card had no source of truth for them and rendered the
  bare "Drying · 11h 35m left". The badge now shows
  "Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
  actually started.

  BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
  (mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
  same per-AMS edge detector that drives the smart-plug-after-drying
  callback. PrinterManager.get_drying_targets exposes it, the four
  printer_state_to_dict call sites thread it through, AMS schema gains
  dry_target_temp + dry_filament, and routes/printers.py builds the same
  fields into the manually-constructed AMSUnit response.

  When no cached target exists (drying started in a previous backend
  lifetime, or initiated outside Bambuddy), the badge falls back to the
  first loaded tray's tray_type + RFID-recommended drying_temp — the
  heuristic the popover already uses to seed defaults.

  i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
  all 11 locales. Parity check 5356 leaves per locale.

  Note: a user reported the H2D's own physical display still labels the
  cycle by the loaded tray's filament (e.g. "PLA" instead of the
  Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
  journalctl shows filament: "PETG" sent and result: success ACKed — and
  the badge in Bambuddy's own UI now reflects what we actually sent,
  independent of the firmware's display choice.
2026-06-25 13:27:32 +02:00
maziggy 8d6f701f1d feat(drying): continue drying while printing + gate rotate-spool when tray loaded (issue #1816)
Continue Auto-Drying while a print is running on capable hardware.
  New Settings > Print Queue > "Continue drying while printing" toggle
  (default OFF). Extends _check_auto_drying in print_scheduler.py to
  evaluate running printers when supports_drying_while_printing(model,
  firmware) returns true. Strict allowlist verified per Bambu wiki
  release notes for "Print While Drying" / "printing while filament is
  drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
  01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
  intentionally excluded. Mid-print drying temperature is capped at
  max(40, preset_temp - 5) to protect spools from heat damage inside the
  hot enclosure during a print, matching Bambu's own "lower drying
  temperature during printing" guidance.

  Rotate-spool toggle in the drying popover is now disabled when any tray
  in the targeted AMS has filament threaded into the feed tube
  (tray.state === 11). The whole AMS rotates as one mechanism, so a
  single loaded slot locks the entire unit. Previously the toggle was
  always clickable and the firmware rejected with dry_sf_reason=[3]
  (ConsumableAtAmsOutlet) after the click. The first cut keyed on the
  printer-level tray_now but missed the H2D's typical post-print state
  where tray_now resets to 255 while filament stays in the tube — the
  per-tray state field reports it correctly. Submission also clamps
  rotateTray off so a stale-true state from a previous AMS can't leak
  through.

  Backend: supports_drying_while_printing in printer_manager.py covers
  display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
  O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
  settings schema. Frontend: toggle on SettingsPage, gate + clamp on
  PrintersPage drying popover using existing amsData cache. i18n: 3 new
  keys x 11 locales, no English fallback. Tests: 7 cases on the gate
  matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
  mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
  transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
  frontend npm run build clean, i18n parity 5355 leaves per locale.
2026-06-25 12:47:26 +02:00
maziggy 166e9f9ef2 fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
First-attempt fix (d196cfc5) was wrong about the cause. Real root,
  traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
  support bundle:

  mqtt_server.py:1296 was passing the slicer's bare subtask_name
  (e.g. "Model_Name") into on_print_command, which stashed under
  that key. _add_to_print_queue looked up under file_path.name
  (the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
  two strings never matched. pop returned None, the 2s wait fired
  against a key the stash side never signaled, every captured
  slicer field silently fell back to settings defaults.

  Affected EVERY Bambu Studio "Send" upload across EVERY model —
  not just H2C nozzle_mapping. bed_leveling / flow_cali /
  vibration_cali / layer_inspect / timelapse from the original
  #1403 capture have been silently ignored since BambuStudio
  started splitting subtask_name (bare) from file (with extension).

  Unit tests passed because fixtures called on_print_command with
  file_path.name directly, bypassing the broken caller.

  Fix in manager.py::on_print_command: derive
  stash_key = data.get("file") or filename and use it for both
  _slicer_print_options and the event lookup. filename
  (subtask_name) still flows unchanged to _schedule_finish_release
  — push_status echoes it back as gcode_file / subtask_name and
  the slicer matches against its own subtask_name there, so
  re-routing that path was a separate regression I caught and
  reverted mid-audit.

  Also: nozzles_info field was a wrong guess in d196cfc5 —
  BambuStudio never sends it (confirmed via wire capture). Drop
  the capture, dispatch, schema, kwarg, and route paths. DB
  column stays nullable so old rows still load; nothing reads
  or writes it.

  Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
  options after the 2s wait, including the looked-up key and the
  actual cache keys present. Future stash/lookup mismatches will
  be obvious from a log line instead of needing a wire capture.

  Behaviour change worth flagging: users on Bambu Studio whose
  slicer-side bed-leveling / flow-cali / vibration-cali /
  layer-inspect / timelapse differ from Bambuddy's
  default-workflow settings will see their slicer choices
  honored now instead of silently overridden. Restores #1403's
  original intent.
2026-06-21 14:09:05 +02:00
maziggy b1cb26f6ee feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
2026-06-20 12:07:20 +02:00
maziggy d196cfc500 fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
  nozzle-rack variant) carries nozzle_mapping (per-filament physical
  nozzle position IDs) and nozzles_info (per-extruder rack metadata).
  The VP intake was dropping both, so the H2C firmware fell back to
  "last matching nozzle type" auto-pick and ignored the user's
  slicer choice — every HF print landed on R2, every standard print
  landed on R4.

  Carry both fields through the VP intake → queue item → MQTT
  dispatch path. New nullable TEXT columns on print_queue, non-
  branched ALTER (matches ams_mapping / filament_overrides
  precedent). Dual-nozzle gate at start_print() keeps the fields
  off single-nozzle dispatches. Fail-open on malformed JSON —
  firmware auto-picks, never worse than pre-fix.

  Stamps both fields on every plate in the multi-plate Send All
  loop (#1697 / #1188 precedent).

  ams_mapping2 still handles H2D/X2D dual-extruder routing
  unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
2026-06-19 13:19:31 +02:00
Ed 0f99b54d7e feat: Update printer card UI for structure and readability (#1661) 2026-06-18 11:46:07 +02:00
maziggy be7e85344c fix(finish-photo): drive capture from stg_cur=22, drop dispatch force-on (#1721)
capture_finish_photo (default-on) was forcing the timelapse MQTT field to
  true on every print, even when the user explicitly unchecked Timelapse in
  the slicer send dialog. On profiles with Timelapse Type = Smooth, that
  flipped the printer's timelapse_record_flag and un-gated the per-layer
  M622 J1 wipe blocks the slicer had baked in — toolhead parked off the
  part every layer, on prints the user opted out of recording.

  Root cause: #1397 implemented the finish-photo feature as a side channel
  of "force the printer into timelapse-recording mode at dispatch" so the
  last-frame extractor had a video to pull from. That conflated recording a
  timelapse with snapping a finish photo, and the per-layer side effects
  were decided at slice time by the user's timelapse_type, which Bambuddy
  has no visibility into post-slice.

  Fix: replace the force-on with a clean MQTT-state-driven trigger.

    bambu_mqtt.py fires a new on_finish_photo_moment callback when
    stg_cur transitions INTO 22 ("Filament unloading") while
    _was_running AND end-of-print gate matches (progress >= 99 OR
    layer_num >= total_layers OR remaining_time <= 0). The gate
    disambiguates from mid-print color swaps (which also transit
    stage 22 but at progress < 99). FINISH-state fallback in the same
    handler fires the callback at the existing transition if stage 22
    never arrived (cancel, external-spool-only, HMS halt, firmware
    variants).

    main.py registers on_finish_photo_moment as a top-level handler.
    It pre-captures one camera frame at the trigger edge (external cam
    → buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and
    caches the JPEG bytes in _stage22_finish_frames[printer_id].
    _background_finish_photo consumes the cached bytes before its
    existing live-grab chain, so the saved photo has the better
    framing (toolhead parked, before bed drop) without restructuring
    the archive-resolution / fallback / notification wiring.

    When a timelapse IS actively recording (user explicitly opted in),
    pre-capture is skipped — _capture_finish_photo_from_timelapse
    still extracts the last frame, which is still the best framing
    and now has no force-on side effects because the user wanted the
    video.

  Removed: resolve_effective_timelapse, _resolve_effective_timelapse
  wrapper, both background_dispatch call sites, the print_scheduler call
  site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse
  (~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video,
  /record, /recording) and its call site. All paths now read
  bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly.
  The archive.bambuddy_forced_timelapse DB column stays defined (default
  False) for back-compat with existing rows — no consumer reads it anymore.
2026-06-13 13:28:58 +02:00
maziggy 2c2725cb53 fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
  giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
  BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
  calibration off.

  start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
  1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
  always send 2 even if a stale flag arrives. The kwarg threads through
  printer_manager, both background_dispatch sites, and print_scheduler so
  every dispatch path respects the per-item setting.

  print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
  branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
  defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
  library FilePrintRequest, archive ReprintRequest, settings.

  PrintModal renders the new toggle only when the selected printer is dual-
  nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
  SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
  hide unless any registered printer is dual-nozzle. Labels reuse the existing
  settings.default* keys so the only new i18n strings are
  settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
  - real translations in all 11 locales.
2026-06-08 09:20:19 +02:00
maziggy 51abc4b7a8 feat(drying): enable AMS drying for H2C at firmware 01.02.00.00+ (issue #1624)
H2C was in _DRYING_UNSUPPORTED_MODELS. Move to _DRYING_MIN_FIRMWARE
  with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes
  the H2C advertises (O1C single-nozzle, O1C2 dual-nozzle) get the
  same gate so supports_drying() fires correctly regardless of which
  form is stored on the printer record.
2026-06-04 10:57:46 +02:00
maziggy ed08ed3787 fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485)
When Bambuddy is restarted mid-print, the first MQTT push from the
  printer carries `_previous_gcode_state = None`. The #1304 guard
  deliberately suppresses on_print_start on that first push to prevent
  duplicate archive creation — but on_print_start is also where
  _capture_timelapse_baseline_at_start runs, so the in-memory
  _timelapse_baselines dict stays empty for the resumed session.

  At PRINT COMPLETE, _scan_for_timelapse_with_retries finds no baseline
  and falls into its "take baseline now" fallback. By that point the
  printer has already uploaded the in-flight MP4, so the snapshot
  includes the new file. Every "Found N files / no new files since
  baseline" retry then fails to detect a diff, and the archive ends up
  with no timelapse attached — pwostran's report (#1485 follow-up): card
  shows the finish snapshot but no video.

  Add a sibling callback on_print_running_observed that bambu_mqtt fires
  in the "Now tracking RUNNING state" branch when on_print_start was
  suppressed. main.py wires it to a thin handler that looks up the
  printer row and calls the existing _capture_timelapse_baseline_at_start.
  Idempotent — skips if a baseline already exists (handles the rare
  same-session race where on_print_start also fires for some reason).

  The printer doesn't upload the timelapse until after PRINT COMPLETE,
  so a baseline captured any time during the print is still pre-upload —
  no narrow window to hit.

  Verified against the in-the-field logs in #1485 (pwostran's 2026-05-23
  support bundle):
    pre-reboot: baseline = 7 files
    reboot
    post-reboot completion: fallback baseline = 8 files (includes new MP4)
    -> all 4 retry attempts report "no new files since baseline"

  10 new tests cover both the MQTT-side fire decision (fires when
  suppressed, doesn't fire when on_print_start handles it, once per
  session, payload shape mirrors on_print_start) and the main.py
  handler (snapshot capture, double-capture guard, missing-printer-row
  guard).
2026-05-23 14:52:04 +02:00
maziggy 0406487eb3 Fix: Add Printer no longer hangs the container on P1S (#1445)
The pre-insert MQTT probe added in 0.2.4.2 (b51598ea) had two bugs that
  compounded on P1S firmware specifically:

  1. Fixed 2-second sleep was too short. P1S broker + TLS handshake
  routinely needs 3-5s to surface CONNACK on a cold MQTT session (same
  firmware family with the documented "broker stops publishing but TCP
  stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected
  a printer that would have connected fine. H2C's broker is snappier and
  cleared the 2s window without trouble — which is why the reporter's
  H2C added without issue and only the P1S misbehaved.

  2. client.disconnect() ran synchronously on the asyncio thread.
  BambuMQTTClient.disconnect() ends in paho's loop_stop() which joins
  the network thread; if that thread was still mid-TLS-handshake to the
  slow P1S socket when teardown ran, the join blocked the asyncio thread
  for as long as the handshake took to complete or fail. POST /printers
  wedged, every other HTTP request queued behind it, Docker healthcheck
  timed out — user-visible symptom: "the container hangs."

  Fix:
  - Replace the fixed sleep with a polling loop (8s budget, 200ms tick,
    early-returns the moment state.connected flips True). Slow brokers
    get the headroom they need; happy-path connects still finish in
    ~1-2s. Constants exposed as PROBE_TIMEOUT_SECONDS / PROBE_POLL_
    INTERVAL_SECONDS class attributes so tests can dial them down.
  - Move client.disconnect() to await asyncio.to_thread(...) so paho's
    thread-join can never block the event loop.

  The empty-card-report-prevention goal of the original probe stays
  intact: a genuinely wrong access code still results in connected=False
  after the 8s budget, the 400 with code=printer_connection_failed
  still fires, the row is still never persisted.
2026-05-20 09:39:10 +02:00
maziggy 6f2cec5eb3 feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
2026-05-17 12:26:42 +02:00
maziggy 5680f5d34b fix(scheduler): watchdogs no longer falsely treat FINISH->IDLE as "print landed" (#1370)
Both the queue-side _watchdog_print_start and the direct-dispatch
  _verify_print_response used `status.state != pre_state` to decide
  whether a project_file command had been accepted. When a printer was
  in FINISH at dispatch time (un-dismissed post-print prompt from a
  prior job), the firmware silently rejected the new command; if the
  user then dismissed the screen prompt, the printer moved FINISH ->
  IDLE and the watchdog returned early as "command landed" — leaving
  the queue row stuck at status='printing' indefinitely and the
  scheduler permanently marking the printer as busy.

  Narrow the "command landed" check in both verifiers to an allow-list
  of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
  Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
  the revert. The subtask_id-advance signal stays in place for H2D's
  slow FINISH -> PREPARE transition (#1078).

  Also wrap _watchdog_print_start's revert commit and
  printer_manager._persist_awaiting_plate_clear in run_with_retry so
  SQLite single-writer contention can't silently drop these writes.
  The revert path returns a tristate sentinel so the post-revert MQTT
  session-recovery logic only runs when we actually reverted (or the
  commit failed) — not when on_print_complete had already cleared the
  row, where a forced reconnect could break a healthy concurrent print.
2026-05-16 09:05:05 +02:00
maziggy 7d3af9834c fix(inventory): emulate state=9 for bare-tray empty-slot signal on P1S/A1 (#1322)
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
  payload and found that P1S and A1 Mini send only {"id": N} for a
  physically empty slot — no state, no tray_type, no other fields. Without
  that signal, the assign-spool path was firing one wasted MQTT publish per
  click on a truly-empty slot (firmware dropped it silently, but still).

  The AMS parser in printer_manager.py now detects the bare-tray shape and
  promotes it to state=9 — the firmware's explicit "no spool" code — which
  lets the existing state in {9, 10} short-circuit in the inventory route
  apply automatically.

  The detection is intentionally narrow:

      len(tray) == 1 and "id" in tray and state is None

  so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
  and tray_type="") has more than one key and stays unaffected — the #1322
  root fix is preserved.
2026-05-15 12:27:01 +02:00
maziggy a2c9eef87c fix(safety): invert bed-jog Z direction on A1 / A1 Mini bed-slingers (#1334)
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
  control sent the nozzle straight into the build plate. Reporter
  triggered it with the 50 mm step and crashed their nozzle.

  Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
  where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
  so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
  gap). The frontend maps "Up" to negative distance with that convention
  in mind.

  A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
  firmware uses standard cartesian Z (Z+ = toolhead up). On those models
  G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
  classification at the bed-jog code path, so every printer got the same
  X1-convention G-code.

  Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
  existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
  already-defined A1_MODELS frozenset which covers display names and
  internal codes N1 / N2S). The bed-jog route now inverts the signed
  distance before emitting G-code when the printer model is in that set,
  so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
  regardless of which physical part moves. Frontend untouched, single
  source of truth lives in the backend, keyed off the Printer.model
  column. Route Query description and docstring updated to spell out the
  new contract: distance is the gap adjustment, not the raw Z value.
2026-05-14 08:58:33 +02:00
maziggy 889c8bd87f fix(printers): show correct plate thumbnail on multi-plate 3MFs (#1166)
P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
  filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
  The /cover route's regex falls back to plate 1 — and the printer card
  shows the wrong plate's thumbnail on multi-plate prints.

  Resolution order in the new resolve_plate_id() helper (used by both
  the status route's current_plate_id and /cover):

  1. The plate Bambuddy dispatched. start_print() now records
     (dispatched_plate_id, dispatched_subtask) on PrinterState; the
     subtask check rejects stale records from a previous Bambuddy
     dispatch bleeding into a Studio-direct print on the same project.
  2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
     for firmware that does include the path).
  3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
     covers per-plate archives sliced separately in Studio without a
     Bambuddy dispatch record.
  4. Default to plate 1.

  Cover-byte cache key simplified to (subtask_name, view_key) now that
  plate resolution is late-bound. clear_cover_cache() already fires on
  every print start, so re-dispatches with a different plate always
  fetch a fresh thumbnail.

  Bambuddy-dispatched prints additionally register the local archive
  3MF in the cover cache at dispatch time, so /cover reads straight
  from the archive directory and doesn't refetch the file over FTP
  from a printer whose FTP server is busy serving the active print.

  Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
  dispatch record on start_print, 2 integration tests for the cover
  route (dispatch wins over plate-1 default; 3MF-scan fallback for
  per-plate archive without dispatch record).
2026-04-29 16:51:38 +02:00
maziggy 096bdd92a8 fix(#1128): broadcast printer_status when awaiting_plate_clear flips
awaiting_plate_clear is a Bambuddy-side flag, not a printer-side one,
  so toggling it does not produce an MQTT push from the printer. Commit
  4e86e8c added the flag to the printer_status payload so MQTT-driven
  broadcasts (e.g. when a print finishes and on_print_complete sets the
  flag to True alongside a state transition to FINISH) carry it. The
  reverse transition didn't: POST /printers/{id}/clear-plate mutated
  PrinterManager._awaiting_plate_clear and persisted to the DB, but
  emitted no printer_status WebSocket update — and the in-main.py
  status-change broadcaster's status_key dedup intentionally excludes
  Bambuddy-side flags, so even a coincidentally-arriving MQTT push
  wouldn't reflect the change.

  The "Mark plate as cleared" button on the printer card disappeared
  "immediately" after a click only because the React Query cache was
  being optimistically updated client-side; clearing the flag through
  any other route (an admin script, a second tab, an automation hitting
  the endpoint directly, the scheduler at print_scheduler.py:1844 when
  dispatching the next queued print) silently left every UI subscriber
  but the originating tab stale until a coincidental status refresh.

  Centralised the broadcast in PrinterManager.set_awaiting_plate_clear
  itself rather than at each call site, so every current AND future
  caller is covered without remembering to wire it up: a new
  _broadcast_status_change(printer_id) private coroutine is scheduled
  alongside the existing _persist_awaiting_plate_clear whenever the flag
  flips under a running event loop. Lazy-imports ws_manager to keep
  printer_manager.py clean of application-layer infra at module-import
  time, short-circuits when get_status returns None (printer
  disconnected — the next reconnect produces a fresh push anyway), and
  swallows ws_manager.send_printer_status failures so the persistence
  path can complete even if the WS layer is temporarily unavailable.

  The same hook is now in place for any other Bambuddy-side flag that
  gets added to printer_state_to_dict later — they'll all need to
  broadcast their own changes for the same reason.

  8 new regression tests in test_printer_manager_status_broadcast.py:
  schedules-on-True/False/loop-running/no-loop/loop-stopped contracts,
  _broadcast_status_change happy path with payload assertion,
  skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop
  test that fires set_awaiting_plate_clear(False) and asserts a
  broadcast lands with awaiting_plate_clear: false in the payload.
  Existing 24 tests in test_scheduler_clear_plate.py continue to pass
  unchanged because they instantiate PrinterManager() without
  attaching a loop (sync unit-test path) — the new _schedule_async
  call short-circuits on the same loop check the existing persistence
  call already used.
2026-04-26 10:08:40 +02:00
maziggy c44b62195a refactor(gcode-viewer): archive-scoped previews, bed from capabilities, plate picker
Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
  archive-preview tool, matching Bambuddy's data model instead of the
  OctoPrint-style "connected-printer + library file picker" flow it shipped
  with. Reached only from the Archives page 3D-preview button; URL
  /gcode-viewer?archive=<id>[&plate=<N>].

  Backend:
  - /archives/{id}/gcode accepts ?plate=N and resolves the filename by
    parsing the suffix as int, so zero-padded names like plate_01.gcode
    are found when the plates endpoint reports index 1.
  - /archives/{id}/plates gains top-level has_gcode: bool. Source-only
    3MFs (PNG/JSON fallback path) surface the flag so the frontend can
    skip the picker instead of sending the user into a dead viewer.
  - printer_state_to_dict injects name + model into every WS snapshot so
    consumers render proper labels on the initial tick without racing a
    separate /printers fetch.
  - /gcode-viewer (no trailing slash) dropped from the backend so reloads
    fall through to the SPA catch-all and keep the layout shell; only
    /gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
    the iframe + static assets.

  Frontend:
  - PlatePickerModal shown only for multi-plate archives with sliced
    gcode, grid layout with thumbnails matching the Re-print modal.
  - Source-only archives show a noGcode toast instead of the empty
    viewer.
  - ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
    no trailing slash; GCodeViewerPage iframe forwards
    window.location.search so the archive reference survives both the
    initial navigate and a full-page reload.
  - Viewer iframe's auth path: fetch intercept injects Bearer; a 401
    redirects to / so the SPA handles login.

  Viewer adapter:
  - Stripped the printer selector, WebSocket subscription, library file
    picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
    Selector. The viewer no longer observes live printer state.
  - Bed size derived from /archives/{id}/capabilities.build_volume
    (extracted from the 3MF's printable_area/printable_height), so H2D,
    H-family, and any future printer render on the correct bed without
    a hardcoded map.
  - loadArchiveById accepts a plate param; fetch intercept rewrites
    __bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].

  Nav + locale cleanup:
  - Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
    now, not a destination page).
  - 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
  - platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
    added in all 8 locales.

  ArchivesPage: the now-unreachable ModelViewerModal render paths + its
  showViewer state removed. ModelViewerModal itself stays — File Manager
  still uses it for library file previews (plate picker + .3mf 3D model).

  pre-commit:
  - gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
    so vendored third-party JS libs don't drift away from upstream.

  Incidental sweeps picked up by pre-commit and kept (unrelated but
  benign):
  - NotificationsPage.tsx: single trailing-whitespace line removed.
  - spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
    the pn5180 driver module imported at line 27 does its own
    `import gpiod` and `gpiod.Chip()` calls, so the diag script's
    top-level import was never referenced.

  Tests:
  - 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
    behaviour (plate=N resolution, zero-padded filenames, missing-plate
    404, no-plate fallback, plate=0 rejection, has_gcode true/false).
  - 3 new cases in test_printer_manager.py for name/model WS injection.
  - PlatePickerModal.test.tsx — 6 frontend cases covering render,
    plate-name composition, onSelect payload, backdrop close, and
    thumbnail fallback.
2026-04-22 13:03:09 +02:00
maziggy 4e86e8cb16 fix(printers): Clear-Plate button delayed 30s–5min after print completes (#939 follow-up)
PR #939 added the awaiting_plate_clear gate but stored it on
  PrinterManager, not on PrinterState. printer_state_to_dict() — which
  builds every WebSocket printer_status payload — never emitted the flag,
  so the frontend's WS merge preserved the stale false value. The only
  path that surfaced true was the 30s HTTP fallback poll, and incoming WS
  ticks kept bumping React Query's dataUpdatedAt, pushing the refetch out
  further on chatty printers.

  Emit awaiting_plate_clear from printer_state_to_dict by reading
  printer_manager.is_awaiting_plate_clear(printer_id) directly; returns
  False when no id is passed. No frontend change needed — the existing WS
  merge carries the flag end-to-end and the button now appears the instant
  the printer transitions to FINISH.

  Regression tests assert the WS dict always contains the key and surfaces
  True when the manager has the flag set for that printer_id.

  Affects every printer (A1/H2D/X1C) equally — transport-agnostic path.
2026-04-21 18:10:02 +02:00
maziggy fa1c46d9a5 feat(printers): show plate name on card for multi-plate active prints (#881)
When two printers were running different plates of the same multi-plate
  3MF, the Printers page cards displayed the same file name on both and
  there was no way to tell them apart. The Queue view already had this
  information by cross-referencing the archive's plate list; the card
  didn't have the linkage.

  Expose `current_archive_id` (resolved by matching the MQTT `subtask_id`
  against `PrintArchive.subtask_id` — the bridge introduced in #972 for
  restart-resume) and `current_plate_id` (parsed from `gcode_file` by a
  new shared `parse_plate_id` helper) on the status endpoint. The helper
  is also called from the WebSocket push path so plate transitions
  reflect within 100 ms instead of waiting 30 s for the next REST poll;
  the archive id itself stays REST-only since it's stable for the life
  of a print and shouldn't make the push path touch the DB.

  The card fetches plate metadata via the same `api.getArchivePlates()`
  call QueuePage uses — shared React Query cache keeps it cheap across
  polls — and renders the actual plate name (or a "Plate N" fallback)
  only when `is_multi_plate` is true. Single-plate prints stay clean.
  Falls back to the previous `plate_N.gcode` regex path when there's no
  archive linkage (e.g. prints started directly from the printer LCD).

  Tests cover the plate-id extraction across Bambu Studio path shapes
  (backend parse_plate_id, printer_state_to_dict wiring) and the label
  override precedence in formatPrintName (frontend).
2026-04-21 09:46:42 +02:00
maziggy 6fb814c5ea feat(printer): add X2D support — camera, dual-nozzle, K-profile, maintenance (#988)
The Bambu Lab X2D (launched April 2026, dual-nozzle, enclosed, hardened
  steel rod gantry, AMS 2 Pro compatible) identifies itself as internal
  model code N6 via SSDP/MQTT, and real serials begin with 20P9. None of
  these identifiers existed in Bambuddy's registries, so the camera
  service fell back to the chamber-image protocol on port 6000 (X2D
  doesn't speak it), firmware-check logged "Unknown printer model: N6",
  and the dual-nozzle K-profile paths — gated on the H2D serial prefix
  "094" — would have treated X2D as single-nozzle.

  Backend:
  - Register N6 → X2D across every registry (PRINTER_MODEL_ID_MAP,
    PRINTER_MODEL_MAP, STEEL_ROD_MODELS, ETHERNET_MODELS,
    CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys + wiki path,
    virtual-printer SSDP/product/serial tables, DB vp_model_fixes).
  - supports_rtsp(): match the X2 display-name prefix and the N6 internal
    code; camera now routes to RTSP on port 322.
  - Dual-nozzle serial prefix check in bambu_mqtt.delete_kprofile and
    kprofiles.set_kprofile broadened to ("094", "20P9") — X2D now takes
    the H2D-style cali_idx in-place edit path.
  - is_h2d model gate in bambu_mqtt.start_print extended with "X2D" so
    timelapse / bed_leveling / flow_cali / vibration_cali / layer_inspect
    are sent as integers and external-spool ams_id 254/255 routing is
    preserved (H2D-style deputy-nozzle addressing).

  X2D uses hardened steel rods like P2S — it is intentionally placed in
  STEEL_ROD_MODELS, not CARBON_ROD_MODELS. A regression-guard test pins
  the classification.

  Frontend:
  - mapModelCode in PrintersPage and SpoolBuddyAmsPage handle N6 and X2D.
  - Enclosure-door badge and airduct-mode whitelists include X2D.
  - MaintenancePage.getMaintenanceWikiUrl routes X2D to P2S wiki URLs for
    steel-rod lubrication, belt tension, cold-pull, and PTFE tube
    (exported to enable direct unit testing).

  Tests:
  - test_printer_models.py: TestX2DModel (10 assertions).
  - test_bambu_mqtt.py: X2D in start_print ams_mapping and is_h2d gate;
    TestDeleteKProfileDualNozzleDetection across H2D, X2D, P2S, X1C.
  - MaintenancePageWikiUrls.test.tsx: 15 assertions covering X2D, P2S
    regression, X1C/H2D/A1Mini regression, and model-name normalisation.

  Docs:
  - README: added X2 series to the supported printers table.
  - CHANGELOG: new entry under 0.2.3b4 Fixed.

  Credit to @krautech for the report and debug bundle, and to @legend813
  for PR #989 which seeded most of the registry changes — rod-type
  classification was corrected (steel, not carbon) and the dual-nozzle /
  K-profile / is_h2d gaps were added on top.
2026-04-16 10:40:32 +02:00
maziggy c73c23b083 fix(printers): forward speed_level in websocket status payload (#993)
The MQTT parser already tracked spd_lvl and updated state.speed_level,
  but printer_state_to_dict omitted the field, so live WebSocket pushes
  never carried it. The frontend's merge-over-old-cache update kept the
  icon stuck on its previous value; only a full page reload (which hits
  the REST /status endpoint — that field was included) would refresh it.

  Changing the print speed from the printer's own panel now updates the
  Bambuddy icon live, without a reload.
2026-04-16 08:40:28 +02:00
maziggy 8af0966e68 feat(printers): airduct mode + status badges + force refresh on printer card
Surface four Home Assistant-style controls on the Printers page card:

  - SD Card badge in the top status row (green / red, icon-only).
  - Enclosure Door badge in the top status row (green / yellow, icon-only).
    Detection per printer family — X1/X1C/X1E read home_flag bit 23, all
    others read top-level `stat` (hex string) bit 23 — so X1 firmware that
    does not flip stat bit 23 stops false-triggering "open". WebSocket
    status-change dedup key now includes door_open so toggling the door
    alone publishes a push, no 30s REST-poll wait.
  - Airduct Mode badge beside the speed control (cooling / heating)
    for P2S/H2D/H2C/H2S; one-click dropdown calls the existing
    set_airduct MQTT command via a new POST /printers/{id}/airduct-mode
    route.
  - Force Refresh entry in the kebab menu — calls the existing
    /printers/{id}/refresh-status endpoint to request a pushall snapshot
    without forcing a reconnect.

  Tests: door-open parsing (X1 home_flag, non-X1 stat, ignore mismatched
  source, invalid hex) and airduct route (validation, not-connected,
  success, failure).
2026-04-13 12:50:17 +02:00
maziggy de7fff0be4 fix: persist plate-clear gate so Auto Off power cycles can't bypass the queue confirmation (#961)
With Auto Off enabled and another job queued, the smart plug cut power when a
  print finished and immediately re-powered the printer because the scheduler
  saw pending items. The printer booted fresh into IDLE and the next job
  auto-dispatched, bypassing the "Clear Plate & Start Next" confirmation.

  Root cause: the plate-clear gate lived only in PrinterManager._plate_cleared
  (in-memory set) and _is_printer_idle treated IDLE as unconditionally idle. On
  power cycle the in-memory flag was lost and the IDLE-on-boot state skipped
  the gate entirely.

  Fix:
  - Replace the in-memory flag with an awaiting_plate_clear column on the
    printers table, rehydrated into the PrinterManager at startup.
  - Set the flag in on_print_complete for completed/failed prints (not user
    cancellations); clear it on ack and on scheduler dispatch.
  - _is_printer_idle now short-circuits to not-idle whenever require_plate_clear
    is on and the flag is set, regardless of the currently reported state —
    so the gate holds through power cycles, Bambuddy restarts, and the printer
    booting back into IDLE.
  - /printers/{id}/clear-plate no longer requires the printer to report
    FINISH/FAILED; it accepts the ack whenever the flag is raised.
  - Frontend widgets (PrinterQueueWidget, Layout, BulkPrinterToolbar) gate on
    the flag rather than reported state.

  Tests: added regression tests for IDLE+awaiting=True (the #961 case) and
  full DB round-trip tests for the persistence layer.
2026-04-13 09:12:12 +02:00
maziggy 648f7d6ba8 P2S - enable AMS drying for firmware 01.02.00.00 and later 2026-04-09 15:42:34 +02:00
maziggy 3381f73e0f Enable AMS drying support for H2S with firmware 01.02.00.00+ (#886)
Move H2S from the drying-unsupported blocklist to the firmware-gated
  list, requiring minimum firmware 01.02.00.00 (released end of March
  2026). Both remote AMS drying and queue auto-drying now work on H2S.
2026-04-04 09:26:00 +02:00
maziggy d327c70006 Fix WebSocket crash on printers without fun field (#873)
Race condition in _update_state: dev mode probe released GIL via MQTT
  publish between raw_data overwrite and vt_tray list restoration, letting
  the event loop iterate over raw dict keys (strings) instead of spool
  dicts. Affects A1, P1, and X1Plus firmware that don't send the fun field.

  Fix: normalize vt_tray dict→list before raw_data assignment, restore
  preserved fields before any GIL-releasing work, add defensive guard in
  printer_state_to_dict.
2026-04-02 13:58:42 +02:00
maziggy 34ccc7b6e7 Fix bed cooled notification never firing on some firmware (#872)
Replace polling-based bed cooldown monitor with event-driven approach.
  Some firmware stops sending bed_temper after print completion, causing
  the 30-min polling loop to time out on stale cached values. Now reacts
  instantly to bed_temper MQTT data via a new on_bed_temp_update callback.
2026-04-02 10:37:48 +02:00
maziggy 86693b4f75 Fix archives capped at 50 items, add pagination (#843)
The archives page only fetched the 50 most recent prints due to a
  hardcoded API limit default. Added client-side pagination with
  configurable page sizes (25/50/100/200/All) and bumped the fetch
  limit to return all archives. Page size is persisted to localStorage.
2026-03-30 08:27:34 +02:00
maziggy 7d94bf7896 Fix P1S/P1P printer card showing "Printing" when idle (#813)
Some P1S/P1P firmware reports stg_cur=0 when idle, which maps to
  the "Printing" stage name and overrides the correct IDLE gcode_state
  on the printer card. Extend the existing A1/A1 Mini stg_cur idle bug
  workaround to also cover P1S and P1P models.
2026-03-25 17:55:36 +01:00
maziggy c9efa4b8bb Fix stale AMS slot data and empty slot button visibility (#784)
Some printers (e.g. H2D) only send {id, state} in incremental MQTT
  tray updates — no tray_type, tray_color, or other fields. When
  filament is unloaded (state changes from 11 to 10), the old tray
  data persisted indefinitely because the merge logic only updates
  fields present in the incoming message.

  Backend:
  - Detect tray state != 11 without tray_type in incremental updates
    and clear stale tray data (bambu_mqtt.py)
  - Expose tray `state` field via REST API and WebSocket broadcasts
    (printer.py schema, printers.py route, printer_manager.py)
  - Include AMS tray content in WebSocket dedup key so load/unload
    transitions trigger broadcasts (main.py)

  Frontend:
  - Add `state` to AMSTray interface (client.ts)
  - Show configure/assign buttons for state=10 (spool present, not
    loaded) but hide for state=9 (truly empty) on AMS/HT slots
  - Hide fill level bar on empty slots

  Tests:
  - 5 new tests covering state-based clearing, preservation, reload,
    and idempotency
2026-03-24 16:03:34 +01:00