Просмотр исходного кода

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.
maziggy 23 часов назад
Родитель
Сommit
fee7c722f5
3 измененных файлов с 166 добавлено и 2 удалено
  1. 0 0
      CHANGELOG.md
  2. 14 2
      backend/app/services/usage_tracker.py
  3. 152 0
      backend/tests/unit/test_usage_tracker.py

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 14 - 2
backend/app/services/usage_tracker.py

@@ -1223,14 +1223,26 @@ async def _track_from_3mf(
                 if isinstance(mapped, int) and mapped >= 0:
                 if isinstance(mapped, int) and mapped >= 0:
                     global_tray_id = mapped
                     global_tray_id = mapped
             # Position-based default: sort available tray IDs so external spools (254/255)
             # 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:
             if global_tray_id is None:
                 _state = printer_manager.get_status(printer_id)
                 _state = printer_manager.get_status(printer_id)
                 _raw = getattr(_state, "raw_data", None) if _state else None
                 _raw = getattr(_state, "raw_data", None) if _state else None
                 if _raw:
                 if _raw:
                     from backend.app.services.spoolman_tracking import build_ams_tray_lookup
                     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):
                     if slot_id <= len(available_trays):
                         global_tray_id = available_trays[slot_id - 1]
                         global_tray_id = available_trays[slot_id - 1]
             # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)
             # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)

+ 152 - 0
backend/tests/unit/test_usage_tracker.py

@@ -1753,6 +1753,158 @@ class TestMqttMappingIntegration:
         assert results[0]["tray_id"] == 2  # From print_cmd mapping, not MQTT
         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:
 class TestNotificationVariables:
     """Tests for filament_details formatting in notifications."""
     """Tests for filament_details formatting in notifications."""
 
 

Некоторые файлы не были показаны из-за большого количества измененных файлов