mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user