ソースを参照

fix(queue): enforce sliced-model compatibility on cross-model dispatch (#2578)

A queue item's "Any <model>" button labeled itself from the file's slice
metadata while the scheduler used the row's target_model, so an X1C-sliced
item targeting H2D showed "Any X1C" above "assign to first idle H2D". The
mismatch itself was created silently: sliced-for metadata loads async, and
switching to model mode before it arrived pre-selected the alphabetically
first model (H2D on a mixed farm), after which the model dropdown hid
itself. Nothing validated compatibility, so the scheduler would hand X1C
G-code to an H2D.

Frontend: never default the target silently, keep the dropdown visible in
model mode (incompatible models disabled), label from the actual target,
warn on mismatch, block submit when incompatible.

Backend: new GCODE_COMPAT_FAMILIES table (X1/X1C/X1E/P1P/P1S interchange;
everything else exact-match; missing metadata never blocks). Queue create
and update reject incompatible targets with 400; the scheduler holds back
pre-existing mismatched rows with an actionable waiting_reason instead of
dispatching them.
maziggy 1 ヶ月 前
コミット
e97413edc7

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.5b2] - Unreleased
 ## [1.2.5b2] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \<model\>" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again.
 - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled.
 - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled.
 - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push.
 - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push.
 - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo).
 - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo).

+ 38 - 1
backend/app/api/routes/print_queue.py

@@ -37,7 +37,11 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
-from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+from backend.app.utils.printer_models import (
+    is_gcode_compatible,
+    normalize_printer_model,
+    normalize_printer_model_id,
+)
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
     extract_bed_type_from_3mf,
     extract_bed_type_from_3mf,
     extract_filament_usage_from_3mf,
     extract_filament_usage_from_3mf,
@@ -466,6 +470,22 @@ async def add_to_queue(
         except InvalidFilenameError as e:
         except InvalidFilenameError as e:
             raise HTTPException(400, str(e)) from e
             raise HTTPException(400, str(e)) from e
 
 
+    # Cross-model safety gate (#2578): a G-code 3MF sliced for one model must
+    # not be queued for dispatch to an incompatible model. The UI can no longer
+    # produce such rows, but API-created rows must be rejected here too — the
+    # scheduler assigns model-based items to hardware with no human in the loop.
+    if target_model_norm:
+        sliced_for = None
+        if archive:
+            sliced_for = archive.sliced_for_model
+        elif library_file and library_file.file_metadata:
+            sliced_for = library_file.file_metadata.get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, target_model_norm):
+            raise HTTPException(
+                400,
+                f"File was sliced for {sliced_for} and cannot be dispatched to {target_model_norm} printers",
+            )
+
     # Extract filament types for model-based assignment (used by scheduler for validation)
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None
     required_filament_types = None
     file_path = None
     file_path = None
@@ -1099,6 +1119,23 @@ async def update_queue_item(
         if not result.scalars().first():
         if not result.scalars().first():
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
 
 
+        # Cross-model safety gate (#2578) — same check as the create route, so
+        # a mismatched target can't be introduced by editing either.
+        sliced_for = None
+        if item.archive_id:
+            result = await db.execute(select(PrintArchive.sliced_for_model).where(PrintArchive.id == item.archive_id))
+            sliced_for = result.scalar_one_or_none()
+        elif item.library_file_id:
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            lib = result.scalar_one_or_none()
+            if lib and lib.file_metadata:
+                sliced_for = lib.file_metadata.get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, update_data["target_model"]):
+            raise HTTPException(
+                400,
+                f"File was sliced for {sliced_for} and cannot be dispatched to {update_data['target_model']} printers",
+            )
+
     # Serialize ams_mapping to JSON for TEXT column storage
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None

+ 40 - 10
backend/app/services/print_scheduler.py

@@ -43,7 +43,7 @@ from backend.app.services.printer_manager import (
 )
 )
 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.filename import derive_remote_filename
 from backend.app.utils.filename import derive_remote_filename
-from backend.app.utils.printer_models import normalize_printer_model
+from backend.app.utils.printer_models import is_gcode_compatible, normalize_printer_model
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -290,6 +290,13 @@ class PrintScheduler:
                 result = await db.execute(
                 result = await db.execute(
                     select(PrintQueueItem)
                     select(PrintQueueItem)
                     .where(PrintQueueItem.status == "pending")
                     .where(PrintQueueItem.status == "pending")
+                    # archive/library_file are read by the cross-model gate
+                    # (#2578); eager-load once per pass instead of a lazy-load
+                    # (which would raise in async) per item.
+                    .options(
+                        selectinload(PrintQueueItem.archive),
+                        selectinload(PrintQueueItem.library_file),
+                    )
                     .order_by(
                     .order_by(
                         PrintQueueItem.printer_id,
                         PrintQueueItem.printer_id,
                         PrintQueueItem.target_model,
                         PrintQueueItem.target_model,
@@ -302,6 +309,10 @@ class PrintScheduler:
                 result = await db.execute(
                 result = await db.execute(
                     select(PrintQueueItem)
                     select(PrintQueueItem)
                     .where(PrintQueueItem.status == "pending")
                     .where(PrintQueueItem.status == "pending")
+                    .options(
+                        selectinload(PrintQueueItem.archive),
+                        selectinload(PrintQueueItem.library_file),
+                    )
                     .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
                     .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
                 )
                 )
             items = list(result.scalars().all())
             items = list(result.scalars().all())
@@ -568,15 +579,34 @@ class PrintScheduler:
                             # Merge: keep original types for non-overridden slots, add override types
                             # Merge: keep original types for non-overridden slots, add override types
                             effective_types = sorted(set(required_types or []) | set(override_types))
                             effective_types = sorted(set(required_types or []) | set(override_types))
 
 
-                    printer_id, waiting_reason = await self._find_idle_printer_for_model(
-                        db,
-                        item.target_model,
-                        busy_printers,
-                        effective_types,
-                        item.target_location,
-                        filament_overrides=filament_overrides,
-                        require_plate_clear=require_plate_clear,
-                    )
+                    # Cross-model safety gate (#2578): never hand a 3MF sliced
+                    # for an incompatible model to a printer, no matter how the
+                    # row got into the DB (old rows, direct API writes). Held
+                    # as pending with an actionable waiting_reason — the user
+                    # fixes it by editing the item's target model.
+                    sliced_for = None
+                    if item.archive:
+                        sliced_for = item.archive.sliced_for_model
+                    elif item.library_file and item.library_file.file_metadata:
+                        sliced_for = item.library_file.file_metadata.get("sliced_for_model")
+
+                    if not is_gcode_compatible(sliced_for, item.target_model):
+                        printer_id = None
+                        waiting_reason = (
+                            f"File was sliced for {sliced_for}, which is not compatible with "
+                            f"{item.target_model} — edit the item and fix its target model"
+                        )
+                        skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1
+                    else:
+                        printer_id, waiting_reason = await self._find_idle_printer_for_model(
+                            db,
+                            item.target_model,
+                            busy_printers,
+                            effective_types,
+                            item.target_location,
+                            filament_overrides=filament_overrides,
+                            require_plate_clear=require_plate_clear,
+                        )
 
 
                     # Update waiting_reason if changed and send notification when first waiting
                     # Update waiting_reason if changed and send notification when first waiting
                     if item.waiting_reason != waiting_reason:
                     if item.waiting_reason != waiting_reason:

+ 35 - 0
backend/app/utils/printer_models.py

@@ -281,6 +281,41 @@ def get_rod_type(model: str | None) -> str | None:
     return None
     return None
 
 
 
 
+# G-code interchange families (#2578). A sliced 3MF may target a different
+# model ONLY within its family: same kinematics, build volume and G-code
+# dialect. X1/P1 series are the one proven-interchangeable group (256mm
+# CoreXY, single nozzle — mixed farms intentionally run X1-sliced jobs on
+# P1S/P1P). Everything else is exact-match only; extend deliberately, never
+# by assumption — a wrong entry here dispatches G-code onto hardware it was
+# not sliced for.
+# Short display names only (uppercase, no spaces) — is_gcode_compatible()
+# resolves internal codes (C11, O1D, ...) to short names before lookup.
+GCODE_COMPAT_FAMILIES = (frozenset(["X1", "X1C", "X1E", "P1P", "P1S"]),)
+
+
+def is_gcode_compatible(sliced_for_model: str | None, target_model: str | None) -> bool:
+    """Return True when G-code sliced for one model may be dispatched to the other.
+
+    Unknown/missing metadata on either side returns True — we can only
+    validate what the 3MF declares, and legacy files without
+    ``sliced_for_model`` must keep working.
+    """
+    if not sliced_for_model or not target_model:
+        return True
+
+    def _norm(model: str) -> str:
+        # Internal codes (e.g. "C11") → short names first, so "C11" vs "X1C"
+        # compares equal instead of leaning on family membership.
+        resolved = PRINTER_MODEL_ID_MAP.get(model.strip(), model)
+        return resolved.strip().upper().replace(" ", "").replace("-", "")
+
+    a = _norm(sliced_for_model)
+    b = _norm(target_model)
+    if a == b:
+        return True
+    return any(a in family and b in family for family in GCODE_COMPAT_FAMILIES)
+
+
 def normalize_printer_model_id(model_id: str | None) -> str | None:
 def normalize_printer_model_id(model_id: str | None) -> str | None:
     """Convert printer_model_id (internal code) to normalized short name.
     """Convert printer_model_id (internal code) to normalized short name.
 
 

+ 100 - 0
backend/tests/integration/test_print_queue_api.py

@@ -848,6 +848,22 @@ class TestQueueLibraryFileSupport:
         assert result["library_file_name"] == "Library Print 1"
         assert result["library_file_name"] == "Library Print 1"
         assert result["print_time_seconds"] == 3600
         assert result["print_time_seconds"] == 3600
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_library_file_rejects_cross_model_mismatch(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Cross-model gate (#2578) also reads sliced_for_model from library file metadata."""
+        await printer_factory(model="H2D")
+        lib_file = await library_file_factory(file_metadata={"print_name": "Mismatch", "sliced_for_model": "X1C"})
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "H2D", "library_file_id": lib_file.id},
+        )
+        assert response.status_code == 400
+        assert "sliced for X1C" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_library_file_with_options(
     async def test_add_to_queue_library_file_with_options(
@@ -1401,6 +1417,90 @@ class TestTargetLocationFeature:
         result = response.json()
         result = response.json()
         assert result["target_location"] is None
         assert result["target_location"] is None
 
 
+    # ------------------------------------------------------------------
+    # Cross-model dispatch gate (#2578): a G-code 3MF sliced for one model
+    # must not be queued for model-based dispatch to an incompatible model.
+    # ------------------------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_rejects_cross_model_mismatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """X1C-sliced archive + target_model=H2D must be rejected (#2578)."""
+        await printer_factory(model="H2D")
+        archive = await archive_factory(sliced_for_model="X1C")
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "H2D", "archive_id": archive.id},
+        )
+        assert response.status_code == 400
+        assert "sliced for X1C" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_allows_gcode_family_target(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """X1C-sliced G-code on a P1S is an intentional mixed-farm workflow —
+        same kinematics/volume family, must stay allowed."""
+        await printer_factory(model="P1S")
+        archive = await archive_factory(sliced_for_model="X1C")
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "P1S", "archive_id": archive.id},
+        )
+        assert response.status_code == 200
+        assert response.json()["target_model"] == "P1S"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_without_sliced_metadata_not_blocked(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Legacy archives without sliced_for_model can't be validated — must keep working."""
+        await printer_factory(model="H2D")
+        archive = await archive_factory()  # no sliced_for_model
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "H2D", "archive_id": archive.id},
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_cross_model_mismatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Editing an item must not be able to introduce an incompatible target either."""
+        await printer_factory(model="X1C")
+        await printer_factory(model="H2D")
+        archive = await archive_factory(sliced_for_model="X1C")
+        item = await queue_item_factory(printer_id=None, target_model="X1C", archive_id=archive.id)
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "H2D"})
+        assert response.status_code == 400
+        assert "sliced for X1C" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_can_fix_stale_mismatched_target(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """A pre-fix DB row with a wrong target (the reporter's rows 78-82) must be
+        repairable by editing the target back to the sliced-for model."""
+        await printer_factory(model="X1C")
+        archive = await archive_factory(sliced_for_model="X1C")
+        # Stale mismatched row written directly to the DB (bypasses the API gate)
+        item = await queue_item_factory(printer_id=None, target_model="H2D", archive_id=archive.id)
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "X1C"})
+        assert response.status_code == 200
+        assert response.json()["target_model"] == "X1C"
+
 
 
 class TestAbortedStatusNormalisation:
 class TestAbortedStatusNormalisation:
     """Tests for issue #558: 'aborted' queue status causes 500 error."""
     """Tests for issue #558: 'aborted' queue status causes 500 error."""

+ 230 - 0
backend/tests/unit/test_scheduler_model_mismatch.py

@@ -0,0 +1,230 @@
+"""Cross-model dispatch gate (#2578).
+
+A queue row can carry a target_model that does not match the model its 3MF was
+sliced for (pre-fix UI wrote such rows silently; direct API writes could too).
+G-code is only interchangeable within an explicit family — everything else must
+be held back at the dispatch boundary, because model-based assignment has no
+human in the loop to catch it.
+
+Covers the pure helper (``is_gcode_compatible``) and the scheduler behaviour:
+a mismatched pending item is never offered a printer and gets an actionable
+``waiting_reason`` instead of being dispatched.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.utils.printer_models import is_gcode_compatible
+
+# ---------------------------------------------------------------------------
+# is_gcode_compatible
+# ---------------------------------------------------------------------------
+
+
+def test_same_model_is_compatible():
+    assert is_gcode_compatible("X1C", "X1C")
+    assert is_gcode_compatible("H2D", "H2D")
+
+
+def test_x1_p1_family_is_interchangeable():
+    # Intentional mixed-farm workflow: X1-sliced jobs on P1S/P1P and back.
+    assert is_gcode_compatible("X1C", "P1S")
+    assert is_gcode_compatible("X1C", "P1P")
+    assert is_gcode_compatible("P1S", "X1E")
+    assert is_gcode_compatible("X1", "P1P")
+
+
+def test_cross_family_is_blocked():
+    # The reporter's case: X1C-sliced G-code targeted at an H2D.
+    assert not is_gcode_compatible("X1C", "H2D")
+    assert not is_gcode_compatible("P1S", "H2D")
+    assert not is_gcode_compatible("X1C", "A1")
+    assert not is_gcode_compatible("A1", "A1 Mini")
+    assert not is_gcode_compatible("H2D", "H2S")
+    assert not is_gcode_compatible("X1C", "P2S")
+
+
+def test_unknown_metadata_is_failsafe_compatible():
+    # Legacy files without sliced_for_model can't be validated — never block.
+    assert is_gcode_compatible(None, "H2D")
+    assert is_gcode_compatible("X1C", None)
+    assert is_gcode_compatible(None, None)
+    assert is_gcode_compatible("", "H2D")
+
+
+def test_normalization_spaces_dashes_case():
+    assert is_gcode_compatible("x1c", "X1C")
+    assert is_gcode_compatible("A1 Mini", "A1-MINI")
+    assert is_gcode_compatible("H2D Pro", "H2DPRO")
+
+
+def test_internal_codes_resolve_to_short_names():
+    # slice_info printer_model_id codes compare equal to their short names.
+    assert is_gcode_compatible("C11", "X1C")
+    assert is_gcode_compatible("O1D", "H2D")
+    assert is_gcode_compatible("C11", "P1S")  # X1C → family with P1S
+    assert not is_gcode_compatible("C11", "H2D")
+
+
+# ---------------------------------------------------------------------------
+# Scheduler: mismatched rows are held back, not dispatched
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def queue_db():
+    """In-memory DB seeded with one idle H2D printer."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add(
+            Printer(
+                id=1,
+                name="H2D-1",
+                serial_number="H2D0001",
+                ip_address="10.0.0.1",
+                access_code="x",
+                model="H2D",
+                is_active=True,
+            )
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_model_item(ctx, *, target_model, sliced_for_model=None, library_meta=None):
+    """Seed one pending model-based queue item, archive- or library-backed."""
+    async with ctx.session_maker() as db:
+        if library_meta is not None:
+            source = LibraryFile(
+                filename="job.3mf",
+                file_path="/library/job.3mf",
+                file_size=10,
+                file_type="3mf",
+                file_metadata=library_meta,
+            )
+            db.add(source)
+            await db.flush()
+            item = PrintQueueItem(library_file_id=source.id, target_model=target_model, status="pending", position=1)
+        else:
+            source = PrintArchive(
+                filename="job.3mf",
+                file_path="archives/job.3mf",
+                file_size=10,
+                status="completed",
+                sliced_for_model=sliced_for_model,
+            )
+            db.add(source)
+            await db.flush()
+            item = PrintQueueItem(archive_id=source.id, target_model=target_model, status="pending", position=1)
+        db.add(item)
+        await db.commit()
+        return item.id
+
+
+async def _run_check_queue(ctx, scheduler, finder, waiting_notification):
+    patches = [
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+            waiting_notification,
+        ),
+        patch.object(scheduler, "_find_idle_printer_for_model", finder),
+        patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        return await scheduler.check_queue()
+
+
+async def _get_item(ctx, item_id):
+    async with ctx.session_maker() as db:
+        return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+@pytest.mark.asyncio
+async def test_mismatched_item_is_held_and_never_offered_a_printer(queue_db):
+    """target_model=H2D on an X1C-sliced archive: the matcher must not even run."""
+    item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model="X1C")
+    scheduler = PrintScheduler()
+    finder = AsyncMock(return_value=(1, None))  # would happily offer the H2D
+    waiting = AsyncMock()
+
+    await _run_check_queue(queue_db, scheduler, finder, waiting)
+
+    finder.assert_not_awaited()
+    item = await _get_item(queue_db, item_id)
+    assert item.status == "pending"
+    assert item.printer_id is None
+    assert "sliced for X1C" in item.waiting_reason
+    # Actionable reason → the user is notified once on transition
+    waiting.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_mismatched_library_item_is_held(queue_db):
+    """Same gate for library-file-backed rows (metadata JSON, not a column)."""
+    item_id = await _add_model_item(queue_db, target_model="H2D", library_meta={"sliced_for_model": "P1S"})
+    scheduler = PrintScheduler()
+    finder = AsyncMock(return_value=(1, None))
+
+    await _run_check_queue(queue_db, scheduler, finder, AsyncMock())
+
+    finder.assert_not_awaited()
+    item = await _get_item(queue_db, item_id)
+    assert item.status == "pending"
+    assert "sliced for P1S" in item.waiting_reason
+
+
+@pytest.mark.asyncio
+async def test_compatible_and_unknown_items_still_reach_the_matcher(queue_db):
+    """Family-compatible (X1C→P1S would be, but here same-model) and
+    metadata-less rows must keep flowing to the printer matcher."""
+    item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model="H2D")
+    scheduler = PrintScheduler()
+    # Matcher returns no printer so the pass stops after the gate — all we
+    # assert is that the gate let the item through.
+    finder = AsyncMock(return_value=(None, "Busy: H2D-1 (Printing)"))
+
+    await _run_check_queue(queue_db, scheduler, finder, AsyncMock())
+
+    finder.assert_awaited_once()
+    item = await _get_item(queue_db, item_id)
+    assert item.status == "pending"
+    assert item.waiting_reason == "Busy: H2D-1 (Printing)"
+
+
+@pytest.mark.asyncio
+async def test_legacy_item_without_metadata_reaches_the_matcher(queue_db):
+    item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model=None)
+    scheduler = PrintScheduler()
+    finder = AsyncMock(return_value=(None, "Busy: H2D-1 (Printing)"))
+
+    await _run_check_queue(queue_db, scheduler, finder, AsyncMock())
+
+    finder.assert_awaited_once()
+    item = await _get_item(queue_db, item_id)
+    assert item.status == "pending"

+ 40 - 1
frontend/src/__tests__/utils/printer.test.ts

@@ -8,7 +8,7 @@
  */
  */
 
 
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
-import { getPrinterImage } from '../../utils/printer';
+import { getPrinterImage, isGcodeCompatible } from '../../utils/printer';
 
 
 describe('getPrinterImage', () => {
 describe('getPrinterImage', () => {
   describe('X2D (#988)', () => {
   describe('X2D (#988)', () => {
@@ -97,3 +97,42 @@ describe('getPrinterImage', () => {
     });
     });
   });
   });
 });
 });
+
+// Mirrors backend/tests/unit/test_scheduler_model_mismatch.py — the frontend
+// table must stay in sync with backend GCODE_COMPAT_FAMILIES (#2578).
+describe('isGcodeCompatible', () => {
+  it('accepts same model', () => {
+    expect(isGcodeCompatible('X1C', 'X1C')).toBe(true);
+    expect(isGcodeCompatible('H2D', 'H2D')).toBe(true);
+  });
+
+  it('accepts the X1/P1 interchange family', () => {
+    expect(isGcodeCompatible('X1C', 'P1S')).toBe(true);
+    expect(isGcodeCompatible('X1C', 'P1P')).toBe(true);
+    expect(isGcodeCompatible('P1S', 'X1E')).toBe(true);
+    expect(isGcodeCompatible('X1', 'P1P')).toBe(true);
+  });
+
+  it('rejects cross-family targets', () => {
+    // The reporter's case: X1C-sliced G-code targeted at an H2D (#2578)
+    expect(isGcodeCompatible('X1C', 'H2D')).toBe(false);
+    expect(isGcodeCompatible('P1S', 'H2D')).toBe(false);
+    expect(isGcodeCompatible('X1C', 'A1')).toBe(false);
+    expect(isGcodeCompatible('A1', 'A1 Mini')).toBe(false);
+    expect(isGcodeCompatible('H2D', 'H2S')).toBe(false);
+    expect(isGcodeCompatible('X1C', 'P2S')).toBe(false);
+  });
+
+  it('is fail-safe on unknown metadata', () => {
+    expect(isGcodeCompatible(null, 'H2D')).toBe(true);
+    expect(isGcodeCompatible('X1C', null)).toBe(true);
+    expect(isGcodeCompatible(undefined, undefined)).toBe(true);
+    expect(isGcodeCompatible('', 'H2D')).toBe(true);
+  });
+
+  it('normalizes case, spaces and dashes', () => {
+    expect(isGcodeCompatible('x1c', 'X1C')).toBe(true);
+    expect(isGcodeCompatible('A1 Mini', 'A1-MINI')).toBe(true);
+    expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
+  });
+});

+ 64 - 24
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -13,6 +13,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, type PrinterStatus } from '../../api/client';
 import { api, type PrinterStatus } from '../../api/client';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
+import { isGcodeCompatible } from '../../utils/printer';
 import {
 import {
   normalizeColorForCompare,
   normalizeColorForCompare,
   colorsAreSimilar,
   colorsAreSimilar,
@@ -409,10 +410,15 @@ export function PrinterSelector({
             onClick={() => {
             onClick={() => {
               onAssignmentModeChange!('model');
               onAssignmentModeChange!('model');
               onMultiSelect([]);
               onMultiSelect([]);
-              // Pre-select the sliced-for model if available, otherwise first model
+              // Pre-select the sliced-for model when it has printers. NEVER
+              // silently fall back to another model (#2578): uniqueModels is
+              // alphabetically sorted, so the old `uniqueModels[0]` default
+              // quietly targeted e.g. H2D for an X1C-sliced file whenever the
+              // metadata hadn't loaded yet. Leave it unset and let the user
+              // pick from the dropdown instead.
               const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
               const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
                 ? slicedForModel
                 ? slicedForModel
-                : uniqueModels[0];
+                : null;
               onTargetModelChange!(defaultModel);
               onTargetModelChange!(defaultModel);
             }}
             }}
             className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
             className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
@@ -422,7 +428,12 @@ export function PrinterSelector({
             }`}
             }`}
           >
           >
             <Users className="w-4 h-4" />
             <Users className="w-4 h-4" />
-            <span className="text-sm">Any {slicedForModel || 'Model'}</span>
+            {/* In model mode the label must reflect the ACTUAL scheduler
+                target, not the slice metadata — an edited queue item can
+                target a different model than the file was sliced for (#2578). */}
+            <span className="text-sm">
+              Any {(assignmentMode === 'model' ? targetModel : null) || slicedForModel || 'Model'}
+            </span>
           </button>
           </button>
         </div>
         </div>
       )}
       )}
@@ -430,29 +441,58 @@ export function PrinterSelector({
       {/* Model selection and location filter (when in model mode) */}
       {/* Model selection and location filter (when in model mode) */}
       {assignmentMode === 'model' && modelAssignmentAvailable && (
       {assignmentMode === 'model' && modelAssignmentAvailable && (
         <div className="space-y-3 mb-4">
         <div className="space-y-3 mb-4">
-          {/* Model selector — only show when sliced model is unknown */}
-          {!slicedForModel && (
-            <div>
-              <label className="block text-xs text-bambu-gray mb-1">Target Model</label>
-              <select
-                value={targetModel || ''}
-                onChange={(e) => {
-                  onTargetModelChange!(e.target.value || null);
-                  // Clear location when model changes
-                  if (onTargetLocationChange) {
-                    onTargetLocationChange(null);
-                  }
-                }}
-                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
-              >
-                <option value="">Select a model...</option>
-                {uniqueModels.map((model) => (
-                  <option key={model} value={model}>
+          {/* Model selector — always visible in model mode so a wrong target
+              on an existing queue item can be seen and fixed (#2578).
+              Incompatible models are disabled: G-code sliced for one model
+              must only go to that model or its interchange family. */}
+          <div>
+            <label className="block text-xs text-bambu-gray mb-1">
+              Target Model
+              {slicedForModel && <span className="ml-2 text-bambu-gray/70">(sliced for {slicedForModel})</span>}
+            </label>
+            <select
+              value={targetModel || ''}
+              onChange={(e) => {
+                onTargetModelChange!(e.target.value || null);
+                // Clear location when model changes
+                if (onTargetLocationChange) {
+                  onTargetLocationChange(null);
+                }
+              }}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+            >
+              <option value="">Select a model...</option>
+              {uniqueModels.map((model) => {
+                const compatible = isGcodeCompatible(slicedForModel, model);
+                return (
+                  <option key={model} value={model} disabled={!compatible}>
                     {model}
                     {model}
+                    {!compatible ? ` — incompatible with ${slicedForModel} G-code` : ''}
                   </option>
                   </option>
-                ))}
-              </select>
-            </div>
+                );
+              })}
+            </select>
+          </div>
+
+          {/* Cross-model state on an existing row: same file/target mismatch
+              the specific-printer path already warns about (#2578). */}
+          {slicedForModel && targetModel && targetModel !== slicedForModel && (
+            isGcodeCompatible(slicedForModel, targetModel) ? (
+              <div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2">
+                <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
+                <span className="text-sm text-yellow-700 dark:text-yellow-400">
+                  File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer
+                </span>
+              </div>
+            ) : (
+              <div className="p-3 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg flex items-center gap-2">
+                <AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
+                <span className="text-sm text-red-700 dark:text-red-400">
+                  File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers —
+                  select a compatible model
+                </span>
+              </div>
+            )
           )}
           )}
 
 
           {/* Location filter (only show when target model is selected and locations exist) */}
           {/* Location filter (only show when target model is selected and locations exist) */}

+ 21 - 0
frontend/src/components/PrintModal/index.tsx

@@ -17,6 +17,7 @@ import {
 } from '../../hooks/useFilamentMapping';
 } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
+import { isGcodeCompatible } from '../../utils/printer';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
@@ -576,6 +577,17 @@ export function PrintModal({
     }
     }
   }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
   }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
 
 
+  // The sliced-for metadata loads async. If the user switched to model mode
+  // before it arrived, the target is still empty (we never silently default
+  // to another model, #2578) — fill it with the sliced-for model once known,
+  // provided an active printer of that model exists.
+  useEffect(() => {
+    if (assignmentMode !== 'model' || targetModel || !slicedForModel) return;
+    if (printers?.some((p) => p.is_active && p.model === slicedForModel)) {
+      setTargetModel(slicedForModel);
+    }
+  }, [assignmentMode, targetModel, slicedForModel, printers]);
+
   // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
   // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
   // Only applies once per printer on initial selection, not when user unchecks
   // Only applies once per printer on initial selection, not when user unchecks
   useEffect(() => {
   useEffect(() => {
@@ -781,6 +793,12 @@ export function PrintModal({
       showToast('Please select a target printer model', 'error');
       showToast('Please select a target printer model', 'error');
       return;
       return;
     }
     }
+    // Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
+    // gets inline feedback instead of a failed request.
+    if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
+      showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
+      return;
+    }
 
 
     setIsSubmitting(true);
     setIsSubmitting(true);
     // Calculate total API calls: plates × printers (or 1 for model-based)
     // Calculate total API calls: plates × printers (or 1 for model-based)
@@ -1070,6 +1088,8 @@ export function PrintModal({
     // Need valid printer/model selection
     // Need valid printer/model selection
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     if (assignmentMode === 'model' && !targetModel) return false;
     if (assignmentMode === 'model' && !targetModel) return false;
+    // Cross-model mismatch cannot be queued (#2578)
+    if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
 
 
     // For multi-plate files, need at least one plate selected
     // For multi-plate files, need at least one plate selected
     if (isMultiPlate && selectedPlates.size === 0) return false;
     if (isMultiPlate && selectedPlates.size === 0) return false;
@@ -1085,6 +1105,7 @@ export function PrintModal({
     selectedPrinters.length,
     selectedPrinters.length,
     assignmentMode,
     assignmentMode,
     targetModel,
     targetModel,
+    slicedForModel,
     isMultiPlate,
     isMultiPlate,
     selectedPlates.size,
     selectedPlates.size,
     isPending,
     isPending,

+ 22 - 0
frontend/src/utils/printer.ts

@@ -18,6 +18,28 @@ export function getPrinterImage(model: string | null | undefined): string {
   return '/img/printers/default.png';
   return '/img/printers/default.png';
 }
 }
 
 
+// G-code interchange families (#2578). Mirrors backend GCODE_COMPAT_FAMILIES
+// in backend/app/utils/printer_models.py — keep the two in sync. A sliced 3MF
+// may target a different model ONLY within its family; everything else is
+// exact-match only.
+const GCODE_COMPAT_FAMILIES: ReadonlyArray<ReadonlySet<string>> = [
+  new Set(['X1', 'X1C', 'X1E', 'P1P', 'P1S']),
+];
+
+/** True when G-code sliced for one model may be dispatched to the other.
+ *  Unknown/missing metadata on either side returns true (can't validate). */
+export function isGcodeCompatible(
+  slicedForModel: string | null | undefined,
+  targetModel: string | null | undefined,
+): boolean {
+  if (!slicedForModel || !targetModel) return true;
+  const norm = (m: string) => m.trim().toUpperCase().replace(/[\s-]/g, '');
+  const a = norm(slicedForModel);
+  const b = norm(targetModel);
+  if (a === b) return true;
+  return GCODE_COMPAT_FAMILIES.some((family) => family.has(a) && family.has(b));
+}
+
 export function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
 export function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
   if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
   if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
   if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };
   if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-Cf3DYu3Y.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-CssD7OeM.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cf3DYu3Y.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-UoLGEHs-.css">
     <link rel="stylesheet" crossorigin href="/assets/index-UoLGEHs-.css">
   </head>
   </head>
   <body>
   <body>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません