diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a95c252..7ab2c3f31 100644 --- a/CHANGELOG.md +++ b/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. - **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 - **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. diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 12fb498c5..335e8d60b 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -22,6 +22,47 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory 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 class PrintSession: printer_id: int @@ -289,9 +330,10 @@ async def _track_from_3mf( Slot-to-tray mapping priority: 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.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) # --- Resolve slot-to-tray mapping --- + mapping_source = None + # 1. Use stored ams_mapping from the print command (reprints/direct prints) 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: queue_result = await db.execute( select(PrintQueueItem) @@ -331,13 +388,14 @@ async def _track_from_3mf( if queue_item and queue_item.ams_mapping: try: slot_to_tray = json.loads(queue_item.ams_mapping) + mapping_source = "queue" except (json.JSONDecodeError, TypeError): pass logger.info( "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)", 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 @@ -521,10 +579,8 @@ async def _track_from_3mf( # Determine mapping source for debug logging if tray_now_override is not None: 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: map_src = "" logger.info( diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py index b246656cf..1d7a72019 100644 --- a/backend/tests/unit/test_usage_tracker.py +++ b/backend/tests/unit/test_usage_tracker.py @@ -14,6 +14,7 @@ import pytest from backend.app.services.usage_tracker import ( PrintSession, _active_sessions, + _decode_mqtt_mapping, _track_from_3mf, on_print_complete, on_print_start, @@ -839,6 +840,189 @@ class TestTrackFrom3mf: 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: """Tests for filament_details formatting in notifications."""