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

fix(usage-tracker): decode MQTT mapping field for correct spool tracking on multi-AMS printers (#364)

On dual-nozzle printers (H2C, H2D Pro) with multiple AMS units, usage
tracking attributed filament consumption to the wrong spools. The MQTT
mapping field — a snow-encoded array mapping slicer slots to physical
AMS trays — was preserved in state but never parsed.

Add _decode_mqtt_mapping() to convert snow encoding (ams_hw_id * 256 +
slot) to bambuddy global tray IDs. Insert as priority 2 in the mapping
resolution chain, after print_cmd ams_mapping but before queue/tray_now
fallbacks. Works for all printer models and print sources.
maziggy 3 месяцев назад
Родитель
Сommit
713a3e931a
3 измененных файлов с 252 добавлено и 9 удалено
  1. 3 0
      CHANGELOG.md
  2. 65 9
      backend/app/services/usage_tracker.py
  3. 184 0
      backend/tests/unit/test_usage_tracker.py

+ 3 - 0
CHANGELOG.md

@@ -17,6 +17,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
 - **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
 - **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
 - **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
 
 
+- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
+- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
+
 ### Improved
 ### Improved
 - **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
 - **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
 - **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
 - **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.

+ 65 - 9
backend/app/services/usage_tracker.py

@@ -22,6 +22,47 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
+    """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
+
+    The printer's MQTT mapping field is an array indexed by slicer filament slot
+    (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
+    65535 means unmapped.
+
+    Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
+    no valid mappings found.
+    """
+    if not isinstance(mapping_raw, list) or not mapping_raw:
+        return None
+
+    result = []
+    for value in mapping_raw:
+        if not isinstance(value, int) or value >= 65535:
+            result.append(-1)
+            continue
+
+        ams_hw_id = value >> 8
+        slot = value & 0xFF
+
+        if 0 <= ams_hw_id <= 3:
+            # Regular AMS: sequential global ID
+            result.append(ams_hw_id * 4 + (slot & 0x03))
+        elif 128 <= ams_hw_id <= 135:
+            # AMS-HT: global ID is the hardware ID (one slot per unit)
+            result.append(ams_hw_id)
+        elif ams_hw_id in (254, 255):
+            # External spool
+            result.append(254 if slot != 255 else 255)
+        else:
+            result.append(-1)
+
+    # Only return if at least one valid mapping exists
+    if all(v < 0 for v in result):
+        return None
+
+    return result
+
+
 @dataclass
 @dataclass
 class PrintSession:
 class PrintSession:
     printer_id: int
     printer_id: int
@@ -289,9 +330,10 @@ async def _track_from_3mf(
 
 
     Slot-to-tray mapping priority:
     Slot-to-tray mapping priority:
     1. Stored ams_mapping from print command (reprints/direct prints)
     1. Stored ams_mapping from print command (reprints/direct prints)
-    2. Queue item ams_mapping (for queue-initiated prints)
-    3. tray_now from printer state (for single-filament non-queue prints)
-    4. Default mapping: slot_id - 1 = global_tray_id (last resort)
+    2. MQTT mapping field from printer state (universal, all print sources)
+    3. Queue item ams_mapping (for queue-initiated prints)
+    4. tray_now from printer state (for single-filament non-queue prints)
+    5. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
     """
     from backend.app.core.config import settings as app_settings
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
@@ -317,10 +359,25 @@ async def _track_from_3mf(
     logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
     logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
 
 
     # --- Resolve slot-to-tray mapping ---
     # --- Resolve slot-to-tray mapping ---
+    mapping_source = None
+
     # 1. Use stored ams_mapping from the print command (reprints/direct prints)
     # 1. Use stored ams_mapping from the print command (reprints/direct prints)
     slot_to_tray = ams_mapping
     slot_to_tray = ams_mapping
+    if slot_to_tray:
+        mapping_source = "print_cmd"
 
 
-    # 2. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
+    # 2. Try MQTT mapping field from printer state (universal, all print sources)
+    if not slot_to_tray:
+        state = printer_manager.get_status(printer_id)
+        raw_data = getattr(state, "raw_data", None) if state else None
+        if raw_data:
+            mqtt_mapping = raw_data.get("mapping")
+            decoded = _decode_mqtt_mapping(mqtt_mapping)
+            if decoded:
+                slot_to_tray = decoded
+                mapping_source = "mqtt"
+
+    # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
     if not slot_to_tray:
     if not slot_to_tray:
         queue_result = await db.execute(
         queue_result = await db.execute(
             select(PrintQueueItem)
             select(PrintQueueItem)
@@ -331,13 +388,14 @@ async def _track_from_3mf(
         if queue_item and queue_item.ams_mapping:
         if queue_item and queue_item.ams_mapping:
             try:
             try:
                 slot_to_tray = json.loads(queue_item.ams_mapping)
                 slot_to_tray = json.loads(queue_item.ams_mapping)
+                mapping_source = "queue"
             except (json.JSONDecodeError, TypeError):
             except (json.JSONDecodeError, TypeError):
                 pass
                 pass
 
 
     logger.info(
     logger.info(
         "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
         "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
         slot_to_tray,
         slot_to_tray,
-        "print_cmd" if ams_mapping else ("queue" if slot_to_tray else "none"),
+        mapping_source or "none",
     )
     )
 
 
     # 3. For single-filament non-queue prints, use tray_now from printer state
     # 3. For single-filament non-queue prints, use tray_now from printer state
@@ -521,10 +579,8 @@ async def _track_from_3mf(
         # Determine mapping source for debug logging
         # Determine mapping source for debug logging
         if tray_now_override is not None:
         if tray_now_override is not None:
             map_src = ", tray_now"
             map_src = ", tray_now"
-        elif slot_to_tray and ams_mapping:
-            map_src = ", print_cmd_map"
-        elif slot_to_tray:
-            map_src = ", queue_map"
+        elif mapping_source:
+            map_src = f", {mapping_source}_map"
         else:
         else:
             map_src = ""
             map_src = ""
         logger.info(
         logger.info(

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

@@ -14,6 +14,7 @@ import pytest
 from backend.app.services.usage_tracker import (
 from backend.app.services.usage_tracker import (
     PrintSession,
     PrintSession,
     _active_sessions,
     _active_sessions,
+    _decode_mqtt_mapping,
     _track_from_3mf,
     _track_from_3mf,
     on_print_complete,
     on_print_complete,
     on_print_start,
     on_print_start,
@@ -839,6 +840,189 @@ class TestTrackFrom3mf:
         assert results[0]["tray_id"] == 1
         assert results[0]["tray_id"] == 1
 
 
 
 
+class TestDecodeMqttMapping:
+    """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
+
+    def test_none_input(self):
+        assert _decode_mqtt_mapping(None) is None
+
+    def test_empty_list(self):
+        assert _decode_mqtt_mapping([]) is None
+
+    def test_all_unmapped(self):
+        """All 65535 values → None (no valid mappings)."""
+        assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
+
+    def test_single_ams_slots(self):
+        """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
+        assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
+
+    def test_multi_ams_slots(self):
+        """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
+        assert _decode_mqtt_mapping([256, 257]) == [4, 5]
+
+    def test_ams_ht_slot(self):
+        """AMS-HT (hw_id=128): snow 32768 → global 128."""
+        assert _decode_mqtt_mapping([32768]) == [128]
+
+    def test_external_spool(self):
+        """External spool: ams_hw_id=254, slot=0 → global 254."""
+        # snow = 254 * 256 + 0 = 65024
+        assert _decode_mqtt_mapping([65024]) == [254]
+
+    def test_mixed_with_unmapped(self):
+        """Mix of valid and unmapped (65535) values."""
+        result = _decode_mqtt_mapping([1, 65535, 0])
+        assert result == [1, -1, 0]
+
+    def test_h2c_real_mapping(self):
+        """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
+        mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
+        result = _decode_mqtt_mapping(mapping)
+        assert result == [1, 0, -1, -1, -1, -1, 128]
+
+    def test_non_int_values_treated_as_unmapped(self):
+        """Non-integer values in the mapping are treated as unmapped."""
+        assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
+
+
+class TestMqttMappingIntegration:
+    """Integration tests: MQTT mapping field used in _track_from_3mf."""
+
+    @pytest.mark.asyncio
+    async def test_h2c_multi_filament_uses_mqtt_mapping(self):
+        """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
+        # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
+        spool_white = _make_spool(spool_id=1, label_weight=1000)
+        spool_black = _make_spool(spool_id=2, label_weight=1000)
+        spool_red = _make_spool(spool_id=3, label_weight=1000)
+        assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
+        assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
+        archive = _make_archive(archive_id=12)
+
+        # db: archive, then 3 pairs of (assignment, spool)
+        # No queue lookup because MQTT mapping is found first
+        db = _mock_db_sequential(
+            [
+                archive,
+                assign_white,
+                spool_white,
+                assign_black,
+                spool_black,
+                assign_red,
+                spool_red,
+            ]
+        )
+
+        # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
+        filament_usage = [
+            {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
+            {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
+            {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
+        ]
+        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=12,
+                status="completed",
+                print_name="Cube + Cube + Cube",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 3
+
+        # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
+        assert results[0]["spool_id"] == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 1
+        assert results[0]["weight_used"] == 21.2
+
+        # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
+        assert results[1]["spool_id"] == 2
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 0
+        assert results[1]["weight_used"] == 24.2
+
+        # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
+        assert results[2]["spool_id"] == 3
+        assert results[2]["ams_id"] == 128
+        assert results[2]["tray_id"] == 0
+        assert results[2]["weight_used"] == 18.5
+
+    @pytest.mark.asyncio
+    async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
+        """ams_mapping from print command is used even when MQTT mapping exists."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
+        db = _mock_db_sequential([archive, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [0, 65535]},  # MQTT says slot 0 → AMS0-T0
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "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=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                ams_mapping=[2],  # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 2  # From print_cmd mapping, not MQTT
+
+
 class TestNotificationVariables:
 class TestNotificationVariables:
     """Tests for filament_details formatting in notifications."""
     """Tests for filament_details formatting in notifications."""