Sfoglia il codice sorgente

AMS mapping fixes (7 bugs):
- backend/app/services/print_scheduler.py — hard nozzle filter + external spool extruder
- backend/app/api/routes/printers.py — ams_extruder_map race condition
- backend/app/utils/threemf_tools.py — group_id priority + dual-nozzle detection
- frontend/src/hooks/useFilamentMapping.ts — external spool extruder
- frontend/src/pages/PrintersPage.tsx — AMS-HT globalTrayId
- frontend/src/components/PrintModal/FilamentMapping.tsx — dropdown nozzle filter

Usage tracking fix:
- backend/app/services/bambu_mqtt.py — capture last valid progress/layer
- backend/app/services/usage_tracker.py — use captured values on cancel

UI fix:
- frontend/src/components/spool-form/ColorSection.tsx — color tooltip z-index

Tests:
- backend/tests/unit/test_scheduler_ams_mapping.py — 63 tests
- frontend/src/__tests__/hooks/useFilamentMapping.test.ts — 43 tests

Static assets:
- static/ — rebuilt frontend bundles

maziggy 3 mesi fa
parent
commit
29e9593f6c

+ 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

+ 14 - 0
backend/app/services/bambu_mqtt.py

@@ -279,6 +279,8 @@ 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._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
@@ -1211,6 +1213,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 +1230,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 +1992,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 +2078,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:

+ 12 - 0
backend/app/services/usage_tracker.py

@@ -167,6 +167,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 +278,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).
 
@@ -374,6 +378,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 +389,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 (

+ 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
 

+ 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]

+ 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