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.
This commit is contained in:
maziggy
2026-05-12 07:47:19 +02:00
parent bf2b34ebcf
commit 4b7df9f30b
3 changed files with 96 additions and 0 deletions
+2
View File
@@ -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
View File
@@ -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",
@@ -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."""