Browse Source

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.
maziggy 2 weeks ago
parent
commit
4b7df9f30b

+ 2 - 0
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

+ 36 - 0
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",

+ 58 - 0
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."""