mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
29311cab2bf0fcb58c5f175feeab1c7f96cd2d21
83
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
4af782cc2f |
Report a refused AMS filament setting instead of discarding it (#2756)
Configuring a slot publishes ams_filament_setting and the printer answers with a verdict. The answer was received and dropped at DEBUG, so a refusal left no trace at the level support bundles are collected at: the reporter saw six Configure Slot attempts on an X1C all return success, all read back by the #2582 verification as holding the previous profile, and no record of what the printer said about any of them. Promote a non-success response to INFO with result, reason, ams_id and tray_id. Refusals only -- unlike extrusion_cali_set (#2718) and ams_filament_drying (#1447) this command is not rare, since every spool assignment and K-profile re-apply sends one, so promoting each ack would bury the interesting line. The developer-mode probe is excluded: it sends this same command to the external slot expecting a refusal on P1 firmware, so promoting it would put an alarming line in every P1 bundle on every reconnect. Matched by sequence id, which user commands cannot collide with -- they publish a hardcoded "0". Diagnostics only; no change to which commands are sent or how they are built. |
||
|
|
18938a10ee |
fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published and returned True, and the printer's extrusion_cali_set answer was logged at DEBUG and dropped, so a write the printer refused was reported to the user as saved (#2718, reporter @jmoore-skild). The reason it could not simply be gated on: the answer itself was wrong. Single-nozzle firmware returned result:"fail" with reason:"invalid tray_id" on writes that demonstrably applied. Measured against an X1C and an H2D over MQTT, the cause is the tray_id:-1 Bambuddy itself put in the payload. Sending three otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0 succeeds, and cali_idx:-1 is accepted either way, so only that one field is at fault. The H2D ignores the value entirely; the X1C validates it, complains, and applies the write anyway. BambuStudio always sends a real tray_id and defaults it to 0 for a manually entered profile. With tray_id:0 the acknowledgement is honest, and the printer echoes back the sequence_id we sent -- confirmed for extrusion_cali_get, _set and _del on both printer classes -- so it can be matched to the write that caused it. Writes now return their sequence_id and the routes await the verdict, turning a real failure into an error that carries the printer's own reason. A printer that stays silent is still treated as success: no answer is not evidence of refusal, and firmware that never answers must not turn every save into an error. Raises the ack to INFO. It sat at DEBUG, so the one line that explains a failed save was absent from every support bundle -- the same reasoning that put ams_filament_drying at INFO for #1447. Also fixes extrusion_cali_set building its payload from str(self._sequence_id) without incrementing first, reusing the previous command's id. Harmless while nothing correlated on it, fatal now that the write path does. Adds supports_nozzle_flow_type() for the Standard / High Flow choice, which the K-Profiles UI previously showed as "Not reported by printer" -- not a value anyone can save. Most printers omit the nozzle identity from their calibration table entirely, and the slicer treats that as Standard rather than unknown; Bambuddy now does the same and keeps the choice editable. The field is hidden only where the model ships a single nozzle variant, using the slicer's own rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine preset) evaluated across every bundled Bambu profile. That puts only A1, A1 Mini and A2L on the hidden side -- it is not the single- versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S are all single-nozzle and all carry two variants. Editing a profile also no longer writes back an empty nozzle_id. Wiki records that on printers which omit the field the chosen flow type is discarded by the firmware and reads back as Standard, in Bambu Studio as well, so it does not get filed as a bug again. |
||
|
|
a35ba8fa5f |
fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)
Every K-profile came back as 0.4mm on printers running any other nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer puts nozzle_diameter on the extrusion_cali_get envelope only; the per-filament entries carry setting_id, filament_id, name, k_value, n_coef and cali_idx, and nothing else. The parser read the field per entry with a hardcoded "0.4" fallback, so the fallback fired on every profile of every response. The envelope value was already in scope, read into response_nozzle and used only to match the request. This never reproduced on H2D because that firmware does include the field per entry. Both construction sites are in the same handler, so the code path is shared; what differs is the payload, and every single-nozzle model omits it. The display was the least of it. Editing is delete-and-re-add on single-nozzle printers, and the dialog rebuilt nozzle_id and nozzle_diameter from its own greyed-out selects, so saving an untouched 0.6mm profile rewrote it on the printer as HH00-0.4. Deleting aimed extrusion_cali_del at the wrong nozzle the same way. Both now pass through what the printer reported. The cali_idx cascade in inventory.py, spoolman_inventory.py and spoolman.py matches on nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the assignment silently failed to stick -- that is the "cannot auto-map a K-profile" half of the report, fixed at the source without touching those three call sites. nozzle_id has no source in the payload at all, and state.nozzles carries material (hardened_steel), not flow, so it cannot honestly produce HH/HS. Rather than keep inventing one, the UI now says the printer did not report it: the card shows the diameter alone, the dialog shows "Not reported by printer", and the High Flow / Standard filter is hidden instead of being offered as a control that can only ever empty the list. Import stops stamping HH00 on profiles whose source reported none. Also correlates K-profile requests by sequence_id. Responses were matched by nozzle diameter through a single shared expectation slot, so a second request overwrote the first's and the first's valid answer was discarded as a mismatch -- the "Failed to get K-profiles after 3 attempts" in the same logs, with the printer having answered correctly both times. Pending state is now one entry per request, keyed by the id we already send, with the nozzle match kept as a fallback for firmware that does not echo it back. Fixes the flow-type select naming a new profile with the opposite label, which contradicted the identical expression 44 lines above it. |
||
|
|
5e2b7b53e6 |
fix(printers): surface the printer's own "command verification failed"
A P1S on firmware 01.10.00.00 rejected every control command and said so: HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy received that, dropped it, and reported a healthy printer instead. The frontend filtered it out. This code's meaning lives in attr's low half (0500) and code's high half (0001), both of which the MMMM_EEEE short form discards, so it collapsed to "0500_0007" — no catalog entry, no firmware actions, and filterKnownHMSErrors drops uncatalogued action-less errors. Catalog lookups now try full_code first, in both the description and the filter, and errors matched that way display the four-group code the printer's own screen shows. The remedy line is ours, not Bambu's: their wiki says to update Studio or Handy, which does not apply to a print sent from Bambuddy. The developer-mode probe made it worse. It read anything that was not an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so an inference drawn from a non-answer became "developer_mode: pass" in the support bundle of a printer that had not accepted a command all day. The probe now has three outcomes: explicit success enables, explicit verify-failure disables, anything else stays unknown and the diagnostic reports skip. The HMS is authoritative over that inference in both directions. It forces developer_mode False when present, and clears back to unknown when the printer stops reporting it, so enabling Developer Mode and restarting the printer is picked up without restarting Bambuddy. Dispatch no longer treats a refusal as a wedge. The watchdog latches the HMS across both phases and fails the item on the first attempt naming the code and the fix, rather than spending three uploads and 270s a lap to arrive at a message about SD cards. The check runs after the active-state exit in both phases, so a lingering HMS can never abort a print that is visibly running. Also: the "wrong or mis-cased serial number" hint no longer fires in the moment after a reconnect. _report_messages_since_connect is reset by _on_connect, so a reconnect landing microseconds before the staleness check leaves it at 0 for reasons that have nothing to do with the serial — this reporter's healthy printer was told to go check its serial 1 ms after reconnecting. |
||
|
|
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. |
||
|
|
c1a4c99059 |
feat(diagnostics): log end-of-print telemetry for finish-photo trigger research (#2547)
The finish photo needs a "printing done, toolhead parked, filament unload not started" moment. stg_cur=22 was meant to be it (#1721) and fires on no model in the field: across 247 support bundles there is not one FINISH PHOTO MOMENT (stage-22), including the 2026-06-13..07-08 window where it was the only pre-FINISH trigger in the code — 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D, all of them the FINISH fallback. A replacement can't be designed from the bundles we have. Out of that window Bambuddy parses only stg_cur and mc_print_sub_stage; every other stage/action field arrives and is dropped unread. The candidates that sound right (print_real_action, mc_action, mc_stage) are absent from A1/A1 Mini/P1S payloads, so none of them can be the universal answer alone. Dump the raw fields for the window between the last object layer and gcode_state=FINISH at DEBUG. Opens on the first end-of-print signal (last layer, progress >= 99, or no remaining time) so a dropped layer_num packet doesn't lose it, logs only what changed frame to frame, closes on the transition out of RUNNING, and arms once per print. Instrumentation only: gated on DEBUG being enabled, read-only against printer state, wrapped so it cannot break ingest, and capped at 400 frames per print. The probed fields are stage codes, counters and bitfields — nothing identifying, and no access code. |
||
|
|
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. |
||
|
|
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. |
||
|
|
1234f0830a |
fix(mqtt): don't let K-profile responses clobber the nozzle size (#2663)
Fetching K-profiles probes every nozzle size in turn (extrusion_cali_get for 0.2/0.4/0.6/0.8mm), and each response echoes the requested diameter at the top level. _process_message passed every "print" message -- including these responses -- to _update_state, which treats a top-level nozzle_diameter as the installed hardware. So the last size probed (0.8) overwrote the real nozzle in memory; a genuine status push corrected it and the next K-profile fetch broke it again, which is why it flickered between 0.8, empty and the correct 0.4. Since 1.2.5 the #1899 mismatch guard then refused to dispatch, failing prints with a bogus "printer has 0.8mm". Handle extrusion_cali_get responses only via the K-profile parser and skip _update_state for them, mirroring the existing get_accessories guard. The nozzle size now comes solely from the real status push. In-memory only -- affected printers self-correct on the next push after updating. |
||
|
|
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. |
||
|
|
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 |
||
|
|
059b733c41 |
fix(ams): don't wipe an AMS-HT spool on a partial state=9 update (#2594)
The AMS merge clears a tray on a partial {id, state} update when state != 11
(the 4-slot AMS "emptied slot" signal, #784). An AMS-HT (single-tray high-temp
dry box, id >= 128) reports its loaded tray as state=9, so the partial the
printer sends on power-on was misread as "emptied" and wiped the HT-A spool's
tray_type/RFID/assignment seconds after power-on.
Skip the state-heuristic for HT units (id >= 128). Genuine HT removal still
clears via the explicit tray_type="" update and tray_exist_bits cleanup;
regular AMS (id < 128) is unchanged.
|
||
|
|
47a2a77cd3 |
fix(filament): don't dispatch an unresolved AMS mapping to the external spool (#2589)
A P1S queue row with use_ams=true but ams_mapping=[-1] was silently printed with no AMS, starting against the empty external feed and pausing with a runout. Two faults combined: - start_print treated -1 (unresolved) the same as >=254 (explicit external) when deciding to force use_ams=False. Only genuine external now downgrades; -1 never does. - The scheduler trusted a stored [-1] as "already resolved" and passed it through. It now recomputes from live AMS trays whenever the stored mapping is entirely unresolved, and clears it if nothing matches rather than sending a doomed command. Frontend: the Print dialog no longer serializes an all-[-1] mapping while the printer status is still loading (the hook returns no mapping), and submit waits for AMS status with a "Waiting for AMS status" notice. Tests: new backend + frontend regression coverage; corrected one existing test that pinned the old [-1] -> use_ams=False behavior. |
||
|
|
f6c6cfbad3 |
fix(ams): show "?" not "Empty" for non-RFID spools using tray_exist_bits (#2527)
A spool with no readable RFID was reported by the standard AMS with an empty tray_type and state=9 — structurally identical to a truly-empty slot at the tray level — so the AMS card rendered it "Empty" while Bambu Studio correctly showed "?". The authoritative "a spool is physically here" signal is firmware's AMS-level tray_exist_bits bitmask (what Studio uses), but Bambuddy inferred emptiness from the per-tray state/tray_type. Confirmed from the reporter's bundle: tray_exist_bits=f (all four slots present) with tray_is_bbl_bits=5 (only slots 0,2 Bambu) — the present-but-non-Bambu slots were the ones shown Empty. Supersedes closed #1838. apply_tray_exist_bits() already parses the bitmask to clear stale fields on absent slots; it now also annotates each slot with an authoritative `exists` bool, gated behind a new annotate_exists flag so only the printer-card path sets it. The VP bridge leaves it off, so the `exists` key never reaches the slicer wire format. `exists` flows through the AMSTray schema/serialization to the frontend, where getEmptySlotKind() uses it: exists===true + no tray_type -> "?" (present, unconfigured), exists===false -> "Empty", exists absent -> the previous state=9/10 heuristic (AMS-HT and missing-bitmask paths unchanged). H2D/X1C already reported present-unknown slots with a non-9 state and took the "?" path; with the fix they reach it via `exists` and are unaffected. |
||
|
|
2d13e77f1b |
fix(mqtt): capture finish photo on last-layer edge, not FINISH state (#1867)
A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already executed the user's End G-code. Users with SwapMod plate-swap injected into End G-code always got a photo of the swapped (empty) plate. Add a layer_num >= total_layer_num edge trigger in _parse_print_data so the pre-capture fires the moment the last object layer completes, on every printer variant. Guarded by the existing _finish_photo_captured one-shot so stage-22 and FINISH-state hooks become no-ops for the same print — no framing regression on AMS printers without custom end G-code. |
||
|
|
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.
|
||
|
|
4c67d8a4e1 | feat: Unify print dispatch through the scheduler (#1625) | ||
|
|
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.
|
||
|
|
4b0150b0ec |
fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout external-spool prints instead of 254 like X1C/P1S/A1 do, so the single-nozzle branch's 0-3 passthrough landed state.tray_now on slot 0 — UI highlighted AMS SLOT 1 instead of the external spool. Usage credit was unaffected (#1276 covers that via ams_mapping). The single-nozzle branch now checks _captured_ams_mapping (slicer- captured per-filament mapping that the request-topic intercept already tracks) before the existing P2S multi-AMS resolver. When every entry is -1, the print uses ONLY the external spool, so state.tray_now is promoted to 254. Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT overridden — we have no evidence H2S misreports mid-print swaps, and trusting the firmware preserves correctness for users with multi- filament setups. No-mapping prints (printer-screen start) fall through unchanged. |
||
|
|
166e9f9ef2 |
fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
First-attempt fix ( |
||
|
|
a53dc20ca3 |
fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
8a63fcbf57 |
fix(vp): apply tray_exist_bits empty-slot cleanup to slicer-facing cache (#1726)
VP bridges bound to a target printer (Proxy mode, Queue mode with specific target) forwarded the printer's raw AMS push_status to the slicer untouched. bambu_mqtt.py::_handle_ams_data applies a tray_exist_bits-driven cleanup to Bambuddy's internal state (promote empty slots to state=9, wipe stale tray_type / tray_color / tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders empty slots as Empty, but the VP bridge cache never ran the same cleanup. Net result on real hardware: a printer with 3 loaded filaments and several previously-loaded-now-empty slots had Bambuddy's AMS card render those slots correctly as Empty, but BambuStudio after Sync painted them as phantom loaded filaments with stale color and material from before the slot went empty. Root cause: two consumers of the same payload, only one wired to the cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py:: _on_printer_raw merged the ams blob via _merge_ams_dict but copied tray_exist_bits through as an opaque scalar without acting on it. Fix: factored the bit-clear logic out of _handle_ams_data into a module-level helper apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label). Internal path replaced with a single call. Bridge calls it after _merge_ams_dict on the merged ams dict, before the merged state is stored as the 1 Hz cached-as-base source. Shared shutdown guard kept on both sides: all-zero bits + power_on_flag=False is the printer-off pattern (#765, would propagate phantom empties on every reconnect); nonzero bits + power-off is valid idle-printer state (#1365, X1C between prints) and still applies. AMS-HT units (id >= 128) skipped on both sides. Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper contract directly. 3 new bridge regression tests reproduce the #1726 wire shape, the shutdown guard, and the AMS-HT skip on the cached slicer-facing state. Existing internal-state tests for the bit-clear logic (covers state=9 promotion, loaded-slot preserve, genuine-removal-with-power-on) continue to pass against the refactored path. One pre-existing bridge fixture had an inconsistent tray_exist_bits ('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match real-printer wire shape. Reported by @needo37 with full code-level analysis including the suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to verify on a live system. |
||
|
|
0de71ca412 |
fix(printer): "off" flow_cali / nozzle_offset_cali now actually suppress the stage
The Re-print and Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted "off" correctly and flowed it through to the project_file MQTT publish — Bambuddy sent extrude_cali_flag: 2 and nozzle_offset_cali: 2 per the "1 = run, 2 = skip" reading inherited from the #1478 / #1682 work. Live test on H2D 01.x: with both toggles off, the stg queue still scheduled stage 8 ("Calibrating dynamic flow") and stage 39 ("Nozzle offset calibration"), and the printer ran both at print start. Root cause: 2 means "skip the explicit pass but still apply / verify stored PA via the calibration stage" — close to a no-op K-factor wise but the per-print physical sequence still runs. 0 is the encoding that actually drops the stage from stg. A BambuStudio Send-dialog capture on the same firmware showed 0 for both fields when the user unchecked the calibrations — contradicting the #1478 commit's read of "BambuStudio never sends 0." Fix: - extrude_cali_flag = 1 if flow_cali else 0 (was: else 2) - nozzle_offset_cali = 1 if (nozzle_offset_cali and is_dual_nozzle) else 0 (was: else 2) Dual-nozzle gate stays; single-nozzle prints continue to force-skip the nozzle-offset cali their head doesn't support (#1682). The 1 (run) branch is unchanged. Verified live on the same H2D after the patch: stg dropped to [29, 13, 4, 14, 3] (cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 gone. Vibration compensation is NOT fixed by this commit: vibration_cali is a bool in our and BambuStudio's wire format, and the H2D firmware queues stage 3 regardless of the false value. Firmware-side, not solvable at the dispatch layer with the current field. Filed as a follow-up. |
||
|
|
e01d3a7979 |
feat(mqtt): one-shot device identification probe for unknown models
Logs device.dev_model_name / dev_product_name / dev_id / project_name at INFO level once per client session, falling back to device.keys() if none of the known fields are present. The MQTT push_status carries the model code in device.dev_model_name on every message, but nothing in bambu_mqtt.py reads or logs that field — so adding a new printer model meant chasing the code through either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the case that surfaced this: get_version also failed because the firmware disconnected right after request topic subscription, so the support bundle had no way to disclose the model. INFO level so the line lands in support bundles without enabling debug. One-shot via _device_id_logged, mirroring the existing _nozzle_fields_logged flag at line 2095, so push_status spam is avoided. Future-proofs against Bambu renaming the field (the fallback dumps device.keys() so a rename like model_name without the dev_ prefix is still observable). 3 unit tests in TestDeviceIdentificationProbe pin all three branches. |
||
|
|
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. |
||
|
|
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). |
||
|
|
745ed847e6 |
fix(archive): stop duplicating the job on a backend restart mid-print (#1485)
A restart during an active print duplicated the running job in the
archive, and every further restart spawned another. on_print_start
re-attaches by subtask_id, falling back to a name match plus a 4-hour
staleness cutoff that cancelled + recreated any name-matched 'printing'
archive older than 4h - destroying the live archive of every long print.
Two fixes:
- start_print records the minted subtask_id (last_dispatch_subtask_id);
on_print_start falls back to it when the printer hasn't echoed one
yet, so queue/scheduled archives persist a restart-stable id.
- Replace the 4h cutoff with a progress-aware check: a name-matched
'printing' archive resumes whenever the printer reports real (or
unknown) progress; it is stale only when the printer shows a
freshly-started print (<1%) on an archive over 2h old.
|
||
|
|
379b1c14bc |
fix(printer): Flow Calibration was silently skipped — wrong project_file fields (#1478)
The H2S never ran flow-dynamics calibration even with the print option
enabled, because start_print built the project_file command wrong:
- extrude_cali_flag was hardcoded to 0. A BambuStudio request-topic
capture from a real H2D (plus X1C/P2S captures) shows it is always 1
(run calibration) or 2 (skip, reuse stored PA), paired with flow_cali,
never 0 — so the printer skipped calibration regardless of the toggle.
- flow_cali and the other calibration/leveling fields were integer-
encoded for the H2 family on a mistaken belief that H2 firmware
requires 0/1. The same capture sends plain JSON booleans for every
model; the belief conflated these fields with use_ams (which does
need to stay boolean — the actual #1386 cause).
Fix: extrude_cali_flag = 1 if flow_cali else 2; send timelapse,
bed_leveling, flow_cali, vibration_cali and layer_inspect as booleans
for all models; drop the is_h_family integer-conversion branch.
use_ams is unchanged.
Corrected the two tests that asserted the integer format and renamed
them; all three model tests now also assert extrude_cali_flag.
|
||
|
|
01787eb1e6 |
fix(printers): normalize serial numbers + diagnose connect-but-no-reports (#1465)
Reporter's H2C connected over MQTT+TLS but every status field stayed
unknown. Root cause was layer-8: the MQTT broker is the printer, it
authenticates on the access code and SUBACKs any topic string, so a
wrong or mis-cased serial connects fine and silently receives nothing
(the report topic device/<serial>/report is case-sensitive; Bambu
serials are uppercase). Bambuddy stored and used the serial verbatim.
- schemas/printer.py: field_validator strip()+upper()s serial_number on
create, rejects blank-after-strip. The subscribed topic now always
matches the printer's correctly-cased one.
- bambu_mqtt.py: count report-topic messages per connection; when a
stale reconnect fires with zero reports received, log a one-shot
hint pointing at the serial number instead of looping silently.
|
||
|
|
76582298b5 |
fix(drying): stop false "drying complete" from killing the printer via smart-plug auto-off (#1462)
Reporter on X2D set a 1h AMS dry; the printer powered off seconds in.
Support log: every "Sent drying command duration=1" was followed 3-9s
later by "AMS 0 drying complete (dry_time 60 -> 0)" -- the completion
callback fired right after drying started, arming smart-plug auto-off.
Root cause: the tray-bearing branch of the AMS partial-update merge
rebuilt the unit as {**ams_unit, "tray": merged_trays}, never spreading
existing_unit. Tray-bearing partials carry no drying fields, so dry_time
(and info) was dropped; the falling-edge detector read the absent field
as 0 and saw a false 60->0 edge.
- Merge: tray branch now spreads existing_unit first, preserving
dry_time / info / humidity / temp across tray-only partials. Matches
the no-tray branch. Also fixes dry_status/dry_sub_status UI flapping.
- Detector: only evaluate the falling edge when dry_time is explicitly
present and parseable; skip otherwise without updating state.
|
||
|
|
e2df0fc601 |
fix(ams): physically-empty slots report state=9 and render distinctly from reset slots (#1322 follow-up)
Two-part fix for the #1322 follow-up by @RosdasHH. Data layer. The previous narrow heuristic in printer_manager.py only caught the bare {"id": N} payload firmware sends right after a printer restart. In steady-state operation — and on the more common post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a populated payload and signals emptiness via the tray_exist_bits bitmask. We already parse that bitmask and use it to wipe stale tray_type / tray_color / tag_uid fields, but never touched the state field, so downstream readers (printers.py API serializer, inventory.py's tray_state in {9, 10} short-circuit, AMS card) saw state: null and had to guess from absent payload fields. Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358 uses == not `in {...}` so a string would silently miss and the reporter's deadlock would come back) to the outer `if not slot_exists` branch, so the bitmask path now writes the canonical "no spool" code for every empty slot regardless of stale fields. The narrow heuristic in printer_manager.py:797 stays as belt-and-suspenders for any MQTT path that doesn't flow through _handle_ams_data. UI layer. With the data flow now consistent, the AMS slot card renders physically-empty slots distinctly from reset slots, per reporter's mockup. New helper getEmptySlotKind(tray) returns "physical" (state ∈ {9, 10}), "reset" (any other empty state), or null (loaded). The inline label below the slot circle reads "Empty" for physical and "Reset" for reset; pre-fix both showed an em-dash. FilamentSlotCircle gains an emptyKind prop that picks a quieter dashed border colour for reset slots so the visual hierarchy reads loaded > reset > physically empty. EmptySlotHoverCard gains a kind prop and switches between "Empty slot" and "Slot reset — no spool assigned". |
||
|
|
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. |
||
|
|
96fd4bb7e3 |
fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
|
||
|
|
7aa5ff0156 |
fix(ams): detect spool removal on X1C firmware that reports power_on_flag=False (#1365)
The #765 guard against shutdown-time data wipes skipped any AMS update with power_on_flag=False, but some X1C firmware emits power_on_flag=False while idle with tray_exist_bits still reflecting the real slot inventory. Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so the bitfield path is the only signal — muting it left spool removals undetected until a manual reconnect. Narrow the skip to the exact shutdown pattern: zero bits AND power_on_flag=False. Non-zero bits with power_on_flag=False are now applied. The #765 shutdown protection is preserved (its regression test uses tray_exist_bits='0' and still passes); newer firmwares are unaffected because their per-tray state path catches the removal first. |
||
|
|
82c90c6387 |
fix(mqtt): skip print-start fire on first RUNNING after Bambuddy startup (#1304)
Restarting Bambuddy mid-print misfired the plate-check + archive flow. The is_new_print guard treated _previous_gcode_state=None → RUNNING as a transition, but None just means we haven't seen any prior state yet — catch-up from a printer that was already running, not a fresh start. Add `_previous_gcode_state is not None` to the guard. _was_running still flips on unconditionally, so completion detection is unchanged. 3 tests that asserted the buggy behavior now seed an explicit prior state; new regression test pins the contract for the reporter's exact scenario. |
||
|
|
7596725550 |
fix(mqtt): external-spool ams_filament_setting must use global tray_id (#1279)
ams_set_filament_setting and reset_ams_slot encoded the single-external
case as {ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCAL tray_id = 0"
comment was a misread of the printer's response (which echoes the local
slot position), not the request semantics.
Captured BambuStudio -> X1C exchange shows the request encoding is
{ams_id: 255, tray_id: 254, slot_id: 0} (global tray index in tray_id).
The previous code's tray_id: 0 is what the P1S in #1279 rejects with
result: "fail", which silently broke external-spool filament selection
on every Bambu printer with no AMS or external spool in active use.
Dual-external (H2D) branch was not in the captured exchange and is
explicitly pinned at the legacy encoding pending a Studio -> H2D capture.
|
||
|
|
c6e6c4cdd9 |
fix(usage-tracker): split filament weight when AMS auto-falls-back mid-print (issue 957)
When one spool ran out and the AMS transparently switched to a sibling
slot of the same material, the usage tracker credited the originally-
mapped spool with the full 3MF estimate AND added the fallback spool's
remain%-delta on top — so a 78g print could record as 138g across two
spools, leaving the empty spool's recorded weight beyond its label.
Two interacting bugs:
1. bambu_mqtt.py: the tray-change recorder gated on
`state in ("RUNNING", "PAUSE")`, but P2S firmware briefly transitions
out of RUNNING during the AMS swap (into LOADING etc.), so the
literal-string gate missed the switch entirely and tray_change_log
stayed empty. Re-key on the print-lifecycle flags
(_was_running and not _completion_triggered) so any tray change
between print start and completion is captured regardless of the
momentary gcode_state.
2. usage_tracker.py: the splitting branch was gated on
`not slot_to_tray`, so the splitting code only ran for prints where
the slicer mapping hadn't been captured — i.e. never on the actual
fallback case (slot_to_tray is populated by every print_cmd). Drop
the gate: when tray_change_log has > 1 entries, splitting takes
over and per-segment per-layer gcode usage replaces the stale
mapping. Path 2 (AMS remain%-delta) then naturally skips both trays
because they're already in handled_trays after splitting,
eliminating the double-credit.
|
||
|
|
3320c7fd45 |
feat(printers): AMS slot Load / Unload from the printer card (#891)
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
|
||
|
|
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).
|
||
|
|
b45ca2a662 |
feat(printer): support Filament Track Switch (FTS) accessory in print modal (#1162)
The FTS routes any AMS slot to either extruder, so AMS info reports
bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
The print modal's per-nozzle dropdown filter then hides every loaded
slot, leaving the user with an empty filament dropdown.
Detection: parse print.device.fila_switch from MQTT push_status into a
new FilaSwitchState dataclass on PrinterState; surface it through the
GET /printers/{id}/status response as a nullable FilaSwitchResponse.
Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
filter when fila_switch.installed is true. Slots currently fed into a
track display an [L]/[R] routing badge in the dropdown so the user
can see where the FTS is currently routing them.
Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
integration (status route), 2 hook regression, 2 component regression.
|
||
|
|
dac6cbfe4e |
fix(mqtt): reset unanswered counter on any ams_filament_setting response (#1164)
Configuring AMS slots ~6 times in a row would silently stop reaching the printer, with filament colours jumping around briefly ~1 min later. Root cause was the zombie-session watchdog from #887. When an ams_filament_setting response took >10 s (normal under load) the watchdog set `_ams_cmd_unanswered=1` and zeroed `_last_ams_cmd_time` so it wouldn't re-fire on every status push. The response handler that resets the counter required `_last_ams_cmd_time > 0` — so when the late response arrived, the reset path skipped it, leaving the counter armed at 1. The next slow response on a fresh command (possibly minutes or hours later) would take the counter to 2 and force-reconnect mid-publish — the in-flight command got dropped, surfacing as "Cannot set AMS filament setting: not connected" if the user retried during the ~1 min reconnect window. Fix: drop the `_last_ams_cmd_time > 0` guard. Any ams_filament_setting response proves the channel is alive, so the counter must reset unconditionally. Real zombie sessions (no responses at all for two consecutive >10 s windows) still trip the watchdog correctly. Regression test in test_bambu_mqtt.py drives the exact reporter sequence: watchdog fires (clears timer, increments counter) → late response arrives (must reset counter) → next slow response (must only count as 1, not 2). Other 10 zombie-detection tests still pass. |
||
|
|
527f8ea471 |
fix(mqtt): #1136 reprint fails with 0500_4003 SD R/W after stuck dispatch
Reprinting from archives sometimes failed immediately with a MicroSD R/W
exception, with the printer's MQTT push referencing a 3MF from a
different unrelated archive. Once it started, every subsequent reprint
hit the same error until the container was restarted.
Root cause from @smandon's support package: paho-mqtt's client-side QoS
1 queue. When the printer's command channel goes half-broken (telemetry
flowing, publishes silently dropped — same #887/#936 pattern),
background_dispatch.py:993 hits its 15s deadline and calls
force_reconnect_stale_session(). That function was force-closing the
underlying socket so paho's auto-reconnect would kick in, but the same
mqtt.Client instance, same client_id, and same in-process QoS 1 queue
stayed alive across the reconnect. Any unacked publish from the broken
session — typically the just-sent project_file for the new archive —
got replayed verbatim on the new connection. The queue accumulates
across multiple stuck dispatches in one Python process, so by the
second or third stuck reprint there were several stale
project_file/resume/stop/clean_print_error commands queued together;
the printer latched onto whichever stale path it processed last,
couldn't find the file on its SD card, and emitted 0500_4003. Container
restart was the only thing that wiped paho's in-process queue.
Replaced socket-close with a context-aware reconnect via a new
_reset_client_for_reconnect() router:
Async-context callers (dispatch deadline, FastAPI handlers via
check_staleness) → hard-reset: client.disconnect() (broker drops
session, clean_session=True), client.loop_stop() (kills paho's
network thread and its queue), null _client, fresh connect() with
incremented client_id. New connection is genuinely empty, no replay.
Paho-network-thread callers (dev-mode probe + ams_filament_setting
zombie detection inside _update_state) → socket-close fallback.
loop_stop() from inside the network thread would self-join and
deadlock, so the safe pattern there is "close the socket and let
paho's loop detect it and auto-reconnect on the same client".
Routing decision uses asyncio.get_running_loop() — paho's callback
thread has no loop, every legitimate hard-reset caller does.
7 regression tests:
- TestForceReconnectRouting (3): sync-context → socket-close fallback,
async-context → hard-reset with disconnect()+loop_stop()+null,
state-disconnected broadcast fires once on either path
- TestHardResetClientDirect (3): helper directly — old client gets
disconnect()+loop_stop(), _client cleared, failing disconnect()
doesn't propagate so background_dispatch's await chain can't break
- TestZombieSessionDetection / TestDeveloperModeProbeTimeout (updated):
paho-thread context still goes through socket-close, preserving the
legacy contract for those paths
|
||
|
|
88b5f56eb2 |
fix: cancel = layer shift, stuck "1 problem", and dropped child-logger logs
Three bugs that surfaced together while debugging an H2D cancel:
1. Cancelling a print stamped failure_reason="Layer shift" in archives
AND left the printer card stuck on "1 problem" forever. Four causes:
(a) POST /printers/{id}/print/stop never set the user-stopped flag, so
on_print_complete couldn't override "failed" -> "cancelled".
(b) HMS-derived failure_reason heuristic mapped any module-0x0C HMS to
"Layer shift". Module 0x0C is "Motion Controller" broadly (includes
cameras, markers, AND the cancel-sequence echo 0C00_001B). Real
layer-shift codes live in module 0x03. Same false-positive class
existed for "Filament runout" (any 0x07) and "Clogged nozzle" (any
0x05). Replaced with a 23-code curated short-code map; unknowns
leave failure_reason=None.
(c) Cancel-echo HMS codes (0300_400C "The task was canceled.",
0500_400E "Printing was cancelled.") were polluting state.hms_errors
via both the hms[] and print_error parse paths. Filter them at
parse time so the frontend never sees them.
(d) Frontend bucketed gcode_state="FAILED" as a problem unconditionally.
Real failures attach an HMS error; user-cancels don't — so FAILED-
without-HMS now buckets as "finished" and only escalates to "error"
when there's an active known HMS.
2. logs/bambuddy.log was silently dropping records from named child
loggers. TraceIDFilter was attached to root_logger, but Python's
logging only invokes a Logger's filters on records originating at that
logger — propagated child-logger records skipped it, formatter raised
KeyError, handler.handleError dropped the record. Moved the filter
from root_logger.addFilter() to handler.addFilter() on each handler,
matching the filter's own docstring guidance.
derive_failure_reason() extracted as a pure function for testability.
status="cancelled" now symmetrically yields "User cancelled" alongside
"aborted".
20 regression tests across:
- backend/tests/unit/test_failure_reason_derivation.py (11)
- backend/tests/unit/services/test_bambu_mqtt.py::TestHMSUserActionFiltering (4)
- backend/tests/unit/test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger (1)
- frontend/src/__tests__/pages/PrintersPageBucketing.test.ts (5; includes
the H2D-cancel-echo "FAILED + only unknown HMS" case)
|
||
|
|
08601b4772 |
● fix(#1111): advance queue item when print fails before reaching RUNNING
When a file sliced for the wrong nozzle size is dispatched, the printer goes IDLE -> PREPARE -> FAILED without ever entering RUNNING. Completion detection required prev=RUNNING or _was_running=True, so on_print_complete never fired and the queue item stayed at "printing" forever -- blocking every subsequent pending item for that printer (check_queue seeds busy_printers from any row in 'printing'). Fire completion on FAILED from PREPARE or SLICING too. Restricted to those two pre-print states so a stale FAILED on first connection (prev=None) still can't accidentally advance an unrelated queue item. Also populate PrintQueueItem.error_message from the current HMS error list via the existing hms_errors.py lookup, so users see e.g. "[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting" instead of a blank failure reason. |
||
|
|
0cf7a11f46 |
fix(#1105): recognise new H2C serial prefix "31B8B" for dual-nozzle detection
Bambu started shipping H2C units with a new serial prefix (`31B8B…`
observed on a January 2026 unit) instead of the legacy `094…` shared by
the H2D/H2C/H2S family. Two serial-prefix-driven paths — the K-profile
edit branch in `kprofiles.py` and the delete-K-profile MQTT command in
`bambu_mqtt.py::delete_kprofile` — were silently routing the new units
through the single-nozzle format.
Match on 5 chars (`31B8B`): covers the 3-char model code plus the two
revision bytes, leaving the revision-letter slot free to iterate. This
mirrors the X2D precedent of using a longer-than-3-char prefix when a
single data point can't confirm family reuse.
Runtime dual-nozzle detection via `device.extruder.info` count and
model-string branches (`self.model in ("H2C", "H2D", …)`) are already
prefix-agnostic — no change needed there.
- backend/app/api/routes/kprofiles.py: add "31B8B" to is_h2d tuple
- backend/app/services/bambu_mqtt.py: same in delete_kprofile
- backend/tests/unit/services/test_bambu_mqtt.py: regression test
`test_h2c_new_prefix_uses_dual_nozzle_format`
|
||
|
|
d0f35e5d60 | fix(mqtt): cap task_id at int32 max to prevent P1S dispatch stalls (#1042) | ||
|
|
115d6fe627 |
fix(mqtt): unique per-submission IDs for archive reprints (#1011)
Archive reprints and library-file prints built the MQTT project_file command with hardcoded project_id="0", subtask_id="0", task_id="0". Printers key per-job state (including gcode_start_time) on those IDs, so reprints looked like continuations of the same job and third-party MQTT observers (OctoEverywhere) reported compounding durations across repeat replays — a 40 min job reprinted from archive showed ~1h40m, and a second reprint of the same file showed ~4h. BambuStudio mints fresh IDs per submission; bambu_mqtt.start_print() now does the same using an epoch-millisecond timestamp for all three fields. md5 is deliberately left empty to avoid activating firmware md5-validation against a digest we can't compute without re-reading the upload. Added 6 regression tests in TestStartPrintUniqueIdentityFields covering non-zero IDs, md5 stays empty, uniqueness across successive submissions, numeric-string format, and blast-radius guard on unrelated payload fields. Updated CHANGELOG. |