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.
This commit is contained in:
maziggy
2026-02-18 11:24:22 +01:00
parent 60b5ff2363
commit 713a3e931a
3 changed files with 252 additions and 9 deletions
+3
View File
@@ -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.
+65 -9
View File
@@ -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(
+184
View File
@@ -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."""