fix(spoolman): per-print 3MF tracking is the only weight writer (#1119)

Spoolman had two mutually-exclusive weight paths gated on the
  `disable_weight_sync` flag. The default (False) used AMS remain%
  x tray_weight auto-sync, which silently dropped non-BL spools
  because the AMS doesn't report tray_weight without RFID. The
  inventory_remaining fallback would have covered it, but the
  spool_assignment table it reads from is wiped on Spoolman
  activation, so non-BL spools got no weight updates at all.

  Match the internal Filament Inventory: per-print tracking always
  runs, AMS auto-sync no longer writes remaining_weight (it still
  maintains spool metadata and slot assignments). The setting
  becomes a no-op; left in the schema and UI for backwards compat.

  - store_print_data: drop the disable_weight_sync early return
  - sync_ams_tray callsites in main.py + routes/spoolman.py: force
    disable_weight_sync=True so weight is never written by AMS sync
  - new regression test confirming tracking runs with flag=false
This commit is contained in:
maziggy
2026-05-12 08:15:28 +02:00
parent 4b7df9f30b
commit b334d7edc9
5 changed files with 75 additions and 17 deletions
+3
View File
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Changed
- **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone.
### 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.
+10 -4
View File
@@ -178,8 +178,9 @@ async def sync_printer_ams(
):
"""Sync AMS data from a specific printer to Spoolman."""
# Check if Spoolman is enabled and connected
# disable_weight_sync is deprecated (#1119); weight comes from per-print tracking.
sm = await get_spoolman_settings(db)
enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
enabled, url = sm["enabled"], sm["url"]
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -318,7 +319,9 @@ async def sync_printer_ams(
sync_result = await client.sync_ams_tray(
tray,
printer.name,
disable_weight_sync=disable_weight_sync,
# Per-print tracking owns weight updates (#1119); manual sync
# only refreshes spool metadata + slot assignments here.
disable_weight_sync=True,
cached_spools=cached_spools,
inventory_remaining=inv_remaining,
spoolman_spool_id_hint=hint,
@@ -394,8 +397,9 @@ async def sync_all_printers(
):
"""Sync AMS data from all connected printers to Spoolman."""
# Check if Spoolman is enabled
# disable_weight_sync is deprecated (#1119); weight comes from per-print tracking.
sm = await get_spoolman_settings(db)
enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
enabled, url = sm["enabled"], sm["url"]
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
@@ -517,7 +521,9 @@ async def sync_all_printers(
sync_result = await client.sync_ams_tray(
tray,
printer.name,
disable_weight_sync=disable_weight_sync,
# Per-print tracking owns weight updates (#1119); manual
# sync-all only refreshes spool metadata + slot assignments.
disable_weight_sync=True,
cached_spools=cached_spools,
inventory_remaining=inv_remaining,
spoolman_spool_id_hint=hint,
+8 -4
View File
@@ -1342,9 +1342,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
if sync_mode and sync_mode != "auto":
return # Only sync on auto mode
# Check if weight sync is disabled
disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
# `spoolman_disable_weight_sync` is deprecated (#1119) — weight is now
# always owned by per-print tracking, never by AMS auto-sync. The
# setting is still read by the settings UI for backwards compat but
# has no effect on the sync path here.
# Get Spoolman URL
spoolman_url = await get_setting(db, "spoolman_url")
@@ -1450,7 +1451,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
result = await client.sync_ams_tray(
tray,
printer_name,
disable_weight_sync=disable_weight_sync,
# Per-print tracking is the only weight writer (#1119).
# AMS auto-sync still maintains spool metadata / slot
# assignments but no longer touches remaining_weight.
disable_weight_sync=True,
cached_spools=cached_spools,
inventory_remaining=inv_remaining,
spoolman_spool_id_hint=hint,
+4 -9
View File
@@ -166,8 +166,10 @@ async def store_print_data(
):
"""Store Spoolman tracking data at print start (persisted to database).
Only stores data when Spoolman is enabled and AMS weight sync is disabled
(i.e., we're using per-usage tracking instead of AMS percentage estimates).
Per-print tracking is the primary weight-update path for Spoolman, mirroring
how the internal Filament Inventory works. The legacy AMS-remain%-based sync
is no longer used as a weight writer (#1119), so this runs whenever Spoolman
is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
"""
from backend.app.api.routes.settings import get_setting
from backend.app.models.active_print_spoolman import ActivePrintSpoolman
@@ -183,13 +185,6 @@ async def store_print_data(
if not spoolman_enabled or spoolman_enabled.lower() != "true":
return
# Only store tracking data if "Disable AMS Weight Sync" is enabled
disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
if not disable_weight_sync:
logger.debug("[SPOOLMAN] Weight sync enabled, skipping per-usage tracking data storage")
return
# Get 3MF file path
full_path = app_settings.base_dir / file_path
if not full_path.exists():
@@ -221,3 +221,53 @@ class TestStorePrintData:
tracking = db.add.call_args.args[0]
assert tracking.slot_to_tray == [1, -1, -1, -1]
db.execute.assert_called_once()
@pytest.mark.asyncio
async def test_stores_tracking_when_disable_weight_sync_is_false(self):
"""#1119: per-print tracking must run regardless of disable_weight_sync.
Previously store_print_data short-circuited when the deprecated
`spoolman_disable_weight_sync` flag was off, leaving non-BL spools
with no weight-update path at all. Per-print tracking is now the
only weight writer for Spoolman, so it must run whenever Spoolman
is enabled.
"""
db = AsyncMock()
db.execute = AsyncMock(return_value=MagicMock())
db.add = MagicMock()
db.commit = AsyncMock()
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]}
)
mock_settings = MagicMock()
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__.return_value = mock_path
# Only spoolman_enabled is consulted now (disable_weight_sync is no
# longer read). The single side_effect entry proves no extra
# get_setting calls slip back in.
with (
patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true"])),
patch(
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
return_value=[{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": "#FF0000"}],
),
patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
):
await store_print_data(
printer_id=1,
archive_id=20,
file_path="archives/test.3mf",
db=db,
printer_manager=printer_manager,
ams_mapping=[0],
)
# Tracking row was inserted — the fix is working.
db.add.assert_called_once()