fix(usage-tracker): #1607 filter empty AMS slots from position-based fallback

When no explicit slot-to-tray mapping is captured (path 5 of 6 in
  _track_from_3mf — fires before the request-topic subscription that catches
  ams_mapping is accepted), the tracker builds available_trays from
  build_ams_tray_lookup and uses position to map the slicer's Nth filament
  to the Nth available tray. The helper enumerated every AMS tray by id
  regardless of whether a spool was loaded, so AMS slots 0-2 loaded + slot 3
  empty + external yielded available_trays = [0, 1, 2, 3, 254]. The slicer
  compacts its filament UI to hide empty AMS slots, so its 4th filament is
  the external — but position mapping routed it to AMS0-T3 (the empty slot)
  instead of 254 (external). No spool assigned there → usage silently
  skipped → external never decremented.

  Filter the fallback to slots with a non-empty tray_type. build_ams_tray_lookup
  stays unchanged for its other callers (spoolman_tracking.store_print_data,
  routes/printers, spool_assignment_notifications); the filter is applied at
  the usage-tracker call site only. Mirrors the existing vt_tray filter in
  build_ams_tray_lookup line 174.
This commit is contained in:
maziggy
2026-06-03 09:45:10 +02:00
parent cdc27eb517
commit fee7c722f5
3 changed files with 167 additions and 2 deletions
+1
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -1223,14 +1223,26 @@ async def _track_from_3mf(
if isinstance(mapped, int) and mapped >= 0:
global_tray_id = mapped
# Position-based default: sort available tray IDs so external spools (254/255)
# naturally follow standard AMS trays, matching slicer slot numbering
# naturally follow standard AMS trays, matching slicer slot numbering.
#
# Filter out AMS slots that have no spool loaded (empty `tray_type`) —
# BambuStudio/OrcaSlicer compact the slot list when assigning filaments
# and don't expose empty AMS slots to the user, so the slicer's 3MF
# slot N maps to the Nth *loaded* tray, not the Nth physical position.
# Without this filter a "3 AMS slots loaded + 1 empty + external"
# layout routes the slicer's 4th filament to the empty AMS slot
# instead of the external (#1607), and the external's spool usage
# never gets recorded. vt_tray entries are already filtered the
# same way inside `build_ams_tray_lookup` (line 174 checks
# `tray_type`), so this just mirrors that for the AMS side.
if global_tray_id is None:
_state = printer_manager.get_status(printer_id)
_raw = getattr(_state, "raw_data", None) if _state else None
if _raw:
from backend.app.services.spoolman_tracking import build_ams_tray_lookup
available_trays = sorted(build_ams_tray_lookup(_raw).keys())
_lookup = build_ams_tray_lookup(_raw)
available_trays = sorted(gid for gid, info in _lookup.items() if info.get("tray_type"))
if slot_id <= len(available_trays):
global_tray_id = available_trays[slot_id - 1]
# Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)
+152
View File
@@ -1753,6 +1753,158 @@ class TestMqttMappingIntegration:
assert results[0]["tray_id"] == 2 # From print_cmd mapping, not MQTT
class TestPositionBasedFallbackEmptyAmsSlot:
"""Position-based mapping fallback (#1607): when no explicit mapping is
available, the slicer's Nth filament must map to the Nth *loaded* AMS tray
(skipping empty slots), not the Nth physical slot position. BambuStudio /
OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS
slots, so the 3MF slot list is dense even when the AMS itself has gaps."""
@pytest.mark.asyncio
async def test_external_routed_correctly_when_ams_has_empty_middle_slot(self):
"""Reporter's scenario: AMS trays 0-2 loaded, tray 3 empty, external
loaded. Slicer emits 4 filaments — slot 4 = external. Without the fix
the position-based fallback maps slot 4 to the empty AMS tray 3
(since `available_trays = [0, 1, 2, 3, 254]`) and external usage is
silently dropped because no spool is assigned to AMS0-T3.
After the fix, empty AMS slots are filtered (tray_type is empty) so
`available_trays = [0, 1, 2, 254]` and slot 4 correctly resolves to
the external (global tray 254 → AMS255-T0)."""
# Spool fed via external (vt_tray 254 → AMS255-T0)
spool = _make_spool(spool_id=42, label_weight=1000)
assignment = _make_assignment(spool_id=42, ams_id=255, tray_id=0)
archive = _make_archive(archive_id=70)
# db: archive, queue_item(None), assignment, spool
db = _mock_db_sequential([archive, None, assignment, spool])
# AMS reports 4 physical tray slots but slot 3 has no spool (empty
# tray_type); external spool is loaded in vt_tray.
# No `mapping` field on the state — forces fallback through path 5.
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA"},
{"id": 1, "tray_type": "PETG"},
{"id": 2, "tray_type": "ABS"},
{"id": 3, "tray_type": ""}, # empty slot
],
}
],
"vt_tray": [{"id": 254, "tray_type": "PLA"}],
},
progress=100,
layer_num=50,
tray_now=254,
tray_change_log=[],
)
# 3MF has 4 dense filament slots — slot 4 is the external. Only slot 4
# has weight (other slots came from AMS spools handled separately).
filament_usage = [{"slot_id": 4, "used_g": 12.3, "type": "PLA", "color": "#00AABB"}]
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,
),
):
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=70,
status="completed",
print_name="External + AMS print",
handled_trays=handled_trays,
printer_manager=printer_manager,
db=db,
)
assert len(results) == 1
# The external spool was charged, NOT the empty AMS slot.
assert results[0]["spool_id"] == 42
assert results[0]["ams_id"] == 255
assert results[0]["tray_id"] == 0
assert results[0]["weight_used"] == 12.3
assert (255, 0) in handled_trays
# Critical assertion: AMS0-T3 (the empty slot) was NOT charged.
assert (0, 3) not in handled_trays
@pytest.mark.asyncio
async def test_dense_ams_unchanged_no_empty_slots(self):
"""Sanity check: when every AMS slot is loaded, the position-based
fallback still works for the slicer's external = last slot case."""
spool = _make_spool(spool_id=99, label_weight=1000)
assignment = _make_assignment(spool_id=99, ams_id=255, tray_id=0)
archive = _make_archive(archive_id=71)
db = _mock_db_sequential([archive, None, assignment, spool])
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA"},
{"id": 1, "tray_type": "PETG"},
{"id": 2, "tray_type": "ABS"},
{"id": 3, "tray_type": "TPU"},
],
}
],
"vt_tray": [{"id": 254, "tray_type": "PLA"}],
},
progress=100,
layer_num=50,
tray_now=254,
tray_change_log=[],
)
# 5 filaments, slot 5 = external. available_trays = [0,1,2,3,254] →
# slot_id=5 → available_trays[4] = 254.
filament_usage = [{"slot_id": 5, "used_g": 7.5, "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,
),
):
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=71,
status="completed",
print_name="Dense AMS + external",
handled_trays=handled_trays,
printer_manager=printer_manager,
db=db,
)
assert len(results) == 1
assert results[0]["spool_id"] == 99
assert results[0]["ams_id"] == 255
assert results[0]["tray_id"] == 0
class TestNotificationVariables:
"""Tests for filament_details formatting in notifications."""