From 4b7df9f30bfc5eee754bd18f6207509aa57cbe3c Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 12 May 2026 07:47:19 +0200 Subject: [PATCH] fix(usage-tracker): skip remain% fallback for trays not used by print (#1269) The AMS remain% delta path charged every tray with a delta, not just trays involved in the print. Swapping a spool in an UNUSED slot mid- print made the slot report remain=0 (fresh spool, no tag), versus a print-start snapshot of 100%, so the originally-assigned spool got charged the full 1000g. Build print_used_keys from ams_mapping, tray_change_log, and tray_now_at_start, and skip fallback for trays not in that set. Legacy "scan every tray" behavior preserved when none of the three signals are present. --- CHANGELOG.md | 2 + backend/app/services/usage_tracker.py | 36 ++++++++++++ .../tests/unit/services/test_usage_tracker.py | 58 +++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2507fd108..5e4948966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Fixed +- **Usage tracker: spool swaps in UNUSED slots mid-print no longer charge the old spool** ([#1269](https://github.com/maziggy/bambuddy/issues/1269), reported by @maugsburger) — Path 2 of the usage tracker (AMS remain% delta fallback) iterated every AMS tray that had a remain% delta, even slots the print never touched. When a user swapped spools in an unrelated slot during a print, the new spool reports `remain=0` (no RFID tag yet) while the snapshot from print-start was 100%, so the fallback charged the originally-assigned spool the full 1000 g. Reporter's case: single-filament print on AMS0-T3 (`ams_mapping=[3]`), swapped a spool in T1 and another in T2 to refill while the print continued — wound up with `Spool 27 consumed 1000.0g (100%) on printer 1 AMS0-T1` and `Spool 24 consumed 170.0g (17%) on printer 1 AMS0-T2`, neither of which were ever in the print. Fix: the fallback now builds `print_used_keys` from `session.ams_mapping`, `state.tray_change_log`, and `session.tray_now_at_start` (the three runtime signals telling us which trays were actually part of the print), converts each global tray ID to `(ams_id, tray_id)` using the standard convention (254/255 → external, ≥128 → AMS-HT, otherwise `id // 4, id % 4`), and skips fallback for trays whose key is not in that set. When all three signals are empty (legacy edge case: no slicer push, no MQTT tray-change events, no `tray_now` at start) the legacy "scan every tray" behavior is preserved so we don't regress prints with no metadata. Regression test in `test_usage_tracker.py::test_skips_fallback_for_trays_outside_print_mapping` reproduces the reporter's exact scenario. + - **Printer card: smart-plug live wattage now rounded to whole watts** ([#1266](https://github.com/maziggy/bambuddy/issues/1266), reported by @Carter3DP) — The printer card's smart-plug status badge rendered `plugStatus.energy.power` raw, so plugs that report fractional watts (Kauf PLF12 via ESPHome / Home Assistant in the reporter's case, but any MQTT plug pushing a float can hit this) showed values like `14.123456789012` W and overflowed the card width. `SmartPlugCard` and `SwitchbarPopover` already wrapped the same field in `Math.round()`; only the printer-card badge was missing the round. Single-line fix at `frontend/src/pages/PrintersPage.tsx:4569`. ### Added diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index aa4ee591c..15cbd6684 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -448,6 +448,30 @@ async def on_print_complete( ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else [] ) + # Build set of trays actually involved in this print (#1269). + # Without this guard, swapping a spool in an UNUSED slot mid-print + # makes that slot's remain% drop to 0, which the fallback below + # would otherwise charge to the originally-assigned spool. + def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]: + if global_tray_id >= 254: + return (255, global_tray_id - 254) + if global_tray_id >= 128: + return (global_tray_id, 0) + return (global_tray_id // 4, global_tray_id % 4) + + print_used_keys: set[tuple[int, int]] = set() + if ams_mapping: + for gid in ams_mapping: + if isinstance(gid, int) and gid >= 0: + print_used_keys.add(_global_to_ams_key(gid)) + for change in getattr(state, "tray_change_log", None) or []: + if isinstance(change, (tuple, list)) and len(change) >= 1: + gid = change[0] + if isinstance(gid, int) and gid >= 0: + print_used_keys.add(_global_to_ams_key(gid)) + if session.tray_now_at_start is not None and session.tray_now_at_start >= 0: + print_used_keys.add(_global_to_ams_key(session.tray_now_at_start)) + # Collect all trays to check: AMS trays + VT (external) trays # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label) trays_to_check: list[tuple[int, int, int, str]] = [] @@ -480,6 +504,18 @@ async def on_print_complete( if key not in session.tray_remain_start: continue + # Skip trays the print never touched. Only enforce when we have + # evidence of which trays the print used; if print_used_keys is + # empty (no mapping, no change log, no tray_now_at_start) keep + # the legacy behavior of scanning every tray. + if print_used_keys and key not in print_used_keys: + logger.info( + "[UsageTracker] %s: not in print mapping/tray_change_log — skipping fallback for printer %d", + tray_label, + printer_id, + ) + continue + if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100: logger.info( "[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d", diff --git a/backend/tests/unit/services/test_usage_tracker.py b/backend/tests/unit/services/test_usage_tracker.py index a5d04f0c8..a8eb9b6f0 100644 --- a/backend/tests/unit/services/test_usage_tracker.py +++ b/backend/tests/unit/services/test_usage_tracker.py @@ -193,6 +193,64 @@ class TestOnPrintCompleteAMSDelta: assert results == [] + @pytest.mark.asyncio + async def test_skips_fallback_for_trays_outside_print_mapping(self): + """#1269: swapping a spool in an UNUSED slot mid-print must NOT charge the old spool. + + Reproduces maugsburger's report: single-color print on AMS0-T3 + (ams_mapping=[3]). User swaps spools in T1 and T2 during the print — + those slots report remain=0 at completion (new spool with no tag). + The fallback must skip T1 and T2 because they were never in the + print's tray mapping or runtime tray_change_log. + """ + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="splitter", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 1): 100, (0, 2): 17, (0, 3): 100}, + tray_now_at_start=3, + ams_mapping=[3], + ) + + # User swapped T1 and T2 mid-print → both report remain=0 now. + # T3 was actually used but it's also at 0 now. Without the fix the + # fallback would charge the originally-assigned spools at T1 and T2. + ams_data = [ + { + "id": 0, + "tray": [ + {"id": 1, "remain": 0}, + {"id": 2, "remain": 0}, + {"id": 3, "remain": 0}, + ], + } + ] + state = _make_printer_state(ams_data, tray_now=3) + state.tray_change_log = [(3, 0)] # only T3 was loaded during the print + pm = _make_printer_manager(state) + + # Only T3 should reach the spool lookup; T1 and T2 must be filtered + # out before any DB query is issued for them. + t3_spool = _make_spool(id=8, label_weight=1000, weight_used=0) + t3_assignment = _make_assignment(spool_id=8, ams_id=0, tray_id=3) + db = AsyncMock() + db.execute = AsyncMock( + side_effect=[ + MagicMock(), # _find_3mf_by_filename: library search + MagicMock(), # _find_3mf_by_filename: archive search + MagicMock(scalar_one_or_none=MagicMock(return_value=t3_assignment)), + MagicMock(scalar_one_or_none=MagicMock(return_value=t3_spool)), + ] + ) + + results = await on_print_complete(1, {"status": "completed"}, pm, db) + + # Only T3 should be charged. T1 (spool 27 in the report) and T2 + # (spool 24) must NOT appear in the results. + assert len(results) == 1 + assert results[0]["ams_id"] == 0 + assert results[0]["tray_id"] == 3 + class TestTrackFrom3MF: """Tests for Path 2: 3MF per-filament fallback tracking."""