mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user