Procházet zdrojové kódy

fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)

  Edward's diagnosis was exact: the manual /print-queue/ POST extracts
  filament requirements from the 3MF and writes
  required_filament_types + filament_overrides + ams_mapping onto the
  queue item, but the VP queue-mode write path skipped all of that.
  Net effect: scheduler reached its model-only-matching fallback and
  auto-dispatched onto whatever printer was free regardless of loaded
  colour.

  Extract the scheduler's existing _get_filament_requirements 3MF
  parser into a shared helper so the VP path can reuse it. VP's
  _add_to_print_queue now populates required_filament_types
  unconditionally (cheap; helps the scheduler reject obvious type
  mismatches) and writes filament_overrides with force_color_match:
  true per consumed slot when a new per-VP queue_force_color_match
  toggle is on. Default off to preserve current behaviour for
  upgraders.

  UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
  mirroring the existing auto-dispatch toggle. i18n: en + de
  translated, other 6 locales seeded with English copy.

  Schema: one nullable column on virtual_printers
  (queue_force_color_match BOOLEAN, default 0/FALSE).

  11 new backend tests (8 for the extracted parser, 3 for the VP
  write path) + 6 new frontend tests (toggle render gating, default
  state, click posts queue_force_color_match in update body).
  Existing scheduler tests pass against the refactored helper.
  README, CHANGELOG, website features page, and wiki virtual-printer
  page all updated.
maziggy před 3 týdny
rodič
revize
459cfdc51f

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -240,6 +240,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Send prints directly from Bambu Studio/Orca Slicer
 - Send prints directly from Bambu Studio/Orca Slicer
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Archive mode, Review mode, Queue mode, or Proxy mode
 - Archive mode, Review mode, Queue mode, or Proxy mode
+- Queue mode: optional **force-color-match** so the scheduler refuses to dispatch onto a printer with the wrong filament loaded
 - SSDP discovery (same LAN) or manual IP entry (VPN/remote)
 - SSDP discovery (same LAN) or manual IP entry (VPN/remote)
 - Network interface override for multi-NIC/Docker/VPN setups
 - Network interface override for multi-NIC/Docker/VPN setups
 - Secure TLS/MQTT/FTP communication
 - Secure TLS/MQTT/FTP communication

+ 6 - 0
backend/app/api/routes/virtual_printers.py

@@ -37,6 +37,7 @@ class VirtualPrinterCreate(BaseModel):
     access_code: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     auto_dispatch: bool = True
+    queue_force_color_match: bool = False
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
 
 
@@ -49,6 +50,7 @@ class VirtualPrinterUpdate(BaseModel):
     access_code: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     auto_dispatch: bool | None = None
+    queue_force_color_match: bool | None = None
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
     tailscale_disabled: bool | None = None
     tailscale_disabled: bool | None = None
@@ -90,6 +92,7 @@ def _vp_to_dict(vp, status: dict | None = None) -> dict:
         "serial": serial,
         "serial": serial,
         "target_printer_id": vp.target_printer_id,
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
         "auto_dispatch": vp.auto_dispatch,
+        "queue_force_color_match": vp.queue_force_color_match,
         "bind_ip": vp.bind_ip,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "tailscale_disabled": vp.tailscale_disabled,
         "tailscale_disabled": vp.tailscale_disabled,
@@ -209,6 +212,7 @@ async def create_virtual_printer(
         access_code=body.access_code,
         access_code=body.access_code,
         target_printer_id=body.target_printer_id,
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         auto_dispatch=body.auto_dispatch,
+        queue_force_color_match=body.queue_force_color_match,
         bind_ip=body.bind_ip,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
         remote_interface_ip=body.remote_interface_ip,
         serial_suffix=new_suffix,
         serial_suffix=new_suffix,
@@ -331,6 +335,8 @@ async def update_virtual_printer(
             vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
             vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
     if body.auto_dispatch is not None:
     if body.auto_dispatch is not None:
         vp.auto_dispatch = body.auto_dispatch
         vp.auto_dispatch = body.auto_dispatch
+    if body.queue_force_color_match is not None:
+        vp.queue_force_color_match = body.queue_force_color_match
     if body.bind_ip is not None:
     if body.bind_ip is not None:
         vp.bind_ip = body.bind_ip
         vp.bind_ip = body.bind_ip
     if body.remote_interface_ip is not None:
     if body.remote_interface_ip is not None:

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

@@ -637,6 +637,18 @@ async def run_migrations(conn):
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
 
 
+    # Migration: Add queue_force_color_match column to virtual_printers (#1188).
+    # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
+    # from the 3MF onto the queue item's filament_overrides so the scheduler
+    # refuses to dispatch onto a printer with the wrong filament loaded.
+    # Default false to preserve current behaviour for upgraders.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
+        )
+
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 
 

+ 5 - 0
backend/app/models/virtual_printer.py

@@ -18,6 +18,11 @@ class VirtualPrinter(Base):
     auto_dispatch: Mapped[bool] = mapped_column(
     auto_dispatch: Mapped[bool] = mapped_column(
         Boolean, server_default="true"
         Boolean, server_default="true"
     )  # print_queue mode: auto-start or manual
     )  # print_queue mode: auto-start or manual
+    queue_force_color_match: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # print_queue mode: pin per-slot type+color from the 3MF onto the queue
+    # item so the scheduler refuses to dispatch onto a printer with the wrong
+    # filament loaded (#1188).
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(
     target_printer_id: Mapped[int | None] = mapped_column(

+ 113 - 0
backend/app/services/filament_requirements.py

@@ -0,0 +1,113 @@
+"""Parse per-slot filament requirements out of a 3MF file.
+
+The scheduler used to own this logic (`PrintScheduler._get_filament_requirements`)
+because it ran during dispatch decisions. Extracted here so the VP queue-mode
+write path can use the same parser to populate `filament_overrides` /
+`required_filament_types` at upload time (#1188 — Bambuddy was creating queue
+items with no filament fields, which made the scheduler fall through to
+model-only matching and dispatch onto whatever printer happened to be free
+regardless of loaded colour).
+
+The shape returned here matches the `filament_overrides` JSON shape the
+scheduler validates against, minus the `force_color_match` flag — callers
+add that themselves based on their own setting.
+"""
+
+from __future__ import annotations
+
+import logging
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+
+logger = logging.getLogger(__name__)
+
+
+def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
+    """Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
+
+    Args:
+        file_path: Path to the 3MF.
+        plate_id: When set, only return filaments used on that plate. When
+            None, return every filament with `used_g > 0` across the file.
+
+    Returns:
+        Sorted list (by `slot_id`) of filament dicts. Empty list when the
+        3MF is unreadable, missing `Metadata/slice_info.config`, or has no
+        filaments matching the plate filter — callers treat that as "no
+        requirements" rather than an error so a malformed 3MF doesn't break
+        the upload path.
+    """
+    if not file_path.exists():
+        return []
+
+    filaments: list[dict] = []
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return []
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)  # noqa: S314  # nosec B314
+
+            if plate_id is not None:
+                for plate_elem in root.findall("./plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+                    if plate_index == plate_id:
+                        _collect_filaments(plate_elem, filaments)
+                        break
+            else:
+                _collect_filaments(root, filaments)
+
+            filaments.sort(key=lambda x: x["slot_id"])
+
+            # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
+            # slot is fed into. Empty mapping for single-nozzle printers, in
+            # which case we just don't add the key.
+            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 %s: %s", file_path, e)
+        return []
+
+    return filaments
+
+
+def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
+    """Walk every `./filament` child under `parent` and append normalised
+    entries to `into`. Skips filaments with `used_g <= 0` (slot present in
+    the slicer config but not consumed by this plate)."""
+    for filament_elem in parent.findall("./filament"):
+        filament_id = filament_elem.get("id")
+        if not filament_id:
+            continue
+        try:
+            used_grams = float(filament_elem.get("used_g", "0"))
+        except (ValueError, TypeError):
+            continue
+        if used_grams <= 0:
+            continue
+        try:
+            slot_id = int(filament_id)
+        except (ValueError, TypeError):
+            continue
+        into.append(
+            {
+                "slot_id": slot_id,
+                "type": filament_elem.get("type", ""),
+                "color": filament_elem.get("color", ""),
+                "tray_info_idx": filament_elem.get("tray_info_idx", ""),
+                "used_grams": round(used_grams, 1),
+            }
+        )

+ 8 - 88
backend/app/services/print_scheduler.py

@@ -4,11 +4,9 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import time
 import time
-import zipfile
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
-import defusedxml.ElementTree as ET
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
@@ -31,7 +29,6 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_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.printer_models import normalize_printer_model
-from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -825,17 +822,15 @@ class PrintScheduler:
         return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
         return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
 
 
     async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
     async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
-        """Extract filament requirements from the source 3MF file.
-
-        Args:
-            db: Database session
-            item: Queue item with archive_id or library_file_id
-
-        Returns:
-            List of filament requirement dicts with slot_id, type, color, used_grams
+        """Resolve the queue item's source 3MF and parse the per-slot
+        filament requirements out of it. Thin DB-resolver wrapper around
+        ``filament_requirements.extract_filament_requirements`` so the VP
+        queue-mode write path (#1188) can reuse the same parser at upload
+        time.
         """
         """
-        file_path: Path | None = None
+        from backend.app.services.filament_requirements import extract_filament_requirements
 
 
+        file_path: Path | None = None
         if item.archive_id:
         if item.archive_id:
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
             archive = result.scalar_one_or_none()
             archive = result.scalar_one_or_none()
@@ -851,82 +846,7 @@ class PrintScheduler:
         if not file_path or not file_path.exists():
         if not file_path or not file_path.exists():
             return None
             return None
 
 
-        filaments = []
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                if "Metadata/slice_info.config" not in zf.namelist():
-                    return None
-
-                content = zf.read("Metadata/slice_info.config").decode()
-                root = ET.fromstring(content)
-
-                # Check if plate_id is specified - use that plate's filaments
-                plate_id = item.plate_id
-                if plate_id:
-                    for plate_elem in root.findall("./plate"):
-                        plate_index = None
-                        for meta in plate_elem.findall("metadata"):
-                            if meta.get("key") == "index":
-                                plate_index = int(meta.get("value", "0"))
-                                break
-                        if plate_index == plate_id:
-                            for filament_elem in plate_elem.findall("./filament"):
-                                filament_id = filament_elem.get("id")
-                                filament_type = filament_elem.get("type", "")
-                                filament_color = filament_elem.get("color", "")
-                                # tray_info_idx identifies the specific spool selected when slicing
-                                tray_info_idx = filament_elem.get("tray_info_idx", "")
-                                used_g = filament_elem.get("used_g", "0")
-                                try:
-                                    used_grams = float(used_g)
-                                    if used_grams > 0 and filament_id:
-                                        filaments.append(
-                                            {
-                                                "slot_id": int(filament_id),
-                                                "type": filament_type,
-                                                "color": filament_color,
-                                                "tray_info_idx": tray_info_idx,
-                                                "used_grams": round(used_grams, 1),
-                                            }
-                                        )
-                                except (ValueError, TypeError):
-                                    pass  # Skip filament entry with unparseable usage data
-                            break
-                else:
-                    # No plate_id - extract all filaments with used_g > 0
-                    for filament_elem in root.findall("./filament"):
-                        filament_id = filament_elem.get("id")
-                        filament_type = filament_elem.get("type", "")
-                        filament_color = filament_elem.get("color", "")
-                        # tray_info_idx identifies the specific spool selected when slicing
-                        tray_info_idx = filament_elem.get("tray_info_idx", "")
-                        used_g = filament_elem.get("used_g", "0")
-                        try:
-                            used_grams = float(used_g)
-                            if used_grams > 0 and filament_id:
-                                filaments.append(
-                                    {
-                                        "slot_id": int(filament_id),
-                                        "type": filament_type,
-                                        "color": filament_color,
-                                        "tray_info_idx": tray_info_idx,
-                                        "used_grams": round(used_grams, 1),
-                                    }
-                                )
-                        except (ValueError, TypeError):
-                            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
-
+        filaments = extract_filament_requirements(file_path, plate_id=item.plate_id)
         return filaments if filaments else None
         return filaments if filaments else None
 
 
     def _build_loaded_filaments(self, status) -> list[dict]:
     def _build_loaded_filaments(self, status) -> list[dict]:

+ 40 - 0
backend/app/services/virtual_printer/manager.py

@@ -111,6 +111,7 @@ class VirtualPrinterInstance:
         target_printer_serial: str = "",
         target_printer_serial: str = "",
         target_printer_id: int | None = None,
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         auto_dispatch: bool = True,
+        queue_force_color_match: bool = False,
         bind_ip: str = "",
         bind_ip: str = "",
         remote_interface_ip: str = "",
         remote_interface_ip: str = "",
         tailscale_disabled: bool = True,
         tailscale_disabled: bool = True,
@@ -127,6 +128,7 @@ class VirtualPrinterInstance:
         self.target_printer_serial = target_printer_serial
         self.target_printer_serial = target_printer_serial
         self.target_printer_id = target_printer_id
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.auto_dispatch = auto_dispatch
+        self.queue_force_color_match = queue_force_color_match
         self.bind_ip = bind_ip
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self.remote_interface_ip = remote_interface_ip
         self.tailscale_disabled = tailscale_disabled
         self.tailscale_disabled = tailscale_disabled
@@ -330,9 +332,12 @@ class VirtualPrinterInstance:
             return
             return
 
 
         try:
         try:
+            import json
+
             from backend.app.api.routes.settings import get_setting
             from backend.app.api.routes.settings import get_setting
             from backend.app.models.print_queue import PrintQueueItem
             from backend.app.models.print_queue import PrintQueueItem
             from backend.app.services.archive import ArchiveService
             from backend.app.services.archive import ArchiveService
+            from backend.app.services.filament_requirements import extract_filament_requirements
 
 
             async with self._session_factory() as db:
             async with self._session_factory() as db:
                 name_source = await get_setting(db, "virtual_printer_archive_name_source")
                 name_source = await get_setting(db, "virtual_printer_archive_name_source")
@@ -355,6 +360,38 @@ class VirtualPrinterInstance:
                     if not self.target_printer_id and self.model:
                     if not self.target_printer_id and self.model:
                         target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
                         target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
                     plate_id = self._extract_plate_id(file_path)
                     plate_id = self._extract_plate_id(file_path)
+
+                    # Parse the 3MF for per-slot filament requirements (#1188).
+                    # The manual /print-queue/ POST flow does this at queue-add
+                    # time; the VP path used to skip it, so the scheduler fell
+                    # through to model-only matching and dispatched onto whatever
+                    # printer happened to be free regardless of loaded colour.
+                    # required_filament_types is populated unconditionally — it's
+                    # cheap, lets the scheduler reject obvious mis-matches even
+                    # without force_color_match. filament_overrides only carries
+                    # force_color_match=True when the per-VP setting is on, so
+                    # upgraders keep the old behaviour by default.
+                    required_filament_types_json: str | None = None
+                    filament_overrides_json: str | None = None
+                    requirements = extract_filament_requirements(file_path, plate_id)
+                    if requirements:
+                        types = sorted({r["type"] for r in requirements if r.get("type")})
+                        if types:
+                            required_filament_types_json = json.dumps(types)
+                        if self.queue_force_color_match:
+                            overrides = [
+                                {
+                                    "slot_id": r["slot_id"],
+                                    "type": r.get("type", ""),
+                                    "color": r.get("color", ""),
+                                    "force_color_match": True,
+                                }
+                                for r in requirements
+                                if r.get("type") and r.get("color")
+                            ]
+                            if overrides:
+                                filament_overrides_json = json.dumps(overrides)
+
                     queue_item = PrintQueueItem(
                     queue_item = PrintQueueItem(
                         printer_id=self.target_printer_id,
                         printer_id=self.target_printer_id,
                         target_model=target_model,
                         target_model=target_model,
@@ -363,6 +400,8 @@ class VirtualPrinterInstance:
                         position=1,
                         position=1,
                         status="pending",
                         status="pending",
                         manual_start=not self.auto_dispatch,
                         manual_start=not self.auto_dispatch,
+                        required_filament_types=required_filament_types_json,
+                        filament_overrides=filament_overrides_json,
                     )
                     )
                     db.add(queue_item)
                     db.add(queue_item)
                     await db.commit()
                     await db.commit()
@@ -894,6 +933,7 @@ class VirtualPrinterManager:
                     serial_suffix=vp.serial_suffix,
                     serial_suffix=vp.serial_suffix,
                     target_printer_id=vp.target_printer_id,
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     auto_dispatch=vp.auto_dispatch,
+                    queue_force_color_match=vp.queue_force_color_match,
                     bind_ip=vp.bind_ip or "",
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     tailscale_disabled=vp.tailscale_disabled,
                     tailscale_disabled=vp.tailscale_disabled,

+ 144 - 0
backend/tests/unit/services/test_filament_requirements.py

@@ -0,0 +1,144 @@
+"""Unit tests for `extract_filament_requirements` (#1188).
+
+The helper is the parser the scheduler used to own and the VP queue-mode
+write path now also uses. Pin the contract end-to-end so a refactor of one
+caller can't silently break the other.
+"""
+
+from __future__ import annotations
+
+import zipfile
+from pathlib import Path
+
+from backend.app.services.filament_requirements import extract_filament_requirements
+
+
+def _make_3mf(
+    file_path: Path,
+    *,
+    plates: list[tuple[int, list[dict]]] | None = None,
+    flat_filaments: list[dict] | None = None,
+) -> None:
+    """Build a minimal 3MF zip. Either ``plates`` (list of
+    ``(plate_index, filaments)``) or ``flat_filaments`` (no plate wrapper)
+    drives the slice_info.config shape."""
+
+    def _filament_xml(filaments: list[dict]) -> str:
+        return "".join(
+            f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
+            f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
+            for f in filaments
+        )
+
+    if plates is not None:
+        plate_xml = "".join(
+            f'<plate><metadata key="index" value="{idx}"/>{_filament_xml(fs)}</plate>' for idx, fs in plates
+        )
+        body = plate_xml
+    elif flat_filaments is not None:
+        body = _filament_xml(flat_filaments)
+    else:
+        body = ""
+    config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
+    with zipfile.ZipFile(file_path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", config)
+
+
+class TestExtractFilamentRequirements:
+    def test_returns_per_slot_dicts_for_plate(self, tmp_path: Path):
+        f = tmp_path / "model.3mf"
+        _make_3mf(
+            f,
+            plates=[
+                (
+                    1,
+                    [
+                        {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.5"},
+                        {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.2"},
+                    ],
+                )
+            ],
+        )
+        out = extract_filament_requirements(f, plate_id=1)
+        assert out == [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "used_grams": 12.5},
+            {"slot_id": 2, "type": "PETG", "color": "#000000", "tray_info_idx": "", "used_grams": 4.2},
+        ]
+
+    def test_skips_zero_use_filaments(self, tmp_path: Path):
+        """Slot present in slice_info.config but `used_g <= 0` means the
+        plate doesn't actually consume that filament — must not show up."""
+        f = tmp_path / "model.3mf"
+        _make_3mf(
+            f,
+            plates=[
+                (
+                    1,
+                    [
+                        {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
+                        {"id": "2", "type": "ABS", "color": "#FF0000", "used_g": "0"},
+                        {"id": "3", "type": "PETG", "color": "#00FF00", "used_g": "-1"},
+                    ],
+                )
+            ],
+        )
+        out = extract_filament_requirements(f, plate_id=1)
+        assert [r["slot_id"] for r in out] == [1]
+
+    def test_filters_to_requested_plate(self, tmp_path: Path):
+        f = tmp_path / "multi.3mf"
+        _make_3mf(
+            f,
+            plates=[
+                (1, [{"id": "1", "type": "PLA", "color": "#FFF", "used_g": "5"}]),
+                (2, [{"id": "1", "type": "PETG", "color": "#000", "used_g": "5"}]),
+            ],
+        )
+        assert extract_filament_requirements(f, plate_id=1)[0]["type"] == "PLA"
+        assert extract_filament_requirements(f, plate_id=2)[0]["type"] == "PETG"
+
+    def test_no_plate_id_walks_flat_filaments(self, tmp_path: Path):
+        """When the slice_info.config has no plate wrapper (some older
+        Studio versions), we still pick up flat ``./filament`` children."""
+        f = tmp_path / "flat.3mf"
+        _make_3mf(
+            f,
+            flat_filaments=[{"id": "1", "type": "PLA", "color": "#FFF", "used_g": "5"}],
+        )
+        out = extract_filament_requirements(f, plate_id=None)
+        assert len(out) == 1
+        assert out[0]["type"] == "PLA"
+
+    def test_returns_empty_list_for_unparseable_file(self, tmp_path: Path):
+        f = tmp_path / "bad.3mf"
+        f.write_bytes(b"not a zip")
+        assert extract_filament_requirements(f, plate_id=1) == []
+
+    def test_returns_empty_list_for_missing_file(self, tmp_path: Path):
+        assert extract_filament_requirements(tmp_path / "nope.3mf", plate_id=1) == []
+
+    def test_returns_empty_list_when_slice_info_missing(self, tmp_path: Path):
+        """3MF without `Metadata/slice_info.config` (e.g. a model-only
+        export) must degrade gracefully."""
+        f = tmp_path / "no-config.3mf"
+        with zipfile.ZipFile(f, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+        assert extract_filament_requirements(f, plate_id=1) == []
+
+    def test_results_are_sorted_by_slot_id(self, tmp_path: Path):
+        f = tmp_path / "unordered.3mf"
+        _make_3mf(
+            f,
+            plates=[
+                (
+                    1,
+                    [
+                        {"id": "3", "type": "PLA", "color": "#FFF", "used_g": "1"},
+                        {"id": "1", "type": "PLA", "color": "#000", "used_g": "1"},
+                        {"id": "2", "type": "PLA", "color": "#F00", "used_g": "1"},
+                    ],
+                )
+            ],
+        )
+        out = extract_filament_requirements(f, plate_id=1)
+        assert [r["slot_id"] for r in out] == [1, 2, 3]

+ 227 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -4,12 +4,38 @@ Tests the virtual printer manager, FTP server, and SSDP server components.
 """
 """
 
 
 import asyncio
 import asyncio
+import json
+import zipfile
 from pathlib import Path
 from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
 
 
 
 
+def _write_3mf_with_filaments(file_path: Path, filaments: list[dict], plate_index: int = 1) -> None:
+    """Build a minimal 3MF zip with `Metadata/slice_info.config` carrying the
+    given per-slot filament entries. Each `filaments` dict needs `id`, `type`,
+    `color`, `used_g`. Used by the #1188 VP queue-mode tests below."""
+    filament_xml = "".join(
+        f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
+        f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
+        for f in filaments
+    )
+    config = (
+        '<?xml version="1.0" encoding="utf-8"?>'
+        "<config>"
+        f'<plate><metadata key="index" value="{plate_index}"/>'
+        f"{filament_xml}"
+        "</plate>"
+        "</config>"
+    )
+    with zipfile.ZipFile(file_path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", config)
+        # Plate gcode is referenced for plate-id detection in the VP path —
+        # presence is enough; contents don't matter.
+        zf.writestr(f"Metadata/plate_{plate_index}.gcode", "; gcode\n")
+
+
 class TestVirtualPrinterInstance:
 class TestVirtualPrinterInstance:
     """Tests for VirtualPrinterInstance class."""
     """Tests for VirtualPrinterInstance class."""
 
 
@@ -291,6 +317,207 @@ class TestVirtualPrinterInstance:
         queue_item = added_items[0]
         queue_item = added_items[0]
         assert queue_item.manual_start is True
         assert queue_item.manual_start is True
 
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
+        """#1188: VP queue-mode used to create PrintQueueItems with no
+        filament fields, so the scheduler fell through to model-only matching
+        and dispatched onto whatever printer was free regardless of loaded
+        colour. ``required_filament_types`` is populated unconditionally
+        (cheap, helps the scheduler validate type even without
+        ``force_color_match``) — pin that contract here."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=21,
+            name="Reqs",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800021",
+            auto_dispatch=True,
+            queue_force_color_match=False,  # off → only required_filament_types
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "multi.3mf"
+        _write_3mf_with_filaments(
+            file_path,
+            [
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"},
+                {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"},
+                # used_g=0 → not actually consumed by this plate, must be ignored
+                {"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"},
+            ],
+            plate_index=1,
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "multi"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        # Type-only fallback always populated. Sorted, deduped, no zero-use ABS.
+        assert queue_item.required_filament_types is not None
+        assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"]
+        # Setting off → no force_color_match overrides leaked.
+        assert queue_item.filament_overrides is None
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path):
+        """#1188 core fix: when the per-VP ``queue_force_color_match`` toggle
+        is on, every consumed slot lands as a ``filament_overrides`` entry
+        with ``force_color_match: true``. This is the field the scheduler
+        keys on (``print_scheduler.py:512``) — without it, slot-by-slot
+        type+color matching never runs."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=22,
+            name="ForceColor",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800022",
+            auto_dispatch=True,
+            queue_force_color_match=True,  # on
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "forced.3mf"
+        _write_3mf_with_filaments(
+            file_path,
+            [
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
+                {"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"},
+            ],
+            plate_index=1,
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "forced"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.filament_overrides is not None
+        overrides = json.loads(queue_item.filament_overrides)
+        assert overrides == [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
+            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
+        ]
+        # required_filament_types still populated alongside overrides.
+        assert json.loads(queue_item.required_filament_types) == ["PLA"]
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
+        """A malformed or fake-bytes 3MF must not crash the upload path —
+        we just write the queue item with no filament fields and let the
+        scheduler fall back to model-only matching (the pre-#1188 default).
+        Regression guard for the existing fake-bytes happy-path tests."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=23,
+            name="Unparseable",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800023",
+            auto_dispatch=True,
+            queue_force_color_match=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "bad.3mf"
+        file_path.write_bytes(b"not a real 3mf zip")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "bad"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        # No filament data extractable → both fields stay None (graceful
+        # fallback to model-only scheduling).
+        assert queue_item.required_filament_types is None
+        assert queue_item.filament_overrides is None
+
     # ========================================================================
     # ========================================================================
     # Tests for archive_name_source setting (#1152)
     # Tests for archive_name_source setting (#1152)
     # ========================================================================
     # ========================================================================

+ 96 - 0
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -45,6 +45,7 @@ const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): Virtu
   serial: '00M00A391800001',
   serial: '00M00A391800001',
   target_printer_id: null,
   target_printer_id: null,
   auto_dispatch: true,
   auto_dispatch: true,
+  queue_force_color_match: false,
   bind_ip: null,
   bind_ip: null,
   remote_interface_ip: null,
   remote_interface_ip: null,
   position: 0,
   position: 0,
@@ -135,6 +136,101 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
   });
   });
 });
 });
 
 
+// #1188 — VP queue mode now pins per-slot type+color so the scheduler refuses
+// to dispatch onto a printer with the wrong filament loaded. The toggle is
+// mode-gated to print_queue (mirroring the auto-dispatch toggle), defaults
+// off (preserves pre-fix behaviour for upgraders), and the click both flips
+// the local state and POSTs the new value to the backend.
+describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+  });
+
+  it('renders force-color-match toggle when mode is print_queue', async () => {
+    const printer = createMockPrinter({ mode: 'print_queue' });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Force color match')).toBeInTheDocument();
+    });
+  });
+
+  it('does not render force-color-match toggle when mode is immediate', async () => {
+    const printer = createMockPrinter({ mode: 'immediate' });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Test VP')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Force color match')).not.toBeInTheDocument();
+  });
+
+  it('does not render force-color-match toggle when mode is proxy', async () => {
+    const printer = createMockPrinter({ mode: 'proxy' });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Test VP')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Force color match')).not.toBeInTheDocument();
+  });
+
+  it('force-color-match toggle defaults off (not green) — preserves pre-fix behaviour', async () => {
+    const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: false });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Force color match')).toBeInTheDocument();
+    });
+
+    const title = screen.getByText('Force color match');
+    const section = title.closest('.flex.items-center.justify-between');
+    expect(section).toBeTruthy();
+    const toggleButton = section!.querySelector('button');
+    expect(toggleButton).toBeTruthy();
+    expect(toggleButton!.className).not.toContain('bg-bambu-green');
+  });
+
+  it('force-color-match toggle renders enabled (green) when queue_force_color_match is true', async () => {
+    const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: true });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Force color match')).toBeInTheDocument();
+    });
+
+    const title = screen.getByText('Force color match');
+    const section = title.closest('.flex.items-center.justify-between');
+    const toggleButton = section!.querySelector('button');
+    expect(toggleButton!.className).toContain('bg-bambu-green');
+  });
+
+  it('clicking force-color-match toggle posts queue_force_color_match in update body', async () => {
+    const user = userEvent.setup();
+    const printer = createMockPrinter({ mode: 'print_queue', queue_force_color_match: false });
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
+      createMockPrinter({ mode: 'print_queue', queue_force_color_match: true })
+    );
+
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Force color match')).toBeInTheDocument();
+    });
+
+    const title = screen.getByText('Force color match');
+    const section = title.closest('.flex.items-center.justify-between');
+    const toggleButton = section!.querySelector('button');
+
+    await user.click(toggleButton!);
+
+    await waitFor(() => {
+      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { queue_force_color_match: true });
+    });
+  });
+});
+
 describe('VirtualPrinterCard - tailscale toggle', () => {
 describe('VirtualPrinterCard - tailscale toggle', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();

+ 3 - 0
frontend/src/api/client.ts

@@ -5710,6 +5710,7 @@ export interface VirtualPrinterConfig {
   serial: string;
   serial: string;
   target_printer_id: number | null;
   target_printer_id: number | null;
   auto_dispatch: boolean;
   auto_dispatch: boolean;
+  queue_force_color_match: boolean;
   tailscale_disabled: boolean;
   tailscale_disabled: boolean;
   bind_ip: string | null;
   bind_ip: string | null;
   remote_interface_ip: string | null;
   remote_interface_ip: string | null;
@@ -5735,6 +5736,7 @@ export const multiVirtualPrinterApi = {
     access_code?: string;
     access_code?: string;
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
+    queue_force_color_match?: boolean;
     bind_ip?: string;
     bind_ip?: string;
     remote_interface_ip?: string;
     remote_interface_ip?: string;
   }) =>
   }) =>
@@ -5751,6 +5753,7 @@ export const multiVirtualPrinterApi = {
     access_code?: string;
     access_code?: string;
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
+    queue_force_color_match?: boolean;
     tailscale_disabled?: boolean;
     tailscale_disabled?: boolean;
     bind_ip?: string;
     bind_ip?: string;
     remote_interface_ip?: string;
     remote_interface_ip?: string;

+ 32 - 0
frontend/src/components/VirtualPrinterCard.tsx

@@ -43,6 +43,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
+  const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
@@ -99,6 +100,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
       setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
       setLocalModel(printer.model || '');
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
+      setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
     }
   }, [printer, pendingAction]);
   }, [printer, pendingAction]);
@@ -380,6 +382,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
               </div>
             )}
             )}
 
 
+            {/* Force-color-match toggle - only for print_queue mode (#1188) */}
+            {localMode === 'print_queue' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <div className="text-white text-sm font-medium">{t('virtualPrinter.queueForceColorMatch.title')}</div>
+                    <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.queueForceColorMatch.description')}</div>
+                  </div>
+                  <button
+                    onClick={() => {
+                      const newVal = !localQueueForceColorMatch;
+                      setLocalQueueForceColorMatch(newVal);
+                      setPendingAction('queueForceColorMatch');
+                      updateMutation.mutate({ queue_force_color_match: newVal });
+                    }}
+                    disabled={pendingAction === 'queueForceColorMatch'}
+                    className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
+                      localQueueForceColorMatch ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    } ${pendingAction === 'queueForceColorMatch' ? 'opacity-50' : ''}`}
+                  >
+                    <span
+                      className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                        localQueueForceColorMatch ? 'translate-x-5' : ''
+                      }`}
+                    />
+                  </button>
+                </div>
+              </div>
+            )}
+
             {/* Tailscale toggle */}
             {/* Tailscale toggle */}
             <div className="pt-2 border-t border-bambu-dark-tertiary">
             <div className="pt-2 border-t border-bambu-dark-tertiary">
               <div className="flex items-center justify-between gap-3">
               <div className="flex items-center justify-between gap-3">

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

@@ -4054,6 +4054,10 @@ export default {
       title: 'Automatisch starten',
       title: 'Automatisch starten',
       description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',
       description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',
     },
     },
+    queueForceColorMatch: {
+      title: 'Farbabgleich erzwingen',
+      description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale-Integration',
       title: 'Tailscale-Integration',
       description: 'Wenn aktiviert, werden Tailscale-zertifizierte TLS-Zertifikate verwendet. Deaktivieren für selbstsignierte Zertifikate.',
       description: 'Wenn aktiviert, werden Tailscale-zertifizierte TLS-Zertifikate verwendet. Deaktivieren für selbstsignierte Zertifikate.',

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

@@ -4063,6 +4063,10 @@ export default {
       title: 'Auto-dispatch',
       title: 'Auto-dispatch',
       description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',
       description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale integration',
       title: 'Tailscale integration',
       description: 'When enabled, uses Tailscale for trusted TLS certs. Disable to use self-signed cert only.',
       description: 'When enabled, uses Tailscale for trusted TLS certs. Disable to use self-signed cert only.',

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

@@ -4042,6 +4042,10 @@ export default {
       title: 'Lancement automatique',
       title: 'Lancement automatique',
       description: 'Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.',
       description: 'Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Intégration Tailscale',
       title: 'Intégration Tailscale',
       description: 'Lorsqu\'activé, utilise Tailscale pour des certificats TLS de confiance. Désactiver pour n\'utiliser que des certificats auto-signés.',
       description: 'Lorsqu\'activé, utilise Tailscale pour des certificats TLS de confiance. Désactiver pour n\'utiliser que des certificats auto-signés.',

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

@@ -4041,6 +4041,10 @@ export default {
       title: 'Avvio automatico',
       title: 'Avvio automatico',
       description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\'avvio manuale.',
       description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\'avvio manuale.',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integrazione Tailscale',
       title: 'Integrazione Tailscale',
       description: 'Quando abilitato, utilizza Tailscale per certificati TLS affidabili. Disabilita per utilizzare solo certificati auto-firmati.',
       description: 'Quando abilitato, utilizza Tailscale per certificati TLS affidabili. Disabilita per utilizzare solo certificati auto-firmati.',

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

@@ -4054,6 +4054,10 @@ export default {
       title: '自動ディスパッチ',
       title: '自動ディスパッチ',
       description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',
       description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale統合',
       title: 'Tailscale統合',
       description: '有効にすると、Tailscaleを使用して信頼できるTLS証明書を使用します。自己署名証明書のみを使用する場合は無効にします。',
       description: '有効にすると、Tailscaleを使用して信頼できるTLS証明書を使用します。自己署名証明書のみを使用する場合は無効にします。',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4041,6 +4041,10 @@ export default {
       title: 'Envio automático',
       title: 'Envio automático',
       description: 'Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.',
       description: 'Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integração Tailscale',
       title: 'Integração Tailscale',
       description: 'Quando ativado, usa Tailscale para certificados TLS confiáveis. Desative para usar apenas certificado autoassinado.',
       description: 'Quando ativado, usa Tailscale para certificados TLS confiáveis. Desative para usar apenas certificado autoassinado.',

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4042,6 +4042,10 @@ export default {
       title: '自动派发',
       title: '自动派发',
       description: '添加到队列时自动开始打印。关闭后,打印任务等待手动派发。',
       description: '添加到队列时自动开始打印。关闭后,打印任务等待手动派发。',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 集成',
       title: 'Tailscale 集成',
       description: '启用后,使用 Tailscale 获取受信任的 TLS 证书。禁用则仅使用自签名证书。',
       description: '启用后,使用 Tailscale 获取受信任的 TLS 证书。禁用则仅使用自签名证书。',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4042,6 +4042,10 @@ export default {
       title: '自動派發',
       title: '自動派發',
       description: '新增到佇列時自動開始列印。關閉後,列印任務等待手動派發。',
       description: '新增到佇列時自動開始列印。關閉後,列印任務等待手動派發。',
     },
     },
+    queueForceColorMatch: {
+      title: 'Force color match',
+      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong colour loaded.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 整合',
       title: 'Tailscale 整合',
       description: '啟用後,使用 Tailscale 取得受信任的 TLS 憑證。停用則僅使用自簽憑證。',
       description: '啟用後,使用 Tailscale 取得受信任的 TLS 憑證。停用則僅使用自簽憑證。',

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-BeWa0UNk.js


+ 1 - 1
static/index.html

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů