Просмотр исходного кода

Nozzle-aware AMS mapping for dual-nozzle printers, BL spool detection fix, AMS startup fix, SQLite WAL (#318)

Dual-nozzle H2D/H2D Pro: filament matching now respects nozzle assignments
from the 3MF file. Each AMS unit feeds a specific nozzle (L/R), and the
scheduler/frontend constrain matching to only trays on the correct nozzle.
Falls back to unfiltered matching when no trays exist on the target nozzle.
L/R badges shown in the filament mapping UI. Translated in en/de/ja/it.

Fix AMS slot config overwritten on startup: on_ams_change unconditionally
unlinked BL spool assignments on every MQTT pushall, then re-assigned them
sending ams_filament_setting without setting_id — clearing the printer's
filament preset. Now compares spool RFID identifiers before unlinking.

Fix BL spool detection false positives: removed tray_info_idx from detection
logic in both backend is_bambu_lab_spool() and frontend isBambuLabSpool().
Third-party spools using Bambu generic presets had GF-prefixed tray_info_idx
values, causing misidentification. Now uses only tray_uuid and tag_uid.

SQLite WAL mode with 5s busy timeout reduces "database is locked" errors.
maziggy 3 месяцев назад
Родитель
Сommit
f9b47282a1

+ 2 - 0
.gitignore

@@ -28,6 +28,8 @@ npm-debug.log*
 # Database
 *.db
 *.db-journal
+*.db-wal
+*.db-shm
 
 # Archive files (user data)
 archive/

+ 7 - 0
CHANGELOG.md

@@ -8,8 +8,15 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
 - **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
 - **Spool Inventory — 3MF-Based Usage Tracking for Non-BL Spools** — Non-Bambu-Lab spools (no RFID) cannot use AMS remain% for usage tracking. Now falls back to per-filament weight estimates from the archived 3MF file (`used_g` per filament slot). For completed prints, uses the full slicer estimate. For failed or aborted prints, scales by print progress percentage. Bambu Lab spools continue using AMS remain% delta tracking as before.
+- **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
 
 ### Fixed
+- **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
+- **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.
+- **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.
+
+### Improved
+- **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
 - **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).

+ 2 - 2
README.md

@@ -72,7 +72,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
-- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
@@ -200,7 +200,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 </tr>
 </table>
 
-**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE/JA/IT) • Auto updates • Database backup/restore • System info dashboard
 
 ---
 

+ 7 - 0
backend/app/api/routes/archives.py

@@ -21,6 +21,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -2669,6 +2670,12 @@ async def get_filament_requirements(
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
+            # Enrich with nozzle mapping for dual-nozzle printers
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 

+ 7 - 0
backend/app/api/routes/library.py

@@ -56,6 +56,7 @@ from backend.app.schemas.library import (
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -1711,6 +1712,12 @@ async def get_library_file_filament_requirements(
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
+            # Enrich with nozzle mapping for dual-nozzle printers
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 

+ 17 - 0
backend/app/core/database.py

@@ -1,14 +1,30 @@
+from sqlalchemy import event
 from sqlalchemy.exc import OperationalError
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import DeclarativeBase
 
 from backend.app.core.config import settings
 
+
+def _set_sqlite_pragmas(dbapi_conn, connection_record):
+    """Set SQLite pragmas on each new connection for concurrency and performance."""
+    cursor = dbapi_conn.cursor()
+    # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
+    cursor.execute("PRAGMA journal_mode = WAL")
+    # Wait up to 5 seconds when the database is locked instead of failing immediately
+    cursor.execute("PRAGMA busy_timeout = 5000")
+    cursor.execute("PRAGMA synchronous = NORMAL")
+    cursor.close()
+
+
 engine = create_async_engine(
     settings.database_url,
     echo=settings.debug,
 )
 
+# Register the pragma listener on the underlying sync engine
+event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
+
 async_session = async_sessionmaker(
     engine,
     class_=AsyncSession,
@@ -29,6 +45,7 @@ async def reinitialize_database():
         settings.database_url,
         echo=settings.debug,
     )
+    event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
     async_session = async_sessionmaker(
         engine,
         class_=AsyncSession,

+ 35 - 3
backend/app/main.py

@@ -551,13 +551,45 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     )
                     stale.append(assignment)  # Slot empty
                 elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
-                    # A Bambu Lab spool was inserted — always unlink manual assignments
+                    # A Bambu Lab spool is in this slot — check if it's the same spool
+                    # that's currently assigned. If yes, keep the assignment (avoids
+                    # unnecessary unlink/re-assign/ams_filament_setting cycle that clears
+                    # the printer's filament preset on every startup).
+                    tray_uuid = current_tray.get("tray_uuid", "")
+                    tag_uid = current_tray.get("tag_uid", "")
+                    spool = assignment.spool
+                    spool_matches = False
+                    if spool:
+                        if (spool.tray_uuid and spool.tray_uuid.upper() == tray_uuid.upper()) or (
+                            spool.tag_uid
+                            and tag_uid
+                            and tag_uid != "0000000000000000"
+                            and spool.tag_uid.upper() == tag_uid.upper()
+                        ):
+                            spool_matches = True
+                    if spool_matches:
+                        # Same BL spool still in slot — keep assignment, update fingerprint if needed
+                        cur_color = current_tray.get("tray_color", "")
+                        cur_type = current_tray.get("tray_type", "")
+                        fp_color = assignment.fingerprint_color or ""
+                        fp_type = assignment.fingerprint_type or ""
+                        if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
+                            assignment.fingerprint_color = cur_color
+                            assignment.fingerprint_type = cur_type
+                            logger.debug(
+                                "Auto-unlink: spool %d AMS%d-T%d — same BL spool, updated fingerprint",
+                                assignment.spool_id,
+                                assignment.ams_id,
+                                assignment.tray_id,
+                            )
+                        continue
+                    # Different BL spool or unrecognized — unlink so auto-assign can match
                     logger.info(
-                        "Auto-unlink: spool %d AMS%d-T%d — Bambu Lab spool detected (uuid=%s)",
+                        "Auto-unlink: spool %d AMS%d-T%d — different Bambu Lab spool detected (uuid=%s)",
                         assignment.spool_id,
                         assignment.ams_id,
                         assignment.tray_id,
-                        current_tray.get("tray_uuid", ""),
+                        tray_uuid,
                     )
                     stale.append(assignment)
                 else:

+ 1 - 1
backend/app/services/bambu_ftp.py

@@ -181,7 +181,7 @@ class BambuFTPClient:
         if self._ftp:
             try:
                 self._ftp.quit()
-            except (OSError, ftplib.Error):
+            except (OSError, ftplib.Error, EOFError):
                 pass  # Best-effort FTP cleanup; connection may already be closed
             self._ftp = None
 

+ 19 - 0
backend/app/services/print_scheduler.py

@@ -23,6 +23,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -477,6 +478,12 @@ class PrintScheduler:
                             pass  # Skip filament entry with unparseable usage data
 
                 filaments.sort(key=lambda x: x["slot_id"])
+
+                # Enrich with nozzle mapping for dual-nozzle printers
+                nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+                if nozzle_mapping:
+                    for filament in filaments:
+                        filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
         except Exception as e:
             logger.warning("Failed to parse filament requirements: %s", e)
             return None
@@ -494,6 +501,9 @@ class PrintScheduler:
         """
         filaments = []
 
+        # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
+        ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
@@ -524,6 +534,7 @@ class PrintScheduler:
                             "is_ht": is_ht,
                             "is_external": False,
                             "global_tray_id": global_tray_id,
+                            "extruder_id": ams_extruder_map.get(str(ams_id)),
                         }
                     )
 
@@ -541,6 +552,7 @@ class PrintScheduler:
                     "is_ht": False,
                     "is_external": True,
                     "global_tray_id": 254,
+                    "extruder_id": 0 if ams_extruder_map else None,
                 }
             )
 
@@ -616,6 +628,13 @@ 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
+            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
+
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
                 idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]

+ 6 - 15
backend/app/services/spoolman.py

@@ -693,31 +693,22 @@ class SpoolmanClient:
     def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
         """Check if a tray has a valid Bambu Lab spool.
 
-        Bambu Lab spools can be identified by:
+        Bambu Lab spools are identified by hardware RFID identifiers only:
         1. tray_uuid: 32-character hex string (preferred, consistent across printers)
         2. tag_uid: 16-character hex string (RFID tag, varies between readers)
-        3. tray_info_idx: Bambu filament preset ID like "GFA00" (most reliable)
 
-        Non-Bambu Lab spools (SpoolEase, third-party) won't have these identifiers.
+        Note: tray_info_idx (e.g. "GFA00") is NOT a reliable indicator — third-party
+        spools using Bambu generic presets also have GF-prefixed tray_info_idx values.
+        The tray_info_idx parameter is kept for API compatibility but ignored.
 
         Args:
             tray_uuid: The tray UUID to check (32 hex chars)
             tag_uid: The RFID tag UID to check as fallback (16 hex chars)
-            tray_info_idx: Bambu filament preset ID like "GFA00", "GFB00"
+            tray_info_idx: Ignored (kept for API compatibility)
 
         Returns:
-            True if the spool has valid Bambu Lab identifiers, False otherwise.
+            True if the spool has valid Bambu Lab RFID identifiers, False otherwise.
         """
-        # Check tray_info_idx first - Bambu filament preset IDs like "GFA00", "GFB00", etc.
-        # This is the most reliable indicator as it's set when the spool is recognized
-        if tray_info_idx:
-            idx = tray_info_idx.strip()
-            # Bambu Lab preset IDs start with "GF" followed by letter and digits
-            # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
-            if idx and len(idx) >= 3 and idx.startswith("GF"):
-                logger.debug("Identified Bambu Lab spool via tray_info_idx: %s", idx)
-                return True
-
         # Check tray_uuid (preferred - consistent across printer models)
         if tray_uuid:
             uuid = tray_uuid.strip()

+ 56 - 0
backend/app/utils/threemf_tools.py

@@ -264,6 +264,62 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
     return properties
 
 
+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.
+
+    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.
+
+    Translation chain:
+        filament_nozzle_map[slot_id - 1] -> slicer extruder index
+        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+
+    Args:
+        zf: An open ZipFile of the 3MF archive
+
+    Returns:
+        Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
+        or None if single-nozzle, missing data, or parse error.
+    """
+    try:
+        if "Metadata/project_settings.config" not in zf.namelist():
+            return None
+
+        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 filament_nozzle_map or not physical_extruder_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
+            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
+
+        return nozzle_mapping
+    except Exception:
+        return None
+
+
 def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 

+ 6 - 6
backend/tests/unit/services/test_bambu_ftp.py

@@ -123,11 +123,10 @@ class TestDisconnectServerGone:
     """Test disconnect behavior when the server has stopped."""
 
     def test_disconnect_after_server_gone(self, ftp_certs, tmp_path):
-        """Disconnect after server has stopped raises EOFError.
+        """Disconnect after server has stopped does not raise.
 
-        Note: The current disconnect() catches (OSError, ftplib.Error) but
-        EOFError is neither. This documents actual behavior — a future fix
-        could add EOFError to the except clause.
+        disconnect() catches OSError, ftplib.Error, and EOFError so that
+        best-effort cleanup never propagates exceptions to the caller.
         """
         from backend.tests.unit.services.mock_ftp_server import (
             MockBambuFTPServer,
@@ -145,8 +144,9 @@ class TestDisconnectServerGone:
         client.connect()
 
         server.stop()
-        with pytest.raises(EOFError):
-            client.disconnect()
+        # Should not raise — disconnect() catches all connection errors
+        client.disconnect()
+        assert client._ftp is None
 
 
 # ---------------------------------------------------------------------------

+ 66 - 0
backend/tests/unit/services/test_spoolman_service.py

@@ -2,6 +2,7 @@
 
 These tests specifically target the sync_ams_tray method's disable_weight_sync
 functionality that controls whether remaining_weight is updated.
+Also includes tests for is_bambu_lab_spool RFID detection.
 """
 
 from unittest.mock import AsyncMock, Mock, patch
@@ -11,6 +12,71 @@ import pytest
 from backend.app.services.spoolman import AMSTray, SpoolmanClient
 
 
+class TestIsBambuLabSpool:
+    """Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only."""
+
+    @pytest.fixture
+    def client(self):
+        return SpoolmanClient("http://localhost:7912")
+
+    def test_valid_tray_uuid_returns_true(self, client):
+        """A non-zero 32-char hex tray_uuid identifies a BL spool."""
+        assert client.is_bambu_lab_spool("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") is True
+
+    def test_valid_tag_uid_returns_true(self, client):
+        """A non-zero 16-char hex tag_uid identifies a BL spool (fallback)."""
+        assert client.is_bambu_lab_spool("", tag_uid="A1B2C3D4E5F6A1B2") is True
+
+    def test_zero_tray_uuid_returns_false(self, client):
+        """All-zero tray_uuid means no RFID tag read."""
+        assert client.is_bambu_lab_spool("00000000000000000000000000000000") is False
+
+    def test_zero_tag_uid_returns_false(self, client):
+        """All-zero tag_uid means no RFID tag read."""
+        assert client.is_bambu_lab_spool("", tag_uid="0000000000000000") is False
+
+    def test_empty_identifiers_returns_false(self, client):
+        """No identifiers means no BL spool."""
+        assert client.is_bambu_lab_spool("") is False
+        assert client.is_bambu_lab_spool("", tag_uid="") is False
+
+    def test_tray_info_idx_ignored(self, client):
+        """tray_info_idx is NOT a reliable BL indicator — third-party spools
+        using Bambu generic presets also have GF-prefixed tray_info_idx values."""
+        # Third-party spool with Bambu preset but no RFID identifiers
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFA00") is False
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFB00") is False
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFSA02_04") is False
+
+    def test_tray_info_idx_with_valid_uuid_returns_true(self, client):
+        """BL spool with both RFID UUID and preset ID — detected by UUID."""
+        assert (
+            client.is_bambu_lab_spool(
+                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                tray_info_idx="GFA00",
+            )
+            is True
+        )
+
+    def test_tray_uuid_preferred_over_tag_uid(self, client):
+        """tray_uuid is checked before tag_uid (both valid)."""
+        assert (
+            client.is_bambu_lab_spool(
+                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                tag_uid="A1B2C3D4E5F6A1B2",
+            )
+            is True
+        )
+
+    def test_short_tray_uuid_returns_false(self, client):
+        """UUID must be exactly 32 hex chars."""
+        assert client.is_bambu_lab_spool("A1B2C3D4") is False
+
+    def test_non_hex_tray_uuid_returns_false(self, client):
+        """UUID must be valid hex."""
+        assert client.is_bambu_lab_spool("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") is False
+
+
 class TestSpoolmanClient:
     """Tests for SpoolmanClient class."""
 

+ 188 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -1,8 +1,13 @@
 """Tests for the AMS mapping computation in the print scheduler."""
 
+import io
+import json
+import zipfile
+
 import pytest
 
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 
 class TestSchedulerAmsMappingHelpers:
@@ -467,3 +472,186 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         assert len(result) == 1
         assert result[0]["tray_info_idx"] == "P4d64437"
         assert result[0]["is_external"] is True
+
+
+def _make_3mf_zip(project_settings: dict | 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))
+    buf.seek(0)
+    return zipfile.ZipFile(buf, "r")
+
+
+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."""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1", "0"],
+                "physical_extruder_map": ["0", "1"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 0, 2: 1, 3: 0}
+        zf.close()
+
+    def test_single_nozzle_returns_none(self):
+        """All slots on same extruder should return None (single-nozzle)."""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "0", "0"],
+                "physical_extruder_map": ["0"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_missing_project_settings_returns_none(self):
+        """Missing project_settings.config should return None."""
+        zf = _make_3mf_zip(None)
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_missing_fields_returns_none(self):
+        """Missing filament_nozzle_map or 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
+        zf.close()
+
+    def test_physical_extruder_map_remapping(self):
+        """Should apply physical_extruder_map to remap slicer extruder to MQTT extruder."""
+        # Slicer ext 0 -> MQTT ext 1, slicer ext 1 -> MQTT ext 0
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1"],
+                "physical_extruder_map": ["1", "0"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 1, 2: 0}
+        zf.close()
+
+
+class TestNozzleAwareMapping:
+    """Test nozzle-aware filament matching in the print scheduler."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_dual_nozzle_matching(self, scheduler):
+        """Filaments assigned to different nozzles should match to correct AMS units."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
+            {"slot_id": 2, "type": "PLA", "color": "#00FF00", "nozzle_id": 1},  # Left nozzle
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "extruder_id": 0},  # AMS0 on right
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},  # AMS1 on left
+        ]
+        # Without nozzle filtering, slot 1 (red, right) would match tray 4 (red, left) by color.
+        # With nozzle filtering, slot 1 (right nozzle) can only use tray 0 (right extruder),
+        # and slot 2 (left nozzle) can only use tray 4 (left extruder).
+        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."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
+        ]
+        loaded = [
+            # 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
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [4]
+
+    def test_no_nozzle_id_skips_filtering(self, scheduler):
+        """When nozzle_id is None, no nozzle filtering should be applied."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000"},  # No nozzle_id
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
+        ]
+        # Should match first available (tray 0) regardless of extruder
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]
+
+    def test_extruder_id_in_loaded_filaments(self, scheduler):
+        """_build_loaded_filaments should include extruder_id from ams_extruder_map."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]},
+                    {"id": 1, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "00FF00"}]},
+                ],
+                "ams_extruder_map": {"0": 0, "1": 1},
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+        assert result[0]["extruder_id"] == 0
+        assert result[1]["extruder_id"] == 1
+
+    def test_extruder_id_none_without_map(self, scheduler):
+        """extruder_id should be None when ams_extruder_map is absent."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]},
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        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."""
+
+        class MockStatus:
+            raw_data = {
+                "vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"},
+                "ams_extruder_map": {"0": 0},
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] == 0
+        assert result[0]["is_external"] is True
+
+    def test_external_spool_no_extruder_map(self, scheduler):
+        """External spool extruder_id should be None without ams_extruder_map."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] is None
+
+    def test_dual_nozzle_with_tray_info_idx(self, scheduler):
+        """Nozzle filtering should work together with tray_info_idx matching."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 0},
+            {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01", "nozzle_id": 1},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00", "extruder_id": 0},
+            {"type": "PLA", "color": "#000000", "global_tray_id": 4, "tray_info_idx": "GFA01", "extruder_id": 1},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 4]

+ 179 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -347,3 +347,182 @@ describe('computeAmsMapping', () => {
     expect(result).toEqual([254]);  // External spool global ID
   });
 });
+
+describe('buildLoadedFilaments - nozzle awareness', () => {
+  it('sets extruderId from ams_extruder_map', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].extruderId).toBe(1);  // AMS 0 → left nozzle
+    expect(result[1].extruderId).toBe(0);  // AMS 1 → right nozzle
+  });
+
+  it('leaves extruderId undefined when no ams_extruder_map', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].extruderId).toBeUndefined();
+  });
+});
+
+describe('computeAmsMapping - nozzle filtering', () => {
+  it('filters candidates by nozzle_id when set', () => {
+    // Filament requires left nozzle (extruder 1), only AMS 0 is on left
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // AMS 0, tray 0 (on left nozzle)
+  });
+
+  it('filters to right nozzle when nozzle_id=0', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    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', () => {
+    // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Right nozzle only
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 0 };  // AMS 0 → right nozzle, none on left
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Falls back to unfiltered (right nozzle PLA)
+  });
+
+  it('stays restricted when target nozzle has trays but wrong type', () => {
+    // Left nozzle has PETG, right has PLA — but requires PLA on left
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle - only PETG
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+      {
+        id: 1,  // Right nozzle - has PLA
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([-1]);  // No PLA on left nozzle, stays restricted
+  });
+
+  it('skips nozzle filtering when nozzle_id is undefined', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },  // No nozzle_id
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+      {
+        id: 1,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([4]);  // Picks best match regardless of nozzle
+  });
+
+  it('handles dual-nozzle multi-slot mapping', () => {
+    // Two filaments: one for left, one for right
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },  // Left
+        { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+        ],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [
+          { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
+        ],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
+  });
+});

+ 13 - 2
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,4 +1,5 @@
 import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../../api/client';
@@ -17,6 +18,7 @@ export function FilamentMapping({
   onManualMappingChange,
   defaultExpanded = false,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = useState(defaultExpanded);
@@ -32,6 +34,7 @@ export function FilamentMapping({
     useFilamentMapping(filamentReqs, printerStatus, manualMappings);
 
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+  const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
 
   // Don't render if no filament requirements
   if (!hasFilamentReqs) {
@@ -126,8 +129,16 @@ export function FilamentMapping({
               <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
                 <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
               </span>
-              {/* Required type + grams */}
-              <span className="text-white truncate">
+              {/* Required type + grams + nozzle badge */}
+              <span className="text-white truncate flex items-center gap-1">
+                {isDualNozzle && item.nozzle_id != null && (
+                  <span
+                    className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
+                    title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
+                  >
+                    {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
+                  </span>
+                )}
                 {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
               </span>
               {/* Arrow */}

+ 1 - 0
frontend/src/components/PrintModal/types.ts

@@ -158,6 +158,7 @@ export interface FilamentReqsData {
     color: string;
     used_grams: number;
     used_meters: number;
+    nozzle_id?: number;
   }>;
 }
 

+ 27 - 44
frontend/src/hooks/useFilamentMapping.ts

@@ -15,6 +15,8 @@ import type { PrinterStatus } from '../api/client';
  */
 export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
   const filaments: LoadedFilament[] = [];
+  const amsExtruderMap = printerStatus?.ams_extruder_map;
+  const hasDualNozzle = amsExtruderMap && Object.keys(amsExtruderMap).length > 0;
 
   // Add filaments from all AMS units (regular and HT)
   printerStatus?.ams?.forEach((amsUnit) => {
@@ -33,6 +35,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           trayInfoIdx: tray.tray_info_idx || '',
+          extruderId: amsExtruderMap?.[String(amsUnit.id)],
         });
       }
     });
@@ -52,6 +55,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       label: 'External',
       globalTrayId: 254,
       trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
+      extruderId: hasDualNozzle ? 0 : undefined,
     });
   }
 
@@ -90,7 +94,15 @@ export function computeAmsMapping(
     const reqTrayInfoIdx = req.tray_info_idx || '';
 
     // Get available trays (not already used)
-    const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+    let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+    // Nozzle-aware filtering: restrict to trays on the correct nozzle
+    if (req.nozzle_id != null) {
+      const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
+      if (nozzleFiltered.length > 0) {
+        available = nozzleFiltered;
+      }
+    }
 
     let idxMatch: LoadedFilament | undefined;
     let exactMatch: LoadedFilament | undefined;
@@ -191,6 +203,8 @@ export interface LoadedFilament {
   globalTrayId: number;
   /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
   trayInfoIdx?: string;
+  /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
+  extruderId?: number;
 }
 
 /**
@@ -203,6 +217,8 @@ export interface FilamentRequirement {
   used_grams: number;
   /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
   tray_info_idx?: string;
+  /** Target nozzle for dual-nozzle printers (0=right, 1=left) */
+  nozzle_id?: number;
 }
 
 /**
@@ -247,48 +263,7 @@ export function useLoadedFilaments(
   printerStatus: PrinterStatus | undefined
 ): LoadedFilament[] {
   return useMemo(() => {
-    const filaments: LoadedFilament[] = [];
-
-    // Add filaments from all AMS units (regular and HT)
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-            trayInfoIdx: tray.tray_info_idx || '',
-          });
-        }
-      });
-    });
-
-    // Add external spool if loaded
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-        trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
-      });
-    }
-
-    return filaments;
+    return buildLoadedFilaments(printerStatus);
   }, [printerStatus]);
 }
 
@@ -355,7 +330,15 @@ export function useFilamentMapping(
       const reqTrayInfoIdx = req.tray_info_idx || '';
 
       // Get available trays (not already used)
-      const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+      let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+      // Nozzle-aware filtering: restrict to trays on the correct nozzle
+      if (req.nozzle_id != null) {
+        const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
+        if (nozzleFiltered.length > 0) {
+          available = nozzleFiltered;
+        }
+      }
 
       let idxMatch: LoadedFilament | undefined;
       let exactMatch: LoadedFilament | undefined;

+ 26 - 14
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -124,26 +124,32 @@ function computeMatchDetails(
       }
     }
 
-    // Auto-match
-    const exactMatch = loadedFilaments.find(
+    // Auto-match with nozzle-aware filtering
+    let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+    if (req.nozzle_id != null) {
+      const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
+      if (nozzleFiltered.length > 0) {
+        candidates = nozzleFiltered;
+      }
+    }
+
+    const exactMatch = candidates.find(
       (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
         f.type?.toUpperCase() === req.type?.toUpperCase() &&
         normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
     );
     const similarMatch = exactMatch
       ? undefined
-      : loadedFilaments.find(
+      : candidates.find(
           (f) =>
-            !usedTrayIds.has(f.globalTrayId) &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
             colorsAreSimilar(f.color, req.color)
         );
     const typeOnlyMatch =
       exactMatch || similarMatch
         ? undefined
-        : loadedFilaments.find(
-            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        : candidates.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
           );
     const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
 
@@ -196,26 +202,32 @@ function computeMappingWithOverrides(
       continue;
     }
 
-    // Auto-match
-    const exactMatch = loadedFilaments.find(
+    // Auto-match with nozzle-aware filtering
+    let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+    if (req.nozzle_id != null) {
+      const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
+      if (nozzleFiltered.length > 0) {
+        candidates = nozzleFiltered;
+      }
+    }
+
+    const exactMatch = candidates.find(
       (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
         f.type?.toUpperCase() === req.type?.toUpperCase() &&
         normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
     );
     const similarMatch = exactMatch
       ? undefined
-      : loadedFilaments.find(
+      : candidates.find(
           (f) =>
-            !usedTrayIds.has(f.globalTrayId) &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
             colorsAreSimilar(f.color, req.color)
         );
     const typeOnlyMatch =
       exactMatch || similarMatch
         ? undefined
-        : loadedFilaments.find(
-            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        : candidates.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
           );
     const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
 

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -2632,6 +2632,10 @@ export default {
     sameTypeDifferentColor: 'Gleicher Typ, andere Farbe',
     filamentTypeNotLoaded: 'Filamenttyp nicht geladen',
     openCalendar: 'Kalender öffnen',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Linke Düse',
+    rightNozzleTooltip: 'Rechte Düse',
   },
 
   // Backup

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -2636,6 +2636,10 @@ export default {
     sameTypeDifferentColor: 'Same type, different color',
     filamentTypeNotLoaded: 'Filament type not loaded',
     openCalendar: 'Open calendar',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Left nozzle',
+    rightNozzleTooltip: 'Right nozzle',
   },
 
   // Backup

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -2306,6 +2306,10 @@ export default {
     noPrintersAvailable: 'Nessuna stampante disponibile',
     printerBusy: 'Stampante occupata',
     printerOffline: 'Stampante offline',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Ugello sinistro',
+    rightNozzleTooltip: 'Ugello destro',
   },
 
   // Backup

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -2552,6 +2552,10 @@ export default {
     printerBusy: 'プリンターは使用中です',
     printerOffline: 'プリンターはオフラインです',
     cancel: 'キャンセル',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: '左ノズル',
+    rightNozzleTooltip: '右ノズル',
   },
   backup: {
     restoreBackup: 'バックアップの復元',

+ 4 - 8
frontend/src/pages/PrintersPage.tsx

@@ -1137,21 +1137,17 @@ function getWifiStrength(rssi: number): { labelKey: string; color: string; bars:
 }
 
 /**
- * Check if a tray contains a Bambu Lab spool.
- * Uses same logic as backend: tray_info_idx (GF*), tray_uuid, or tag_uid.
+ * Check if a tray contains a Bambu Lab spool (RFID-tagged).
+ * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,
+ * which is a filament profile/preset ID that third-party spools also get when
+ * the user selects a generic Bambu preset (e.g. "GFA00" for Generic PLA).
  */
 function isBambuLabSpool(tray: {
   tray_uuid?: string | null;
   tag_uid?: string | null;
-  tray_info_idx?: string | null;
 } | null | undefined): boolean {
   if (!tray) return false;
 
-  // Check tray_info_idx first (most reliable - Bambu preset IDs start with "GF")
-  if (tray.tray_info_idx && tray.tray_info_idx.startsWith('GF')) {
-    return true;
-  }
-
   // Check tray_uuid (32 hex chars, non-zero)
   if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') {
     return true;

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C7KLL0cs.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CYXpThrD.js"></script>
+    <script type="module" crossorigin src="/assets/index-C7KLL0cs.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов