From a53dc20ca3a137f2645652b05734d13d2b590331 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 20 Jun 2026 12:26:31 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + backend/app/services/bambu_mqtt.py | 15 +- backend/app/services/usage_tracker.py | 25 +++- .../tests/unit/services/test_bambu_mqtt.py | 59 ++++++++ backend/tests/unit/test_usage_tracker.py | 135 ++++++++++++++++++ 5 files changed, 229 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e3a6e089..1bc8244e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ All notable changes to Bambuddy will be documented in this file. - **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. ### Fixed +- **Mid-print AMS Backup spool-switch credited the entire print to the second spool instead of splitting the weight (#1771, reported by @biduleman)** — Reporter (P1S) forcefully started a print needing ~260 g with only 180 g remaining on the first spool; the printer correctly consumed the first spool, AMS Backup auto-switched to a same-material second spool, and finished the print. Bambuddy then attributed ALL 260 g to the second spool; the first spool was left untouched in the inventory. Reads as a usage-attribution bug; root cause is two stacking firmware-quirk bugs that produce exactly the all-to-second-spool symptom for prints without per-layer 3MF gcode. **Bug A — firmware reset of `total_layer_num`.** `bambu_mqtt.py:2135` wrote `state.total_layers = int(data["total_layer_num"])` unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models reset `layer_num` / `progress` via at print end) pushes a `total_layer_num: 0` frame at print completion. The unconditional write clobbered the slicer's actual total — by the time the usage-tracker ran a frame or two later, `state.total_layers` was 0. The existing `_last_valid_layer_num` guard at line 2127 covered the same race for `layer_num` but the equivalent guard for `total_layers` was never added. **Bug B — `usage_tracker.py:1129-1137` dumped-all-to-last fallback.** The mid-print tray-switch split path (which handles AMS Backup → second spool exactly like this scenario) has three attribution branches per segment: per-layer 3MF gcode (precise), linear by layer ratio (`total_layer_num`-based), and "remainder" (the last segment always gets `total_weight - sum_previous`). When per-layer 3MF data is unavailable (force-started prints often lack it) AND `total_layers == 0` (Bug A had just fired), the linear branch silently produced `segment_grams = 0.0` for every non-last segment — so the entire print weight collapsed onto the last segment's remainder calculation. Path 2 (AMS remain% delta) couldn't recover because (1) the just-emptied spool typically reports `remain=-1` after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added to `handled_trays` by Bug B's misallocation, suppressing the Path 2 lookup. End result: 260 g credited to spool 2, spool 1 left at 180 g unchanged — exactly the screenshot the reporter posted. **Fix A — `bambu_mqtt.py`.** Mirror the existing `_last_valid_layer_num` shape: only overwrite `state.total_layers` when the incoming `total_layer_num` is positive, so a firmware-reset frame can't clobber the cached value. The explicit reset to 0 on new print start now lives in the `_handle_print_start` block at line ~3132 (right next to the existing `state.layer_num = 0`) so the previous print's total still can't bleed into the next one before its first real push arrives. **Fix B — `usage_tracker.py`.** Cascade the linear-fallback denominator: try `state.total_layers` first (the canonical source), then `last_layer_num` (the print's last-valid layer captured at completion time, already threaded into `_track_from_3mf` as a parameter for the `last_progress` partial-print case), then equal-split across segments as a last-resort fence — still wrong, but bounded. The original behaviour was strictly worse than equal-split: it always dumped 100% of the print's weight onto the last segment regardless of where the switch actually happened. **Tests.** 5 new backend cases. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_uses_last_layer_num_when_total_layers_reset` — the reporter's exact 260 g / 180 g split scenario with `state.total_layers=0` and `last_layer_num=260`, asserts 180.0 / 80.0 g attribution. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_equal_split_when_no_layer_info_at_all` — both denominators unavailable, asserts equal-split (30.0 / 30.0 g for a 60 g print) instead of the dump-to-last behaviour. `test_bambu_mqtt.py::TestTotalLayersPreservation` (× 3) — non-zero push sets the field; zero push preserves the cached value; new-print-start path explicitly resets to 0. Existing 7 `TestTrayChangeSplit` cases (including the precise-per-layer-gcode happy path and the `total_layers=100` linear fallback regression at line 1045) still green — they use a positive `total_layers` so the new cascade is dormant for them. **Scope check — what this does NOT change.** The precise per-layer 3MF branch (`extract_layer_filament_usage_from_3mf` returns data) is preferred over the linear fallback whenever per-layer data is available, so users who slice through PrintModal with full 3MF analysis stay on the precise path. Single-tray prints (no AMS Backup switch) never enter the split path at all (`len(tray_changes) > 1` gate). Path 2 (AMS remain% delta) is unaffected — it only fires for trays Path 1 didn't already attribute, and the fixed Path 1 covers the correct trays now. **One intentional semantic shift worth flagging:** `state.total_layers` now persists across the firmware-end-of-print reset frame and between prints, instead of briefly dropping to 0 at completion and staying there until the next print's first push. The explicit reset in `_handle_print_start` (line ~3135, sibling to the existing `state.layer_num = 0` reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer of `state.total_layers` across the backend (`main.py`'s first-layer notification, `metrics.py`'s Prometheus gauge, `spoolman_tracking.py`'s progress estimator, `printers.py`'s REST response, `mqtt_relay.py`'s relay payload, `printer_manager.py`'s WS payload, the finish-photo-moment trigger at `bambu_mqtt.py:2206`): none distinguishes "no active print" by `total_layers == 0` — they all check `state.state` for that. So the persistence change is invisible to every existing surface, and downstream consumers that DO read the value get a more reliable number at end-of-print and across a power-cycle. Full backend `pytest -n 30` 6222/6222 in 94 s; ruff clean. + - **"Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by @biduleman)** — Reporter (P1S) had `prefer_lowest_filament=true` with two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. **(1) The frontend sort never saw inventory grams.** The backend has a two-tier sort (`_prefer_lowest_sort_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_mapping`. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (`useFilamentMapping.ts::computeAmsMapping` + `useFilamentMapping`, `useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides` + `computeMatchDetails`, `amsHelpers.ts::autoMatchFilament`) only sorted by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=100` (freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. **(2) The sort had no notion of whether the printer could actually USE a near-empty spool.** Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. **Fix — dispatch-time backend gate.** `_compute_ams_mapping_for_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.ams_filament_backup is False`, logs `[prefer-lowest] skipped (AMS Backup OFF on printer %s)` so the decision is visible in support bundles without enabling DEBUG. Tri-state default: `None` (unknown / A1 family) applies the sort, preserving today's behaviour. **Fix — banding-equivalent two-tier frontend sort.** New exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map` param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft used `amsId * 4 + trayId` for all slots, which gives `ams_id = -1` (external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. **Fix — frontend backup gate.** New exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. **Settings → Filaments tooltip.** Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). **i18n.** New key `settings.preferLowestFilamentBackupNote` translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. **Tests.** 7 new backend cases — `test_scheduler_backup_gate.py` (tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) and `test_inventory_remain_endpoint.py` (× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases — `useFilamentMapping.test.ts` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green. - **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — printer auto-picked the last matching nozzle instead (#1780, reported by @mkoreen)** — The reporter (H2C with the rack-swap "dual nozzle variant" model code O1C2; 7 nozzles registered across two extruders: R1/R2 high-flow 0.4, R3/R4 standard 0.4, plus three other diameters) noticed that picking a specific nozzle in Bambu Studio (e.g. "use R1") had no effect — the H2C would consistently load R2 for HF prints and R4 for standard prints. Root-cause traced from the printer's reported `device.nozzle.info[]` state + the Bambu Studio source: BambuStudio's `project_file` MQTT command for O1C2 carries two extra fields — `nozzle_mapping` (a `list[int]` of per-filament physical nozzle position IDs, populated from a prior `get_auto_nozzle_mapping` round-trip with the firmware) and `nozzles_info` (a `list[dict]` of per-extruder rack metadata: `id`/`type`/`flowSize`/`diameter`). The VP intake at `virtual_printer/manager.py:564-574` only captured five fields out of the slicer's project_file dict (bed_leveling / flow_cali / vibration_cali / layer_inspect / timelapse) and dropped everything else, including these two. Without `nozzle_mapping` on the dispatched project_file, the H2C firmware fell back to its auto-pick rule ("any nozzle matching diameter + flow class") and deterministically landed on the last matching slot in the rack — which is why R1 selections always became R2 and R3 selections always became R4. **Fix:** carry both fields through the full intake → queue → dispatch path. `_add_to_print_queue` reads `nozzle_mapping` + `nozzles_info` out of the captured slicer opts, normalises a stringified-JSON or already-parsed shape to the same canonical JSON-string representation, and stamps both on the PrintQueueItem inside the multi-plate loop (so a multi-plate "Send All" preserves the nozzle pick across plates, mirroring `gcode_injection` / `filament_overrides` per-plate stamping from #1697 / #1188). New nullable TEXT columns `nozzle_mapping` / `nozzles_info` on `print_queue` — non-branched ALTER (same as `ams_mapping` / `filament_overrides` precedent at `database.py:944/955`). The dispatcher reads the JSON strings off the queue item, parses them back to list/dict, and includes them on the published `project_file` command as parsed JSON values (not strings — the wire shape matches BS's, same convention as `ams_mapping` / `ams_mapping2`). Dual-nozzle gate at `bambu_mqtt.py::start_print()` keeps the fields off single-nozzle dispatches as defense-in-depth (`is_dual_nozzle` runtime flag already established by `device.extruder.info[]` len ≥ 2). **Fail-open on malformed JSON:** an unparseable column value logs a WARNING and omits the field — firmware then runs the same auto-pick path that was the pre-fix behaviour, never a worse one. **No model gate elsewhere:** every other model omits these fields from its project_file, so the pass-through is a transparent no-op on X1C / P1S / A1 / H2D / X2D. **API surface:** `PrintQueueItemResponse` parses both fields back to `list[int]` / `list[dict]` so any future "edit print → nozzle" UI can read+round-trip them; `PrintQueueItemUpdate` accepts them and the route handler serialises to JSON for storage (same shape as `ams_mapping`). **Tests:** 3 new cases in `test_virtual_printer.py::TestVirtualPrinterInstance` (capture round-trip, NULL-on-omitted-fields, per-plate stamping on multi-plate) and 6 new cases in `test_bambu_mqtt.py::TestStartPrintNozzleMappingDispatch` (dual-nozzle injection both fields, single-nozzle no-emit even when set, dual-nozzle no-fields no-op, partial-only mapping passthrough, malformed JSON logs + dispatch continues, empty-string treated as absent). 1 line update in `test_printer_manager.py::test_start_print_calls_client` to add the two new kwargs to the `assert_called_once_with` matcher. Full backend `pytest -n 30` 6176/6176 in 86.67s; ruff clean; `npm run build` clean; vitest 2158/2158; i18n parity green. **Scope:** O1C2 (the H2C dual-nozzle-rack variant) is the only Bambu model with a rack-swap mechanism where the firmware can choose between multiple physical nozzles per side, so the observable fix lands there. H2D / X2D dual-extruder routing was never affected — those carry filament-to-extruder mapping through `ams_mapping2` (ams_id 254/255), which Bambuddy already forwards correctly. No DB migration on Postgres-only side; no permission change, no i18n keys, no frontend changes (a "pick a different nozzle from queue/archives" UI is reasonable follow-up scope but isn't required to close this bug — the slicer's pick now rides through, which is the reporter's primary expected behaviour). diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index fb494f5cc..a6a4b6d69 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -2133,7 +2133,14 @@ class BambuMQTTClient: if new_layer > old_layer and self.on_layer_change: self.on_layer_change(new_layer) if "total_layer_num" in data: - self.state.total_layers = int(data["total_layer_num"]) + # Some firmware (P1S observed) resets `total_layer_num` to 0 at + # print end — same shape as the `layer_num` reset guarded above. + # Preserve the last known good value so the usage-tracker split + # path (#1771) has a denominator that survives the reset frame. + # Explicit reset to 0 happens on print start (`_handle_print_start`). + new_total = int(data["total_layer_num"]) + if new_total > 0: + self.state.total_layers = new_total # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage) # Convert to 0-100 percentage for display @@ -3120,6 +3127,12 @@ class BambuMQTTClient: self.state.hms_errors = [] # Reset layer tracking for new print (needed for layer-based timelapse) self.state.layer_num = 0 + # Reset total_layers so the previous print's value can't bleed into + # this print's usage-tracker split before the new push_status arrives + # with the slicer's total (#1771 follow-on to the preservation guard + # above at line ~2135 — the guard now ignores firmware-reset 0s, so + # the explicit reset has to happen here instead). + self.state.total_layers = 0 # Reset completion tracking for new print self._was_running = True self._completion_triggered = False diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 9b32272ff..02578192c 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -1127,14 +1127,27 @@ async def _track_from_3mf( mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0) segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density) else: - # No per-layer data: linear fallback by layer ratio + # No per-layer data: linear fallback by layer ratio (#1771). + # Cascade denominators because firmware on some models (P1S + # observed) resets `total_layer_num` to 0 at print end — + # `last_layer_num` is the print's last-valid layer captured + # mid-print and survives that reset (same shape as the + # `last_progress` fallback at line 1040). Equal-split is the + # last-resort fence: still wrong, but bounded — never dumps + # the entire print onto the last segment, which was the + # original #1771 symptom for the reporter (P1S, AMS Backup + # fed from spool 1 then spool 2, all 260 g credited to + # spool 2 even though spool 1 had given up its 180 g). seg_end_layer = tray_changes[seg_idx + 1][1] - total_layers = state.total_layers if state else 0 - if total_layers > 0: - segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers + denom = (state.total_layers if state else 0) or last_layer_num + if denom > 0: + segment_grams = total_weight * (seg_end_layer - seg_start_layer) / denom else: - # Can't compute ratio — assign all to last segment - segment_grams = 0.0 + # No layer information available from any source — + # spread evenly across segments. The last segment will + # get the rounding remainder via the `is_last` branch + # above on its own iteration. + segment_grams = total_weight / len(tray_changes) sum_previous += segment_grams if segment_grams <= 0: diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 8a9682154..34f5eecb1 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -5795,6 +5795,65 @@ class TestPrintRunningObservedCallback: } +class TestTotalLayersPreservation: + """#1771: P1S firmware resets `total_layer_num` to 0 at print end. Without + this guard, the usage tracker's split path saw `state.total_layers = 0` at + completion and dumped the whole print onto the last spool. + + These tests pin the preservation pattern (mirror of `_last_valid_layer_num`) + and the explicit reset on new print start so the previous print's total + can't bleed into the next. + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST123", + access_code="12345678", + ) + return client + + def test_nonzero_total_layer_num_sets_state(self, mqtt_client): + # Baseline: a fresh push with the slicer's total updates state.total_layers. + mqtt_client._process_message({"print": {"total_layer_num": 260}}) + assert mqtt_client.state.total_layers == 260 + + def test_zero_total_layer_num_does_not_clobber_cached_value(self, mqtt_client): + # Firmware-reset frame: total_layer_num=0 arrives mid- or end-of-print. + # The guard must NOT overwrite the previously-captured 260. + mqtt_client._process_message({"print": {"total_layer_num": 260}}) + mqtt_client._process_message({"print": {"total_layer_num": 0}}) + assert mqtt_client.state.total_layers == 260 + + def test_print_start_explicitly_resets_total_layers(self, mqtt_client): + # Without the explicit reset on print start, the previous print's total + # would persist into the new print until its first total_layer_num push + # arrived — which is exactly the kind of cross-print bleed the + # preservation guard above otherwise opens up. + mqtt_client._process_message({"print": {"total_layer_num": 260}}) + assert mqtt_client.state.total_layers == 260 + + # Simulate the new-print-start trigger shape (is_new_print path): + # state was previously RUNNING on an old file; now we observe a + # different file going RUNNING. + mqtt_client._previous_gcode_state = "RUNNING" + mqtt_client._previous_gcode_file = "/data/Metadata/old_print.gcode" + mqtt_client._was_running = True + mqtt_client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "gcode_file": "/data/Metadata/new_print.gcode", + "subtask_name": "new_print", + } + } + ) + assert mqtt_client.state.total_layers == 0 + + class TestAmsFilamentBackupHoldTimer: """Regression: stale push_status arriving within the hold window after a toggle command MUST NOT flip ams_filament_backup back to the printer's diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py index 203a1642e..af000b9cf 100644 --- a/backend/tests/unit/test_usage_tracker.py +++ b/backend/tests/unit/test_usage_tracker.py @@ -1415,6 +1415,141 @@ class TestTrayChangeSplit: assert results[2]["ams_id"] == 0 assert results[2]["tray_id"] == 2 + @pytest.mark.asyncio + async def test_tray_switch_uses_last_layer_num_when_total_layers_reset(self): + """#1771 regression: P1S firmware resets `total_layer_num` to 0 at print + end; without the cascade the linear fallback collapsed to `0.0` per + non-last segment and dumped the whole print onto the last spool. With + the fix, `last_layer_num` (the print's last-valid layer captured before + the firmware reset) is the substitute denominator. + + Reporter's exact shape: print needed ~260 g, started on a 180 g spool, + AMS Backup switched at ~70% through, second spool finished the print. + Before fix: spool 1 → 0 g, spool 2 → 260 g (the bug). + After fix: spool 1 → 180 g, spool 2 → 80 g (correct). + """ + spool_a = _make_spool(spool_id=10, label_weight=1000) + spool_b = _make_spool(spool_id=20, label_weight=1000) + assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=0) + assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1) + archive = _make_archive(archive_id=171) + + db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b]) + + # Firmware reset: state.total_layers is 0 by the time usage_tracker runs. + # last_layer_num threaded in from on_print_complete is the survival value. + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=0, # also reset + tray_now=1, + last_loaded_tray=1, + total_layers=0, # the bug trigger + tray_change_log=[(0, 0), (1, 180)], # switched at layer 180 of 260 + ) + + filament_usage = [{"slot_id": 1, "used_g": 260.0, "type": "PLA", "color": ""}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + patch( + "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", + return_value=None, # No per-layer 3MF data — force linear fallback path + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=171, + status="completed", + print_name="#1771 repro", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + last_layer_num=260, # survives the firmware reset of total_layer_num + ) + + # Both segments must be attributed correctly. + assert len(results) == 2 + # Segment 1: tray 0, layers 0-180 of 260 → 260 * 180/260 = 180.0 g + assert results[0]["ams_id"] == 0 + assert results[0]["tray_id"] == 0 + assert results[0]["weight_used"] == 180.0 + # Segment 2: tray 1, remainder = 260 - 180 = 80.0 g + assert results[1]["ams_id"] == 0 + assert results[1]["tray_id"] == 1 + assert results[1]["weight_used"] == 80.0 + + @pytest.mark.asyncio + async def test_tray_switch_equal_split_when_no_layer_info_at_all(self): + """Defensive fence: when neither `state.total_layers` nor `last_layer_num` + survives (older firmware / edge case), equal-split across segments is the + last-resort fallback. Still wrong but BOUNDED — the original bug dumped + the whole print weight onto the last segment, which was strictly worse. + """ + spool_a = _make_spool(spool_id=10, label_weight=1000) + spool_b = _make_spool(spool_id=20, label_weight=1000) + assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=0) + assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1) + archive = _make_archive(archive_id=172) + + db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=0, + tray_now=1, + last_loaded_tray=1, + total_layers=0, # neither source available + tray_change_log=[(0, 0), (1, 50)], + ) + + filament_usage = [{"slot_id": 1, "used_g": 60.0, "type": "PLA", "color": ""}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + patch( + "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", + return_value=None, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=172, + status="completed", + print_name="no layer info", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + last_layer_num=0, # also unavailable + ) + + # 2 segments, equal split: 60g / 2 = 30g each. Last segment uses the + # `is_last` remainder branch so it stays at 30.0 too. + assert len(results) == 2 + assert results[0]["weight_used"] == 30.0 + assert results[1]["weight_used"] == 30.0 + class TestDecodeMqttMapping: """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""