Browse Source

Merge branch 'feature/filament-fix' of https://github.com/Keybored02/bambuddy into feature/filament-fix

Matteo Parenti 3 months ago
parent
commit
a649a246b2

+ 21 - 1
CHANGELOG.md

@@ -2,7 +2,27 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-## [0.2.1] - Not released
+## [0.2.1b] - Not released
+
+### Fixed
+- **Nozzle Mapping Uses Wrong Source in 3MF Files** — The `extract_nozzle_mapping_from_3mf()` function used `filament_nozzle_map` (user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in the `group_id` attribute on `<filament>` elements in `slice_info.config`. Now uses `group_id` as the primary source and falls back to `filament_nozzle_map` only when `group_id` is not present.
+- **Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle** — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.
+- **Print Scheduler External Spool Ignores Nozzle Assignment** — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.
+- **ams_extruder_map Race Condition on Printer Status API** — The `/printers/{id}/status` endpoint read `ams_extruder_map` from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.
+- **Filament Mapping Frontend Ignores Nozzle for External Spools** — The `useFilamentMapping` hook always set `extruder_id: 0` for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.
+- **AMS-HT Global Tray ID Computed Wrong on Printer Card** — The PrintersPage computed AMS-HT tray IDs using `ams_id * 4 + slot` (giving 512+), but AMS-HT units use their raw `ams_id` (128-135) as the global tray ID. Now uses `ams_id` directly for AMS-HT units.
+- **Filament Mapping Dropdown Shows Wrong Nozzle Trays** — The FilamentMapping dropdown filtered by `extruder_id` using strict equality, but `extruder_id` could be `undefined` for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when `extruder_id` is undefined.
+- **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.
+- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
+- **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.
 
 
 ## [0.2.0] - 2026-02-17

+ 3 - 2
backend/app/api/routes/printers.py

@@ -402,8 +402,9 @@ async def get_printer_status(
 
     # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
     ams_mapping = raw_data.get("ams_mapping", [])
-    # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
-    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+    # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
+    # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
+    ams_extruder_map = state.ams_extruder_map or {}
     logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
 
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id

+ 30 - 3
backend/app/services/bambu_mqtt.py

@@ -279,6 +279,9 @@ class BambuMQTTClient:
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
+        self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
+        self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
+        self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
@@ -530,6 +533,16 @@ class BambuMQTTClient:
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
 
+            # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
+            # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
+            if not self._is_dual_nozzle and "device" in print_data:
+                dev = print_data.get("device")
+                if isinstance(dev, dict):
+                    ext_info = dev.get("extruder", {}).get("info", [])
+                    if isinstance(ext_info, list) and len(ext_info) >= 2:
+                        self._is_dual_nozzle = True
+                        logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
+
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
                 try:
@@ -912,7 +925,9 @@ class BambuMQTTClient:
 
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
                 # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
-                if parsed_tray_now >= 0 and parsed_tray_now <= 3:
+                # Single-nozzle printers (X1C, P2S, etc.) always report global IDs, even with multiple AMS
+                ams_map = self.state.ams_extruder_map
+                if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     if pending_target is not None:
@@ -945,7 +960,8 @@ class BambuMQTTClient:
                         if snow_tray is not None and snow_tray != 255:
                             # snow_tray is already normalized to global ID
                             # Verify the slot matches what we see in tray_now
-                            snow_slot = snow_tray % 4 if snow_tray < 128 else -1
+                            # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0
+                            snow_slot = snow_tray % 4 if snow_tray < 128 else (0 if snow_tray <= 135 else -1)
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
                                     logger.debug(
@@ -962,7 +978,6 @@ class BambuMQTTClient:
                                 self.state.tray_now = snow_tray
                         else:
                             # Fallback: snow not available, use ams_extruder_map (less reliable)
-                            ams_map = self.state.ams_extruder_map
                             # Find ALL AMS units on the active extruder
                             ams_on_extruder = []
                             for ams_id_str, ext_id in ams_map.items():
@@ -1211,6 +1226,9 @@ class BambuMQTTClient:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
+            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
+            if self.state.progress > 0:
+                self._last_valid_progress = self.state.progress
             self.state.progress = float(data["mc_percent"])
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
@@ -1225,6 +1243,9 @@ class BambuMQTTClient:
         if "layer_num" in data:
             new_layer = int(data["layer_num"])
             old_layer = self.state.layer_num
+            # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
+            if old_layer > 0:
+                self._last_valid_layer_num = old_layer
             self.state.layer_num = new_layer
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
@@ -1984,6 +2005,9 @@ class BambuMQTTClient:
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False
+            # Reset last valid progress/layer for usage tracking
+            self._last_valid_progress = 0.0
+            self._last_valid_layer_num = 0
             # Initialize timelapse tracking based on current state
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # so self.state.timelapse may already be set from this message.
@@ -2067,6 +2091,9 @@ class BambuMQTTClient:
                     "timelapse_was_active": timelapse_was_active,
                     "hms_errors": hms_errors_data,
                     "ams_mapping": self._captured_ams_mapping,
+                    # Last valid progress/layer before firmware reset (for partial usage tracking)
+                    "last_progress": self._last_valid_progress,
+                    "last_layer_num": self._last_valid_layer_num,
                 }
             )
             self._captured_ams_mapping = None

+ 5 - 5
backend/app/services/print_scheduler.py

@@ -558,7 +558,7 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_external": True,
                         "global_tray_id": tray_id,
-                        "extruder_id": (tray_id - 254) if ams_extruder_map else None,
+                        "extruder_id": (255 - tray_id) if ams_extruder_map else None,
                     }
                 )
 
@@ -634,12 +634,12 @@ class PrintScheduler:
             # Get available trays (not already used)
             available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
 
-            # Nozzle-aware filtering: restrict to trays on the correct nozzle
+            # Nozzle-aware filtering: restrict to trays on the correct nozzle.
+            # Hard filter — cross-nozzle assignment causes print failures
+            # ("position of left hotend is abnormal"), so never fall back.
             req_nozzle_id = req.get("nozzle_id")
             if req_nozzle_id is not None:
-                nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id]
-                if nozzle_filtered:
-                    available = nozzle_filtered
+                available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:

+ 77 - 9
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
@@ -167,6 +208,8 @@ async def on_print_complete(
             db,
             ams_mapping=ams_mapping,
             tray_now_at_start=session.tray_now_at_start if session else -1,
+            last_progress=data.get("last_progress", 0.0),
+            last_layer_num=data.get("last_layer_num", 0),
         )
         results.extend(threemf_results)
 
@@ -276,6 +319,8 @@ async def _track_from_3mf(
     db: AsyncSession,
     ams_mapping: list[int] | None = None,
     tray_now_at_start: int = -1,
+    last_progress: float = 0.0,
+    last_layer_num: int = 0,
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
 
@@ -285,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
@@ -313,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)
@@ -327,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
@@ -374,6 +436,10 @@ async def _track_from_3mf(
     else:
         state = printer_manager.get_status(printer_id)
         progress = state.progress if state else 0
+        # Firmware resets progress to 0 on cancel — use last valid progress captured during print
+        if progress <= 0 and last_progress > 0:
+            progress = last_progress
+            logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
         scale = max(0.0, min(progress / 100.0, 1.0))
 
     # Per-layer gcode accuracy for partial prints
@@ -381,6 +447,10 @@ async def _track_from_3mf(
     if status != "completed":
         state = printer_manager.get_status(printer_id)
         current_layer = state.layer_num if state else 0
+        # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
+        if current_layer <= 0 and last_layer_num > 0:
+            current_layer = last_layer_num
+            logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
         if current_layer > 0:
             try:
                 from backend.app.utils.threemf_tools import (
@@ -509,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(

+ 40 - 22
backend/app/utils/threemf_tools.py

@@ -265,15 +265,18 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
 
 
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
-    """Extract per-slot nozzle/extruder mapping from a 3MF file's project settings.
+    """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
     On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
-    specific nozzle. This reads the slicer's nozzle assignment from
-    Metadata/project_settings.config.
+    specific nozzle. The slicer may override user preferences when using "Auto For
+    Flush" mode, so the actual assignment comes from slice_info.config group_id
+    attributes, not from the user's filament_nozzle_map preference.
 
-    Translation chain:
-        filament_nozzle_map[slot_id - 1] -> slicer extruder index
-        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+    Priority:
+        1. group_id on <filament> elements in slice_info.config (actual assignment)
+        2. filament_nozzle_map in project_settings.config (user preference fallback)
+
+    Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
 
     Args:
         zf: An open ZipFile of the 3MF archive
@@ -289,33 +292,48 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         content = zf.read("Metadata/project_settings.config").decode()
         data = json.loads(content)
 
-        filament_nozzle_map = data.get("filament_nozzle_map")
         physical_extruder_map = data.get("physical_extruder_map")
+        if not physical_extruder_map or len(physical_extruder_map) <= 1:
+            return None  # Single-nozzle printer
 
-        if not filament_nozzle_map or not physical_extruder_map:
+        # Priority 1: Use group_id from slice_info filament elements.
+        # This reflects the actual slicer assignment (respects "Auto For Flush").
+        nozzle_mapping: dict[int, int] = {}
+        if "Metadata/slice_info.config" in zf.namelist():
+            si_content = zf.read("Metadata/slice_info.config").decode()
+            si_root = ET.fromstring(si_content)
+            for filament_elem in si_root.findall(".//filament"):
+                group_id_str = filament_elem.get("group_id")
+                filament_id_str = filament_elem.get("id")
+                if group_id_str is not None and filament_id_str:
+                    try:
+                        group_id = int(group_id_str)
+                        slot_id = int(filament_id_str)
+                        if group_id < len(physical_extruder_map):
+                            nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
+                    except (ValueError, TypeError, IndexError):
+                        pass
+
+        if nozzle_mapping:
+            return nozzle_mapping
+
+        # Priority 2: Fall back to filament_nozzle_map (user preference).
+        # This is correct when the user manually assigned nozzles, but may be
+        # wrong when the slicer overrides via "Auto For Flush".
+        filament_nozzle_map = data.get("filament_nozzle_map")
+        if not filament_nozzle_map:
             return None
 
-        # Build slot_id (1-based) -> extruder_id mapping
-        nozzle_mapping: dict[int, int] = {}
         for i, slicer_ext_str in enumerate(filament_nozzle_map):
             slot_id = i + 1
             try:
                 slicer_ext = int(slicer_ext_str)
                 if slicer_ext < len(physical_extruder_map):
-                    extruder_id = int(physical_extruder_map[slicer_ext])
-                    nozzle_mapping[slot_id] = extruder_id
+                    nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
             except (ValueError, TypeError, IndexError):
-                pass  # Skip slots with unparseable nozzle mapping
-
-        if not nozzle_mapping:
-            return None
-
-        # If all slots map to the same extruder, this is a single-nozzle printer
-        unique_extruders = set(nozzle_mapping.values())
-        if len(unique_extruders) <= 1:
-            return None
+                pass
 
-        return nozzle_mapping
+        return nozzle_mapping if nozzle_mapping else None
     except Exception:
         return None
 

+ 572 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1205,3 +1205,575 @@ class TestRequestTopicAmsMapping:
         assert complete_data["status"] == "completed"
         # Mapping cleared after completion
         assert mqtt_client._captured_ams_mapping is None
+
+
+# ---------------------------------------------------------------------------
+# tray_now disambiguation helpers
+# ---------------------------------------------------------------------------
+
+
+def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None):
+    """Build minimal print.ams payload for tray_now disambiguation tests."""
+    ams = {"tray_now": str(tray_now)}
+    if ams_units is not None:
+        ams["ams"] = ams_units
+    if tray_exist_bits is not None:
+        ams["tray_exist_bits"] = tray_exist_bits
+    return {"print": {"ams": ams}}
+
+
+def _extruder_info_payload(extruders):
+    """Build device.extruder.info payload (dual-nozzle detection + snow).
+
+    Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
+    """
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "info": extruders,
+                }
+            }
+        }
+    }
+
+
+def _extruder_state_payload(state_val):
+    """Build device.extruder.state payload (active extruder via bit 8)."""
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "state": state_val,
+                }
+            }
+        }
+    }
+
+
+# ---------------------------------------------------------------------------
+# 1. Single-nozzle X1E — direct passthrough
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleX1E:
+    """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_X1E",
+            access_code="12345678",
+        )
+
+    def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
+        """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
+        for slot in range(4):
+            mqtt_client._process_message(_ams_payload(slot))
+            assert mqtt_client.state.tray_now == slot
+
+    def test_tray_now_255_means_unloaded(self, mqtt_client):
+        """tray_now=255 means no filament loaded."""
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+
+    def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
+        """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
+        mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
+        assert mqtt_client._is_dual_nozzle is False
+
+    def test_last_loaded_tray_survives_unload(self, mqtt_client):
+        """Load tray 2, unload → last_loaded_tray stays 2."""
+        mqtt_client._process_message(_ams_payload(2))
+        assert mqtt_client.state.last_loaded_tray == 2
+
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+        assert mqtt_client.state.last_loaded_tray == 2
+
+
+# ---------------------------------------------------------------------------
+# 2. Single-nozzle P2S — multiple AMS, global IDs pass through
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleP2S:
+    """Single-nozzle, 2 AMS — global IDs 4-7 for AMS 1 pass through directly."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_P2S",
+            access_code="12345678",
+        )
+
+    def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
+        """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
+        for global_id in range(4, 8):
+            mqtt_client._process_message(_ams_payload(global_id))
+            assert mqtt_client.state.tray_now == global_id
+
+    def test_tray_change_across_ams_units(self, mqtt_client):
+        """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
+        mqtt_client._process_message(_ams_payload(1))
+        assert mqtt_client.state.tray_now == 1
+
+        mqtt_client._process_message(_ams_payload(6))
+        assert mqtt_client.state.tray_now == 6
+
+
+# ---------------------------------------------------------------------------
+# 3. H2D Pro — initial state detection
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSetup:
+    """H2D Pro initial state detection."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
+        """2 entries in device.extruder.info → _is_dual_nozzle=True."""
+        mqtt_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert mqtt_client._is_dual_nozzle is True
+
+    def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
+        """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
+        ams_units = [
+            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
+        # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
+        """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
+
+        If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
+        """
+        payload = {
+            "print": {
+                "device": {
+                    "extruder": {
+                        "info": [
+                            {"id": 0, "snow": 0xFF00FF},
+                            {"id": 1, "snow": 0xFF00FF},
+                        ],
+                        "state": 0x0001,
+                    }
+                },
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                    ],
+                    "tray_now": "2",
+                    "tray_exist_bits": "f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
+        # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
+        # Single AMS on extruder 0 → global_id = 0*4+2 = 2
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# Shared H2D fixture for classes 4-8
+# ---------------------------------------------------------------------------
+
+
+class _H2DFixtureMixin:
+    """Mixin providing a pre-configured H2D Pro client."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    @pytest.fixture
+    def h2d_client(self, mqtt_client):
+        """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "device": {
+                        "extruder": {
+                            "info": [
+                                {"id": 0, "snow": 0xFF00FF},
+                                {"id": 1, "snow": 0xFF00FF},
+                            ],
+                            "state": 0x0001,  # right extruder active
+                        }
+                    },
+                    "ams": {
+                        "ams": [
+                            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+                        ],
+                        "tray_now": "255",
+                        "tray_exist_bits": "1000f",
+                    },
+                }
+            }
+        )
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+        return mqtt_client
+
+
+# ---------------------------------------------------------------------------
+# 4. H2D Snow field disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
+    """Snow field disambiguation (primary path)."""
+
+    def test_snow_disambiguates_ams0_slot(self, h2d_client):
+        """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
+        # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
+        # so we need it in a prior message).
+        snow_val = 0 << 8 | 2  # AMS 0 slot 2 = raw 2
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 2
+
+        # Now send tray_now=2
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
+        """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
+        # Snow: extruder 1 → AMS 128 slot 0
+        snow_val = 128 << 8 | 0  # = 32768
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": snow_val},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(1) == 128
+
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        # tray_now="0" with left extruder active, snow says AMS HT (128)
+        # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
+        """Verify state.h2d_extruder_snow dict is populated correctly."""
+        snow_ext0 = 1 << 8 | 3  # AMS 1 slot 3 → global 7
+        snow_ext1 = 0 << 8 | 0  # AMS 0 slot 0 → global 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_ext0},
+                    {"id": 1, "snow": snow_ext1},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 7
+        assert h2d_client.state.h2d_extruder_snow[1] == 0
+
+    def test_snow_unloaded_value(self, h2d_client):
+        """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFFFF},
+                    {"id": 1, "snow": 0xFFFF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 255
+        assert h2d_client.state.h2d_extruder_snow[1] == 255
+
+    def test_snow_initial_sentinel_not_stored(self, h2d_client):
+        """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
+        # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Snow dict should remain empty (no matching branch)
+        assert h2d_client.state.h2d_extruder_snow == {}
+
+
+# ---------------------------------------------------------------------------
+# 5. H2D Pending target disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
+    """Pending target disambiguation (when Bambuddy initiates load)."""
+
+    def test_pending_target_matches_slot(self, h2d_client):
+        """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+        assert h2d_client.state.pending_tray_target is None  # cleared
+
+    def test_pending_target_slot_mismatch(self, h2d_client):
+        """pending=5, tray_now='2' → uses raw slot, clears pending."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(2))
+        # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
+        assert h2d_client.state.tray_now == 2
+        assert h2d_client.state.pending_tray_target is None
+
+    def test_pending_target_takes_priority_over_snow(self, h2d_client):
+        """When both pending and snow are set, pending wins."""
+        # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
+        snow_val = 0 << 8 | 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 1
+
+        # Set pending target to AMS 1 slot 1 (global 5)
+        h2d_client.state.pending_tray_target = 5
+        # tray_now="1" — matches pending (5%4=1), pending should win over snow
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+
+# ---------------------------------------------------------------------------
+# 6. H2D ams_extruder_map fallback
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
+    """ams_extruder_map fallback (no pending, no snow)."""
+
+    def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
+        """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
+        # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
+        h2d_client._process_message(_ams_payload(2))
+        # AMS 0 is the only AMS on extruder 0 (right, active by default)
+        # Fallback: single AMS → global = 0*4+2 = 2
+        assert h2d_client.state.tray_now == 2
+
+    def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
+        """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
+        # Set up: two AMS units on the same extruder (right, ext 0)
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
+        # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
+        h2d_client.state.tray_now = 5
+        # tray_now="1" → 5%4=1 matches → keep current=5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+    def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
+        """No AMS mapped to the active extruder → raw slot as global ID."""
+        # All AMS on left extruder, but right is active
+        h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# 7. H2D Active extruder switching
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
+    """Active extruder switching via device.extruder.state bit 8."""
+
+    def test_active_extruder_right_by_default(self, h2d_client):
+        """Initial state.active_extruder == 0 (right)."""
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_state_bit8_switches_to_left(self, h2d_client):
+        """state=0x100 → active_extruder=1 (left)."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+    def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
+        """Cycle 0 → 1 → 0."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
+        """Snow on both extruders; switching active changes which snow is used."""
+        # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},  # AMS 0 slot 1 → global 1
+                    {"id": 1, "snow": 128 << 8 | 0},  # AMS HT → global 128
+                ]
+            )
+        )
+
+        # Right active (default) — tray_now="1" → snow ext[0] says global 1
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+
+        # Switch to left
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+
+# ---------------------------------------------------------------------------
+# 8. H2D Full multi-message sequences
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
+    """Multi-message sequences simulating real H2D Pro prints."""
+
+    def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
+        """Setup → load AMS 0 slot 1 → verify tray_now=1."""
+        # Snow update: extruder 0 loading AMS 0 slot 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Printer reports tray_now="1"
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+        assert h2d_client.state.last_loaded_tray == 1
+
+    def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
+        """Setup → switch left → load AMS HT → verify tray_now=128."""
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Snow: ext 1 → AMS HT slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+
+        # Printer reports tray_now="0" (AMS HT single slot)
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+        assert h2d_client.state.last_loaded_tray == 128
+
+    def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
+        """Multi-color print alternating between right and left nozzles.
+
+        Sequence:
+        1. Right loads AMS 0 slot 0 (tray=0)
+        2. Switch left, load AMS HT (tray=128)
+        3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
+        4. Unload (255)
+        """
+        # Step 1: Right extruder loads AMS 0 slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 0
+
+        # Step 2: Switch to left, load AMS HT
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+        # Step 3: Switch back to right, load AMS 0 slot 2
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 2},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+        # Step 4: Unload
+        h2d_client._process_message(_ams_payload(255))
+        assert h2d_client.state.tray_now == 255
+        assert h2d_client.state.last_loaded_tray == 2

+ 363 - 11
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -474,12 +474,17 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         assert result[0]["is_external"] is True
 
 
-def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
+def _make_3mf_zip(
+    project_settings: dict | None = None,
+    slice_info_xml: str | None = None,
+) -> zipfile.ZipFile:
     """Create an in-memory ZipFile mimicking a 3MF with project_settings.config."""
     buf = io.BytesIO()
     with zipfile.ZipFile(buf, "w") as zf:
         if project_settings is not None:
             zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+        if slice_info_xml is not None:
+            zf.writestr("Metadata/slice_info.config", slice_info_xml)
     buf.seek(0)
     return zipfile.ZipFile(buf, "r")
 
@@ -487,8 +492,55 @@ def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
 class TestExtractNozzleMappingFrom3mf:
     """Test the extract_nozzle_mapping_from_3mf utility."""
 
-    def test_dual_nozzle_mapping(self):
-        """Should return slot->extruder mapping for dual-nozzle files."""
+    def test_group_id_priority_over_filament_nozzle_map(self):
+        """group_id from slice_info should override filament_nozzle_map from project_settings.
+
+        Real-world scenario: "Auto For Flush" mode sets filament_nozzle_map all to 0
+        (user preference) but the actual assignment in slice_info has different group_ids.
+        """
+        # filament_nozzle_map says all on slicer ext 0 → MQTT ext 1 (LEFT)
+        # But slice_info group_id says slot 6 → group 0 (LEFT), slot 12 → group 1 (RIGHT)
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="6" type="PLA" color="#56B7E6" used_g="1.84" group_id="0"/>
+            <filament id="12" type="PLA" color="#B39B84" used_g="1.76" group_id="1"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0"] * 12,
+                "physical_extruder_map": ["1", "0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)
+        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
+        assert result == {6: 1, 12: 0}
+        zf.close()
+
+    def test_fallback_to_filament_nozzle_map_without_group_id(self):
+        """Should fall back to filament_nozzle_map when slice_info has no group_id."""
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="PLA" color="#FF0000" used_g="5.0"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1", "0"],
+                "physical_extruder_map": ["0", "1"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 0, 2: 1, 3: 0}
+        zf.close()
+
+    def test_fallback_to_filament_nozzle_map_without_slice_info(self):
+        """Should fall back to filament_nozzle_map when no slice_info.config exists."""
         zf = _make_3mf_zip(
             {
                 "filament_nozzle_map": ["0", "1", "0"],
@@ -500,7 +552,7 @@ class TestExtractNozzleMappingFrom3mf:
         zf.close()
 
     def test_single_nozzle_returns_none(self):
-        """All slots on same extruder should return None (single-nozzle)."""
+        """Single physical_extruder_map entry should return None (single-nozzle)."""
         zf = _make_3mf_zip(
             {
                 "filament_nozzle_map": ["0", "0", "0"],
@@ -519,7 +571,7 @@ class TestExtractNozzleMappingFrom3mf:
         zf.close()
 
     def test_missing_fields_returns_none(self):
-        """Missing filament_nozzle_map or physical_extruder_map should return None."""
+        """Missing physical_extruder_map should return None."""
         zf = _make_3mf_zip({"some_other_key": "value"})
         result = extract_nozzle_mapping_from_3mf(zf)
         assert result is None
@@ -562,8 +614,8 @@ class TestNozzleAwareMapping:
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [0, 4]
 
-    def test_nozzle_fallback_when_no_match(self, scheduler):
-        """Should fall back to unfiltered list when nozzle-filtered list is empty."""
+    def test_nozzle_hard_filter_no_fallback(self, scheduler):
+        """Hard filter: no fallback to wrong nozzle when target nozzle has no trays."""
         required = [
             {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
         ]
@@ -571,9 +623,9 @@ class TestNozzleAwareMapping:
             # Only a tray on the left nozzle, none on right
             {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
         ]
-        # No trays on extruder 0, so fallback to unfiltered -> should still match
+        # No trays on extruder 0 — hard filter returns -1, no cross-nozzle fallback
         result = scheduler._match_filaments_to_slots(required, loaded)
-        assert result == [4]
+        assert result == [-1]
 
     def test_no_nozzle_id_skips_filtering(self, scheduler):
         """When nozzle_id is None, no nozzle filtering should be applied."""
@@ -620,7 +672,7 @@ class TestNozzleAwareMapping:
         assert result[0]["extruder_id"] is None
 
     def test_external_spool_extruder_id(self, scheduler):
-        """External spool should have extruder_id=0 when ams_extruder_map exists."""
+        """External spool 254 (Ext-L) should have extruder_id=1 (LEFT) when ams_extruder_map exists."""
 
         class MockStatus:
             raw_data = {
@@ -630,7 +682,8 @@ class TestNozzleAwareMapping:
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
-        assert result[0]["extruder_id"] == 0
+        # Default vt_tray id=254 → Ext-L → LEFT nozzle (extruder 1)
+        assert result[0]["extruder_id"] == 1
         assert result[0]["is_external"] is True
 
     def test_external_spool_no_extruder_map(self, scheduler):
@@ -655,3 +708,302 @@ class TestNozzleAwareMapping:
         ]
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [0, 4]
+
+
+# ============================================================================
+# MODEL-SPECIFIC TESTS: Real data from actual printers
+# ============================================================================
+
+
+def _h2d_raw_data():
+    """H2D real data fixture (from live API response 2026-02-18).
+
+    Configuration:
+        LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
+        RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
+        External: 254 (Ext-L, LEFT), 255 (Ext-R, RIGHT, empty)
+
+    ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
+    """
+    return {
+        "ams": [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0, "tray_type": "PETG", "tray_color": "FFFFFFFF", "tray_info_idx": "GFG02"},
+                    {"id": 1, "tray_type": "PLA", "tray_color": "C8C8C8FF", "tray_info_idx": "GFA06"},
+                    {"id": 2, "tray_type": "PETG", "tray_color": "875718FF", "tray_info_idx": "GFG02"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFA00"},
+                ],
+            },
+            {
+                "id": 1,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                    {"id": 1, "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG02"},
+                    {"id": 2, "tray_type": "PLA", "tray_color": "5F6367FF", "tray_info_idx": "GFA06"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "B39B84FF", "tray_info_idx": "GFA02"},
+                ],
+            },
+            {
+                "id": 128,
+                "tray": [{"id": 0}],  # AMS-HT, empty
+            },
+            {
+                "id": 2,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA-S", "tray_color": "FFFFFFFF", "tray_info_idx": "P8aa1726"},
+                    {"id": 1, "tray_type": "PLA", "tray_color": "56B7E6FF", "tray_info_idx": "PFUS9924"},
+                    {"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "tray_info_idx": "GFG02"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "PFUS9ac9"},
+                ],
+            },
+        ],
+        "vt_tray": [
+            {"id": 254, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "P4d64437"},
+            {"id": 255, "tray_type": "", "tray_color": "00000000"},  # empty
+        ],
+        "ams_extruder_map": {"0": 1, "1": 0, "2": 1, "128": 0},
+    }
+
+
+def _x1c_raw_data():
+    """X1C real data fixture (from live API response 2026-02-18).
+
+    Configuration:
+        Single nozzle (extruder 0): AMS 0 (4-slot, all empty), AMS 1 (4-slot, 3 loaded)
+        External: 254 (single, empty)
+
+    ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
+    """
+    return {
+        "ams": [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0},  # empty
+                    {"id": 1},  # empty
+                    {"id": 2},  # empty
+                    {"id": 3},  # empty
+                ],
+            },
+            {
+                "id": 1,
+                "tray": [
+                    {"id": 0},  # empty
+                    {"id": 1, "tray_type": "PLA", "tray_color": "EBCFA6FF", "tray_info_idx": "PFUS22b2"},
+                    {"id": 2, "tray_type": "PLA", "tray_color": "FCECD6FF", "tray_info_idx": "P4d64437"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "0066FFFF", "tray_info_idx": "P4d64437"},
+                ],
+            },
+        ],
+        "vt_tray": [
+            {"id": 254, "tray_type": "", "tray_color": "00000000"},  # empty
+        ],
+        "ams_extruder_map": {"0": 0, "1": 0},
+    }
+
+
+class TestH2DModel:
+    """H2D-specific tests with real printer data (dual nozzle, AMS-HT)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_h2d(self, scheduler):
+        """H2D: correct extruder_id, global_tray_id, AMS-HT handling."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+
+        # Should have 13 loaded filaments (4 + 4 + 0 + 4 + 1 external)
+        assert len(result) == 13
+
+        # AMS 0 trays → extruder 1 (LEFT)
+        ams0 = [f for f in result if f["ams_id"] == 0]
+        assert len(ams0) == 4
+        assert all(f["extruder_id"] == 1 for f in ams0)
+        assert [f["global_tray_id"] for f in ams0] == [0, 1, 2, 3]
+
+        # AMS 1 trays → extruder 0 (RIGHT)
+        ams1 = [f for f in result if f["ams_id"] == 1]
+        assert len(ams1) == 4
+        assert all(f["extruder_id"] == 0 for f in ams1)
+        assert [f["global_tray_id"] for f in ams1] == [4, 5, 6, 7]
+
+        # AMS-HT 128 → empty, should not appear
+        ams_ht = [f for f in result if f["ams_id"] == 128]
+        assert len(ams_ht) == 0
+
+        # AMS 2 trays → extruder 1 (LEFT)
+        ams2 = [f for f in result if f["ams_id"] == 2]
+        assert len(ams2) == 4
+        assert all(f["extruder_id"] == 1 for f in ams2)
+        assert [f["global_tray_id"] for f in ams2] == [8, 9, 10, 11]
+
+    def test_external_spool_extruder_h2d(self, scheduler):
+        """H2D: Ext-L (254) = LEFT (extruder 1), Ext-R (255) = RIGHT (extruder 0)."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        ext = [f for f in result if f["is_external"]]
+        assert len(ext) == 1  # Only 254 has filament
+        assert ext[0]["global_tray_id"] == 254
+        # Ext-L (254) should be LEFT nozzle (extruder 1)
+        assert ext[0]["extruder_id"] == 1
+
+    def test_match_left_nozzle_only(self, scheduler):
+        """H2D: left-nozzle requirement only matches left-nozzle AMS."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1},  # LEFT
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Black PLA on LEFT: AMS 0 T4 (global 3)
+        assert result == [3]
+
+    def test_match_right_nozzle_only(self, scheduler):
+        """H2D: right-nozzle requirement only matches right-nozzle AMS."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0},  # RIGHT
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # White PLA on RIGHT: AMS 1 T1 (global 4)
+        assert result == [4]
+
+    def test_reject_cross_nozzle(self, scheduler):
+        """H2D: hard filter rejects cross-nozzle assignment."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        # PLA-S only exists on AMS 2 T1 (LEFT), require on RIGHT
+        required = [
+            {"slot_id": 1, "type": "PLA-S", "color": "#FFFFFF", "nozzle_id": 0},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [-1]  # No fallback to wrong nozzle
+
+    def test_dual_nozzle_multi_filament(self, scheduler):
+        """H2D: multi-filament print maps to correct nozzles."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PETG", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": "GFG02"},
+            {"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0, "tray_info_idx": "GFA00"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # PETG white on LEFT: AMS 0 T1 (global 0)
+        # PLA white on RIGHT: AMS 1 T1 (global 4)
+        assert result == [0, 4]
+
+    def test_external_spool_matches_on_correct_nozzle(self, scheduler):
+        """H2D: external spool on left nozzle matches left-nozzle requirement."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1, "tray_info_idx": "P4d64437"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [254]  # External spool on left nozzle
+
+
+class TestX1CModel:
+    """X1C-specific tests with real printer data (single nozzle, 2x regular AMS)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_x1c(self, scheduler):
+        """X1C: all filaments on extruder 0, correct global_tray_id."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+
+        # Only 3 loaded (AMS 1 trays 1-3)
+        assert len(result) == 3
+        # All on extruder 0
+        assert all(f["extruder_id"] == 0 for f in result)
+        # Correct global tray IDs
+        assert [f["global_tray_id"] for f in result] == [5, 6, 7]
+
+    def test_single_nozzle_no_filtering(self, scheduler):
+        """X1C: single-nozzle 3MF has no nozzle_id, all trays available."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#0066FF"},  # No nozzle_id
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Blue PLA → AMS 1 T4 (global 7)
+        assert result == [7]
+
+    def test_tray_info_idx_matching_x1c(self, scheduler):
+        """X1C: tray_info_idx matching works across AMS units."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#EBCFA6", "tray_info_idx": "PFUS22b2"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Unique tray_info_idx → AMS 1 T2 (global 5)
+        assert result == [5]
+
+    def test_non_unique_tray_info_idx_color_match_x1c(self, scheduler):
+        """X1C: non-unique tray_info_idx falls back to color matching."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        # P4d64437 appears in AMS 1 T3 and T4
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FCECD6", "tray_info_idx": "P4d64437"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Should pick AMS 1 T3 (global 6, color FCECD6) over T4 (0066FF)
+        assert result == [6]
+
+    def test_multi_filament_x1c(self, scheduler):
+        """X1C: multi-filament print matches freely across AMS units."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#EBCFA6"},
+            {"slot_id": 2, "type": "PLA", "color": "#0066FF"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [5, 7]

+ 184 - 0
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."""
 

+ 1 - 0
frontend/.npmrc

@@ -0,0 +1 @@
+audit-level=high

File diff suppressed because it is too large
+ 146 - 2067
frontend/package-lock.json


+ 0 - 2
frontend/package.json

@@ -32,10 +32,8 @@
     "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-http-backend": "^3.0.2",
-    "install": "^0.13.0",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
-    "npm": "^11.9.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",

+ 306 - 2
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -430,8 +430,9 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
   });
 
-  it('falls back to all trays when target nozzle has no trays at all', () => {
+  it('returns -1 when target nozzle has no trays (hard filter)', () => {
     // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
+    // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
     const reqs = {
       filaments: [
         { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
@@ -447,7 +448,7 @@ describe('computeAmsMapping - nozzle filtering', () => {
 
     const result = computeAmsMapping(reqs, status);
 
-    expect(result).toEqual([0]);  // Falls back to unfiltered (right nozzle PLA)
+    expect(result).toEqual([-1]);  // Hard filter: no fallback to wrong nozzle
   });
 
   it('stays restricted when target nozzle has trays but wrong type', () => {
@@ -526,3 +527,306 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
   });
 });
+
+// ============================================================================
+// MODEL-SPECIFIC TESTS: Real data from actual printers
+// ============================================================================
+
+/**
+ * H2D real data fixture (from live API response 2026-02-18).
+ *
+ * Configuration:
+ *   LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
+ *   RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
+ *   External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
+ *
+ * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
+ */
+function createH2DStatus(): PrinterStatus {
+  const status = createPrinterStatus(
+    [
+      {
+        id: 0, // LEFT nozzle (extruder 1)
+        humidity: 24,
+        temp: 21.4,
+        tray: [
+          { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
+          { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
+        ],
+      },
+      {
+        id: 1, // RIGHT nozzle (extruder 0)
+        humidity: 25,
+        temp: 21.7,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
+          { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
+          { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
+        ],
+      },
+      {
+        id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
+        humidity: 48,
+        temp: 21.4,
+        tray: [
+          { id: 0 }, // empty tray
+        ],
+      },
+      {
+        id: 2, // LEFT nozzle (extruder 1)
+        humidity: 18,
+        temp: 24.0,
+        tray: [
+          { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
+          { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
+          { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
+        ],
+      },
+    ],
+    [
+      { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
+      { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
+    ]
+  );
+  (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
+  return status;
+}
+
+/**
+ * X1C real data fixture (from live API response 2026-02-18).
+ *
+ * Configuration:
+ *   Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
+ *   External: 254 (single)
+ *
+ * ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
+ */
+function createX1CStatus(): PrinterStatus {
+  const status = createPrinterStatus(
+    [
+      {
+        id: 0,
+        humidity: 23,
+        temp: 26.1,
+        tray: [
+          { id: 0 }, // empty (has tray_color but no tray_type)
+          { id: 1 }, // empty
+          { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
+          { id: 3 }, // empty
+        ],
+      },
+      {
+        id: 1,
+        humidity: 20,
+        temp: 25.9,
+        tray: [
+          { id: 0 }, // empty
+          { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
+          { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
+          { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
+        ],
+      },
+    ],
+    [
+      { id: 254, tray_type: '', tray_color: '00000000' }, // empty
+    ]
+  );
+  (status as any).ams_extruder_map = { '0': 0, '1': 0 };
+  return status;
+}
+
+describe('H2D model tests (dual nozzle, real data)', () => {
+  describe('buildLoadedFilaments', () => {
+    it('assigns correct extruderId to all AMS units', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+
+      // AMS 0 trays → extruder 1 (LEFT)
+      const ams0 = result.filter((f) => f.amsId === 0);
+      expect(ams0).toHaveLength(4);
+      ams0.forEach((f) => expect(f.extruderId).toBe(1));
+
+      // AMS 1 trays → extruder 0 (RIGHT)
+      const ams1 = result.filter((f) => f.amsId === 1);
+      expect(ams1).toHaveLength(4);
+      ams1.forEach((f) => expect(f.extruderId).toBe(0));
+
+      // AMS 2 trays → extruder 1 (LEFT)
+      const ams2 = result.filter((f) => f.amsId === 2);
+      expect(ams2).toHaveLength(4);
+      ams2.forEach((f) => expect(f.extruderId).toBe(1));
+    });
+
+    it('computes correct globalTrayId for all AMS types', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+
+      // Regular AMS: amsId * 4 + trayId
+      expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
+      expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
+      expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
+      expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
+    });
+
+    it('skips empty AMS-HT tray (no tray_type)', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      // AMS-HT 128 is empty in real data — should be skipped
+      const ht = result.filter((f) => f.amsId === 128);
+      expect(ht).toHaveLength(0);
+    });
+
+    it('includes loaded external spool with correct extruder', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      const ext = result.filter((f) => f.isExternal);
+      // Only Ext-L (254) has filament, Ext-R (255) is empty
+      expect(ext).toHaveLength(1);
+      expect(ext[0].globalTrayId).toBe(254);
+      expect(ext[0].type).toBe('PLA');
+      // Ext-L (254) should be LEFT nozzle (extruder 1)
+      expect(ext[0].extruderId).toBe(1);
+    });
+
+    it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
+      expect(result).toHaveLength(13);
+    });
+  });
+
+  describe('computeAmsMapping', () => {
+    it('matches left-nozzle filament to left-nozzle AMS only', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
+      expect(result).toEqual([3]);
+    });
+
+    it('matches right-nozzle filament to right-nozzle AMS only', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
+      expect(result).toEqual([4]);
+    });
+
+    it('rejects cross-nozzle assignment (right requires type only on left)', () => {
+      const reqs = {
+        filaments: [
+          // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
+          { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([-1]); // No fallback to wrong nozzle
+    });
+
+    it('maps dual-nozzle multi-filament print correctly', () => {
+      const reqs = {
+        filaments: [
+          // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
+          { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
+          // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
+          { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([0, 4]);
+    });
+
+    it('matches external spool on correct nozzle', () => {
+      const reqs = {
+        filaments: [
+          // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([254]); // External spool on left nozzle
+    });
+  });
+});
+
+describe('X1C model tests (single nozzle, real data)', () => {
+  describe('buildLoadedFilaments', () => {
+    it('assigns all filaments to extruder 0', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      result.forEach((f) => expect(f.extruderId).toBe(0));
+    });
+
+    it('computes correct globalTrayId for regular AMS', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      // AMS 1 T2 (tray id 1) → globalTrayId 5
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
+      // AMS 1 T3 (tray id 2) → globalTrayId 6
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
+      // AMS 1 T4 (tray id 3) → globalTrayId 7
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
+    });
+
+    it('returns only loaded trays (3 from AMS 1)', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
+      expect(result).toHaveLength(3);
+    });
+  });
+
+  describe('computeAmsMapping', () => {
+    it('matches single-nozzle file without nozzle filtering', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
+      expect(result).toEqual([7]);
+    });
+
+    it('matches by tray_info_idx across AMS units', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
+      expect(result).toEqual([5]);
+    });
+
+    it('handles non-unique tray_info_idx with color matching', () => {
+      // P4d64437 appears in both AMS 1 T3 and T4
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
+      expect(result).toEqual([6]);
+    });
+
+    it('does not cross-nozzle filter for single-nozzle printer', () => {
+      // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
+          { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Both should match freely across all AMS units
+      expect(result).toEqual([5, 7]);
+    });
+  });
+});

+ 3 - 1
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -159,7 +159,9 @@ export function FilamentMapping({
                 <option value="" className="bg-bambu-dark text-bambu-gray">
                   -- Select slot --
                 </option>
-                {loadedFilaments.map((f) => (
+                {loadedFilaments
+                  .filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)
+                  .map((f) => (
                   <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                     {f.label}: {f.type} ({f.colorName})
                   </option>

+ 4 - 4
frontend/src/components/spool-form/ColorSection.tsx

@@ -177,7 +177,7 @@ export function ColorSection({
                 key={`${color.hex}-${color.name}`}
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
-                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
@@ -185,7 +185,7 @@ export function ColorSection({
                 style={{ backgroundColor: `#${color.hex}` }}
                 title={color.name}
               >
-                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                   {color.name}
                 </span>
               </button>
@@ -220,7 +220,7 @@ export function ColorSection({
                 key={color.hex}
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
-                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
@@ -228,7 +228,7 @@ export function ColorSection({
                 style={{ backgroundColor: `#${color.hex}` }}
                 title={color.name}
               >
-                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                   {color.name}
                 </span>
               </button>

+ 8 - 11
frontend/src/hooks/useFilamentMapping.ts

@@ -58,7 +58,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         globalTrayId: trayId,
         trayInfoIdx: extTray.tray_info_idx || '',
-        extruderId: hasDualNozzle ? (trayId - 254) : undefined,
+        extruderId: hasDualNozzle ? (255 - trayId) : undefined,
       });
     }
   }
@@ -100,12 +100,11 @@ export function computeAmsMapping(
     // Get available trays (not already used)
     let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
 
-    // Nozzle-aware filtering: restrict to trays on the correct nozzle
+    // Nozzle-aware filtering: restrict to trays on the correct nozzle.
+    // This is a hard filter — cross-nozzle assignment causes print failures
+    // ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
     if (req.nozzle_id != null) {
-      const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
-      if (nozzleFiltered.length > 0) {
-        available = nozzleFiltered;
-      }
+      available = available.filter((f) => f.extruderId === req.nozzle_id);
     }
 
     let idxMatch: LoadedFilament | undefined;
@@ -336,12 +335,10 @@ export function useFilamentMapping(
       // Get available trays (not already used)
       let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
 
-      // Nozzle-aware filtering: restrict to trays on the correct nozzle
+      // Nozzle-aware filtering: restrict to trays on the correct nozzle.
+      // This is a hard filter — cross-nozzle assignment causes print failures.
       if (req.nozzle_id != null) {
-        const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
-        if (nozzleFiltered.length > 0) {
-          available = nozzleFiltered;
-        }
+        available = available.filter((f) => f.extruderId === req.nozzle_id);
       }
 
       let idxMatch: LoadedFilament | undefined;

+ 2 - 2
frontend/src/pages/PrintersPage.tsx

@@ -64,6 +64,7 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
+import { getGlobalTrayId } from '../utils/amsHelpers';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -2977,8 +2978,7 @@ function PrinterCard({
                         const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                         const isEmpty = !tray?.tray_type;
                         // Check if this is the currently loaded tray
-                        // Global tray ID = ams.id * 4 + tray.id
-                        const globalTrayId = ams.id * 4 + (tray?.id ?? 0);
+                        const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                         const isActive = effectiveTrayNow === globalTrayId;
                         // Get cloud preset info if available
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-tulFiIvt.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-xpjLLAhl.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D8lA4NE5.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DF7TfzH1.css">
+    <script type="module" crossorigin src="/assets/index-xpjLLAhl.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-tulFiIvt.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff