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

Resolve dispatch plate_id from the archive instead of assuming 1 (#2951)

sgiffhorn 3 дней назад
Родитель
Сommit
89dde591f2

+ 89 - 7
backend/app/services/print_scheduler.py

@@ -5,6 +5,7 @@ import json
 import logging
 import logging
 import time
 import time
 import uuid
 import uuid
+import zipfile
 from collections import deque
 from collections import deque
 from collections.abc import Mapping
 from collections.abc import Mapping
 from dataclasses import dataclass
 from dataclasses import dataclass
@@ -74,8 +75,10 @@ from backend.app.utils.printer_models import (
     normalize_printer_model,
     normalize_printer_model,
 )
 )
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    default_plate_number,
     extract_rack_plan_from_3mf,
     extract_rack_plan_from_3mf,
     extract_slot_extruders_from_3mf,
     extract_slot_extruders_from_3mf,
+    select_plate_gcode_name,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -853,6 +856,77 @@ def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str
     )
     )
 
 
 
 
+def _effective_plate_id(explicit_plate_id: int | None, file_path: Path) -> int:
+    """The plate to dispatch, resolved once in ``_start_print`` and reused at
+    every call site below it: G-code injection, usage registration, rack-plan
+    lookup, slot-extruder lookup, the external-spool check, and the actual
+    print command.
+
+    A positive explicit ``plate_id`` on the queue item always wins. A
+    non-positive one is treated as "not set" and resolved from the archive,
+    the way the rest of the queue code already reads it (``if item.plate_id:``
+    in ``api/routes/print_queue.py``): the schemas put no lower bound on the
+    field, and passing a 0 straight through would build a print command for
+    ``Metadata/plate_0.gcode``, which is the same wedge this function exists
+    to prevent. The ``item.plate_id or 1`` this replaced mapped 0 to 1.
+
+    Falling back to a bare ``1`` instead of reading the archive assumes a
+    single-plate file's one G-code is numbered 1, which only holds for a
+    plate exported on its own: one cut out of a larger project keeps its
+    ORIGINAL plate number, so a printer asked to print "plate 1" of a file
+    whose only G-code is ``plate_2.gcode`` accepts the command, can't find
+    the file, throws an HMS error, and sits wedged in IDLE until
+    power-cycled (#2947).
+
+    The call sites agreed on a fallback only by accident before this:
+    with G-code injection on, ``inject_gcode_into_3mf`` already falls back to
+    the archive's own default plate internally whenever the plate id it's
+    handed isn't in the file, so it could silently inject into a different
+    plate than the one the print command itself asked for.
+
+    Falls back to 1 when the archive can't be read, holds no G-code member at
+    all, or its default member doesn't follow the ``plate_N`` naming
+    convention (a slicer that doesn't use it has no number to dispatch).
+
+    An explicit plate the archive doesn't hold is logged and then sent
+    anyway. It wedges the printer exactly like #2947 did, but redirecting it
+    to a plate that is in the file would print a model nobody asked for,
+    which is the worse of the two.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+    except (OSError, zipfile.BadZipFile) as exc:
+        logger.warning(
+            "Dispatch plate: cannot read %s (%s), so the archive's own plate numbering "
+            "is unavailable; dispatching plate %s",
+            file_path,
+            exc,
+            explicit_plate_id if explicit_plate_id is not None and explicit_plate_id > 0 else 1,
+        )
+        names = None
+
+    if explicit_plate_id is not None and explicit_plate_id > 0:
+        if (
+            names is not None
+            and default_plate_number(names) is not None
+            and select_plate_gcode_name(names, explicit_plate_id) is None
+        ):
+            logger.warning(
+                "Dispatch plate: %s was queued for plate %s but holds no G-code for it "
+                "(it has %s). Sending plate %s as asked; expect the printer to reject the "
+                "file, since printing a different plate would print the wrong model (#2947)",
+                file_path,
+                explicit_plate_id,
+                ", ".join(sorted(n for n in names if n.endswith(".gcode"))),
+                explicit_plate_id,
+            )
+        return explicit_plate_id
+
+    resolved = default_plate_number(names) if names is not None else None
+    return resolved if resolved is not None else 1
+
+
 class PrintScheduler:
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
     """Background scheduler that processes the print queue."""
 
 
@@ -6833,6 +6907,10 @@ class PrintScheduler:
             logger.info("Queue item %s: dispatch abandoned — cancelled during preheat", item.id)
             logger.info("Queue item %s: dispatch abandoned — cancelled during preheat", item.id)
             return
             return
 
 
+        # See `_effective_plate_id` for why this is resolved once here rather
+        # than each site below repeating `item.plate_id or 1`.
+        effective_plate_id = _effective_plate_id(item.plate_id, file_path)
+
         # G-code injection for auto-print systems (#422)
         # G-code injection for auto-print systems (#422)
         injected_path = None
         injected_path = None
         # #2547: tracked separately from `injected_path`, which is also set when
         # #2547: tracked separately from `injected_path`, which is also set when
@@ -6851,7 +6929,7 @@ class PrintScheduler:
                         from backend.app.utils.threemf_tools import inject_gcode_into_3mf
                         from backend.app.utils.threemf_tools import inject_gcode_into_3mf
 
 
                         injected_path = inject_gcode_into_3mf(
                         injected_path = inject_gcode_into_3mf(
-                            file_path, item.plate_id or 1, start_gc or None, end_gc or None
+                            file_path, effective_plate_id, start_gc or None, end_gc or None
                         )
                         )
                         if injected_path:
                         if injected_path:
                             file_path = injected_path
                             file_path = injected_path
@@ -7052,7 +7130,11 @@ class PrintScheduler:
                 ams_mapping=ams_mapping,
                 ams_mapping=ams_mapping,
                 created_by_id=item.created_by_id,
                 created_by_id=item.created_by_id,
                 cost_center_id=item.cost_center_id,
                 cost_center_id=item.cost_center_id,
-                plate_id=item.plate_id,
+                # The plate actually dispatched, not the queue item's raw
+                # column: on None, `register_expected_print` stores nothing,
+                # and `extract_filament_usage_from_3mf` then books every
+                # filament in the file rather than the one plate that printed.
+                plate_id=effective_plate_id,
             )
             )
             # Registration happens before the print command by necessity (the
             # Registration happens before the print command by necessity (the
             # printer can report the print before the send returns), so record
             # printer can report the print before the send returns), so record
@@ -7209,7 +7291,7 @@ class PrintScheduler:
         # rack as it stands right now, after the upload, not at queue time.
         # rack as it stands right now, after the upload, not at queue time.
         resolved_nozzle_mapping = None
         resolved_nozzle_mapping = None
         if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model):
         if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model):
-            rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=item.plate_id or 1)
+            rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=effective_plate_id)
             if rack_plan is not None:
             if rack_plan is not None:
                 try:
                 try:
                     stored_choice = json.loads(item.nozzle_rack_choice) if item.nozzle_rack_choice else {}
                     stored_choice = json.loads(item.nozzle_rack_choice) if item.nozzle_rack_choice else {}
@@ -7308,7 +7390,7 @@ class PrintScheduler:
             and file_path is not None
             and file_path is not None
             and is_nozzle_rack_model(printer.model)
             and is_nozzle_rack_model(printer.model)
         ):
         ):
-            slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=item.plate_id or 1)
+            slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=effective_plate_id)
             if slot_extruders:
             if slot_extruders:
                 nozzle_slot_extruders = json.dumps(slot_extruders)
                 nozzle_slot_extruders = json.dumps(slot_extruders)
 
 
@@ -7348,7 +7430,7 @@ class PrintScheduler:
             from backend.app.services.filament_requirements import extract_filament_requirements
             from backend.app.services.filament_requirements import extract_filament_requirements
 
 
             consumed = _consumed_mapping_entries(
             consumed = _consumed_mapping_entries(
-                ams_mapping, extract_filament_requirements(file_path, plate_id=item.plate_id or 1)
+                ams_mapping, extract_filament_requirements(file_path, plate_id=effective_plate_id)
             )
             )
             if consumed and all(_is_external_tray(t) for t in consumed):
             if consumed and all(_is_external_tray(t) for t in consumed):
                 effective_use_ams = False
                 effective_use_ams = False
@@ -7356,7 +7438,7 @@ class PrintScheduler:
                     "Queue item %s: every filament plate %s prints is on the external spool "
                     "Queue item %s: every filament plate %s prints is on the external spool "
                     "(mapping %s) — dispatching with use_ams=False (#3087)",
                     "(mapping %s) — dispatching with use_ams=False (#3087)",
                     item.id,
                     item.id,
-                    item.plate_id or 1,
+                    effective_plate_id,
                     ams_mapping,
                     ams_mapping,
                 )
                 )
 
 
@@ -7369,7 +7451,7 @@ class PrintScheduler:
         started = printer_manager.start_print(
         started = printer_manager.start_print(
             item.printer_id,
             item.printer_id,
             remote_filename,
             remote_filename,
-            plate_id=item.plate_id or 1,
+            plate_id=effective_plate_id,
             ams_mapping=ams_mapping,
             ams_mapping=ams_mapping,
             bed_levelling=item.bed_levelling,
             bed_levelling=item.bed_levelling,
             flow_cali=item.flow_cali,
             flow_cali=item.flow_cali,

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

@@ -1243,6 +1243,29 @@ def carries_gcode(file_path: Path | str) -> bool:
         return False
         return False
 
 
 
 
+def default_plate_number(names: list[str]) -> int | None:
+    """The plate index to dispatch when the caller didn't ask for one.
+
+    Composes :func:`default_plate_gcode_name` with the numeric index its
+    chosen member encodes, so a caller that needs the integer ``plate_id`` a
+    print command takes gets the same "lowest plate, not first zip member"
+    answer the plate viewer already relies on.
+
+    None when the archive holds no G-code at all, or its default member
+    doesn't follow the ``plate_N`` naming convention ``default_plate_gcode_name``
+    falls back to a bare first member for — callers dispatching a real print
+    command should fall back to plate 1 themselves in that case rather than
+    treat None as a plate number.
+
+    A single-plate export cut out of a larger project keeps its ORIGINAL
+    plate number, so a caller that instead hardcodes a plate-1 fallback sends
+    a print command for a plate the archive may not contain. A real printer
+    accepts that command, can't find the G-code, and wedges (#2947).
+    """
+    selected = default_plate_gcode_name(names)
+    return _plate_number_of(selected) if selected is not None else None
+
+
 # The header block sits at the very top of the plate G-code. Read only that
 # The header block sits at the very top of the plate G-code. Read only that
 # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
 # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
 # would inflate all of it to reach ~40 lines.
 # would inflate all of it to reach ~40 lines.

+ 41 - 4
backend/tests/integration/test_external_spool_use_ams_3087.py

@@ -46,13 +46,20 @@ pytestmark = pytest.mark.integration
 _PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
 _PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
 
 
 
 
-def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None:
+def _write_3mf(
+    path: Path,
+    plate_index: int = 4,
+    filaments: str = _PLATE_4_ONE_FILAMENT,
+    gcode_members: tuple[str, ...] = (),
+) -> None:
     path.parent.mkdir(parents=True, exist_ok=True)
     path.parent.mkdir(parents=True, exist_ok=True)
     with zipfile.ZipFile(path, "w") as zf:
     with zipfile.ZipFile(path, "w") as zf:
         zf.writestr(
         zf.writestr(
             "Metadata/slice_info.config",
             "Metadata/slice_info.config",
             f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
             f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
         )
         )
+        for name in gcode_members:
+            zf.writestr(name, "")
 
 
 
 
 def _write_3mf_without_slice_info(path: Path) -> None:
 def _write_3mf_without_slice_info(path: Path) -> None:
@@ -70,11 +77,24 @@ async def dispatch_case(tmp_path):
     base_dir = tmp_path / "external-spool"
     base_dir = tmp_path / "external-spool"
 
 
     async def _build(
     async def _build(
-        mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S"
+        mapping,
+        *,
+        use_ams=True,
+        plate_id=4,
+        filaments=_PLATE_4_ONE_FILAMENT,
+        slice_info=True,
+        model="P1S",
+        plate_index=None,
+        gcode_members=(),
     ):
     ):
-        archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
+        # The file's own plate number follows the queue item's unless a case
+        # needs them apart: an item with no plate still prints a numbered one.
+        plate_index = plate_id if plate_index is None else plate_index
+        archive_rel = Path("archives") / f"plate-{plate_index}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
         if slice_info:
         if slice_info:
-            _write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments)
+            _write_3mf(
+                base_dir / archive_rel, plate_index=plate_index, filaments=filaments, gcode_members=gcode_members
+            )
         else:
         else:
             _write_3mf_without_slice_info(base_dir / archive_rel)
             _write_3mf_without_slice_info(base_dir / archive_rel)
 
 
@@ -177,6 +197,23 @@ class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
 
 
         assert call.kwargs["use_ams"] is False
         assert call.kwargs["use_ams"] is False
 
 
+    async def test_an_item_with_no_plate_is_judged_on_the_plate_that_prints(self, dispatch_case):
+        """#2947's file: plate 2 cut out of a two-plate project, queued with no
+        plate. The check read plate 1 then, which this file does not describe,
+        so it found no filaments and the print went out with the AMS.
+        """
+        ids = await dispatch_case.build(
+            [-1, 254],
+            filaments='<filament id="2" used_g="9.0" type="PLA"/>',
+            plate_id=None,
+            plate_index=2,
+            gcode_members=("Metadata/plate_2.gcode",),
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["plate_id"] == 2
+        assert call.kwargs["use_ams"] is False
+
 
 
 class TestWhatMustNotChange:
 class TestWhatMustNotChange:
     async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
     async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):

+ 107 - 4
backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py

@@ -47,7 +47,13 @@ _FILAMENTS = (
 _NOZZLES = '<nozzle id="0" extruder_id="1"/><nozzle id="1" extruder_id="2"/><nozzle id="2" extruder_id="2"/>'
 _NOZZLES = '<nozzle id="0" extruder_id="1"/><nozzle id="1" extruder_id="2"/><nozzle id="2" extruder_id="2"/>'
 
 
 
 
-def _write_3mf(path: Path) -> None:
+def _write_3mf(path: Path, gcode_members: tuple[str, ...] = (), slice_info: str | None = None) -> None:
+    """The rack cases need no G-code member and carry none, which is why
+    ``gcode_members`` defaults to empty. #2947 does need one: the plate a
+    dispatch resolves to when the queue item names none is read out of the
+    archive's G-code member names, and with none present the resolution has
+    nothing to answer from and falls back to 1 regardless.
+    """
     path.parent.mkdir(parents=True, exist_ok=True)
     path.parent.mkdir(parents=True, exist_ok=True)
     with zipfile.ZipFile(path, "w") as zf:
     with zipfile.ZipFile(path, "w") as zf:
         zf.writestr(
         zf.writestr(
@@ -62,8 +68,10 @@ def _write_3mf(path: Path) -> None:
         )
         )
         zf.writestr(
         zf.writestr(
             "Metadata/slice_info.config",
             "Metadata/slice_info.config",
-            f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
+            slice_info or f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
         )
         )
+        for name in gcode_members:
+            zf.writestr(name, "")
 
 
 
 
 def _rack(present=(1, 2, 3, 4, 5, 6)):
 def _rack(present=(1, 2, 3, 4, 5, 6)):
@@ -84,7 +92,16 @@ async def rack_case(tmp_path):
     archive_rel = Path("archives") / "benchy.gcode.3mf"
     archive_rel = Path("archives") / "benchy.gcode.3mf"
     _write_3mf(base_dir / archive_rel)
     _write_3mf(base_dir / archive_rel)
 
 
-    async def _build(model: str, choice: dict | None):
+    async def _build(
+        model: str,
+        choice: dict | None,
+        *,
+        plate_id: int | None = 1,
+        gcode_members: tuple[str, ...] = (),
+        slice_info: str | None = None,
+    ):
+        if gcode_members or slice_info:
+            _write_3mf(base_dir / archive_rel, gcode_members, slice_info)
         async with session_maker() as db:
         async with session_maker() as db:
             printer = Printer(
             printer = Printer(
                 name="H2C-1",
                 name="H2C-1",
@@ -107,7 +124,7 @@ async def rack_case(tmp_path):
             item = PrintQueueItem(
             item = PrintQueueItem(
                 printer_id=printer.id,
                 printer_id=printer.id,
                 archive_id=archive.id,
                 archive_id=archive.id,
-                plate_id=1,
+                plate_id=plate_id,
                 status="pending",
                 status="pending",
                 nozzle_rack_choice=json.dumps(choice) if choice else None,
                 nozzle_rack_choice=json.dumps(choice) if choice else None,
             )
             )
@@ -233,6 +250,92 @@ class TestAPickThatNoLongerFits:
         delete_file.assert_awaited()
         delete_file.assert_awaited()
 
 
 
 
+class TestThePlateThatGetsDispatched:
+    """#2947: a queue item with no plate of its own used to dispatch a bare 1.
+
+    Driven on an X1C so the rack machinery is out of the way entirely (see
+    ``test_a_non_rack_printer_is_left_entirely_alone``): the only thing under
+    test here is which plate number leaves ``_start_print``.
+
+    The archive is the shape that wedged a real printer: a single-plate export
+    cut out of a two-plate project, so its one G-code member is
+    ``plate_2.gcode`` and there is no ``plate_1.gcode`` for the firmware to
+    find.
+    """
+
+    async def test_the_print_command_names_the_plate_the_archive_holds(self, rack_case):
+        ids = await rack_case.build("X1C", None, plate_id=None, gcode_members=("Metadata/plate_2.gcode",))
+        start_print, _, item = await _dispatch(rack_case, ids, [])
+
+        assert start_print.call_count == 1
+        assert start_print.call_args.kwargs["plate_id"] == 2
+        assert item.status == "printing"
+
+    async def test_usage_tracking_is_registered_for_that_same_plate(self, rack_case):
+        """Imported inside ``_start_print``, so it patches on its own module.
+
+        Without this the archive says plate 2 printed while the usage tracker
+        was told nothing, and a None there books every filament in the file
+        instead of the plate that ran.
+        """
+        ids = await rack_case.build("X1C", None, plate_id=None, gcode_members=("Metadata/plate_2.gcode",))
+        with patch("backend.app.main.register_expected_print") as register:
+            start_print, _, _ = await _dispatch(rack_case, ids, [])
+
+        assert start_print.call_count == 1
+        assert register.call_args.kwargs["plate_id"] == 2
+
+
+# Plates 2 and 3 cut out of a larger project, so there is no plate 1. Plate 2
+# prints filaments 1 and 2 (one rack group, one fixed); plate 3 prints filament
+# 3 on a second rack group. The rack lookups read every plate when asked for one
+# the file does not describe, and across both plates this becomes the
+# two-rack-groups case that neither lookup can answer the same way.
+_PLATES_2_AND_3 = (
+    "<config>"
+    '<plate><metadata key="index" value="2"/>'
+    '<filament id="1" group_id="2" color="#DE4343" nozzle_diameter="0.40" volume_type="High Flow"/>'
+    '<filament id="2" group_id="0" color="#F4EE2A" nozzle_diameter="0.40" volume_type="High Flow"/>'
+    '<nozzle id="0" extruder_id="1"/><nozzle id="2" extruder_id="2"/></plate>'
+    '<plate><metadata key="index" value="3"/>'
+    '<filament id="3" group_id="1" color="#0078BF" nozzle_diameter="0.40" volume_type="High Flow"/>'
+    '<nozzle id="1" extruder_id="2"/></plate>'
+    "</config>"
+)
+
+
+class TestTheRackLookupsReadThePlateThatGetsDispatched:
+    """#2947 on an H2C: the rack plan and the slot extruders are read for the
+    same plate the print command names, not for a plate 1 the file lacks.
+    """
+
+    async def _build(self, rack_case):
+        return await rack_case.build(
+            "H2C",
+            None,
+            plate_id=None,
+            gcode_members=("Metadata/plate_2.gcode", "Metadata/plate_3.gcode"),
+            slice_info=_PLATES_2_AND_3,
+        )
+
+    async def test_the_rack_is_resolved_for_plate_two_only(self, rack_case):
+        ids = await self._build(rack_case)
+        start_print, _, _ = await _dispatch(rack_case, ids, _rack())
+
+        assert start_print.call_args.kwargs["plate_id"] == 2
+        # Filament 3 belongs to plate 3; plate 2 has no third slot to map.
+        assert _sent_mapping(start_print)[:3] == [16, 1, -1]
+
+    async def test_the_slot_extruders_are_read_for_plate_two_only(self, rack_case):
+        """With no rack to assign from, the dispatch falls back to the #2800
+        slot extruders, which plate 2 alone can state and the pair cannot."""
+        ids = await self._build(rack_case)
+        start_print, _, _ = await _dispatch(rack_case, ids, _rack(present=()))
+
+        assert start_print.call_args.kwargs["nozzle_mapping"] is None
+        assert json.loads(start_print.call_args.kwargs["nozzle_slot_extruders"]) == [0, 1]
+
+
 class TestOtherModels:
 class TestOtherModels:
     async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case):
     async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case):
         """No rack means no resolution, no refusal, and no mapping invented."""
         """No rack means no resolution, no refusal, and no mapping invented."""

+ 35 - 0
backend/tests/unit/test_plate_gcode_selection.py

@@ -9,6 +9,7 @@ opening plate 2 on a file whose first plate is plate 1.
 
 
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
     default_plate_gcode_name,
     default_plate_gcode_name,
+    default_plate_number,
     select_plate_gcode_name,
     select_plate_gcode_name,
 )
 )
 
 
@@ -42,6 +43,40 @@ class TestDefaultPlateGcodeName:
         assert default_plate_gcode_name(["3D/3dmodel.model"]) is None
         assert default_plate_gcode_name(["3D/3dmodel.model"]) is None
 
 
 
 
+class TestDefaultPlateNumber:
+    """The integer a dispatch caller needs when no ``plate_id`` was asked for
+    (#2947). Composes ``default_plate_gcode_name`` with the numeric index its
+    chosen member encodes.
+    """
+
+    def test_picks_the_lowest_plate_not_the_first_member(self):
+        assert default_plate_number(REVERSED_ORDER) == 1
+
+    def test_single_plate_file_numbered_two_resolves_to_two(self):
+        # The exact shape of a plate cut out of a multi-plate project: one
+        # G-code member, keeping its ORIGINAL (non-1) plate number. Dispatch
+        # hardcoding a plate-1 fallback here is #2947.
+        assert default_plate_number(["Metadata/plate_2.gcode"]) == 2
+
+    def test_a_gcode_md5_sidecar_is_not_mistaken_for_the_toolpath(self):
+        names = ["Metadata/plate_1.gcode.md5", "Metadata/plate_2.gcode", "Metadata/plate_1.gcode"]
+        assert default_plate_number(names) == 1
+
+    def test_double_digit_plates_sort_numerically_not_lexically(self):
+        names = ["Metadata/plate_10.gcode", "Metadata/plate_2.gcode"]
+        assert default_plate_number(names) == 2
+
+    def test_returns_none_for_an_unsliced_file(self):
+        assert default_plate_number(["3D/3dmodel.model"]) is None
+
+    def test_returns_none_when_the_default_member_has_no_plate_number(self):
+        # default_plate_gcode_name falls back to the first .gcode member for
+        # a slicer that doesn't use the plate_N convention; that member
+        # carries no parseable number, and this must not invent one — the
+        # dispatch caller is the one that falls back to plate 1.
+        assert default_plate_number(["out.gcode", "other.gcode"]) is None
+
+
 class TestSelectPlateGcodeName:
 class TestSelectPlateGcodeName:
     def test_selects_the_named_plate_regardless_of_zip_order(self):
     def test_selects_the_named_plate_regardless_of_zip_order(self):
         assert select_plate_gcode_name(REVERSED_ORDER, 1) == "Metadata/plate_1.gcode"
         assert select_plate_gcode_name(REVERSED_ORDER, 1) == "Metadata/plate_1.gcode"

+ 150 - 0
backend/tests/unit/test_scheduler_effective_plate_id.py

@@ -0,0 +1,150 @@
+"""``_effective_plate_id``, the plate resolved once in ``_start_print`` and
+reused at every dispatch call site: G-code injection, usage registration,
+rack-plan lookup, slot-extruder lookup, the external-spool check, and the
+actual print command (#2947).
+
+A single-plate 3MF exported out of a multi-plate project keeps its ORIGINAL
+plate number: cutting the right-side plate out of a two-plate project leaves
+a file whose only G-code member is ``Metadata/plate_2.gcode``, never
+``plate_1.gcode``. A caller that hardcodes ``item.plate_id or 1`` sends a
+print command for a plate the archive doesn't hold. The printer accepts the
+command, can't find the G-code, and wedges until power-cycled.
+
+The call sites agreed on this fallback only by accident before this fix
+— see the function's own docstring for how a plate mismatch could sneak past
+G-code injection specifically.
+"""
+
+import logging
+import zipfile
+
+from backend.app.services.print_scheduler import _effective_plate_id
+
+
+def _write_gcode_members(path, names):
+    with zipfile.ZipFile(path, "w") as zf:
+        for name in names:
+            zf.writestr(name, "")
+    return path
+
+
+class TestEffectivePlateId:
+    def test_explicit_plate_id_wins_even_when_the_file_cannot_be_read(self, tmp_path):
+        # A queue item's own choice always wins, and an archive that can't be
+        # opened must not stop it from being returned.
+        assert _effective_plate_id(3, tmp_path / "does-not-exist.3mf") == 3
+
+    def test_plate_id_zero_is_treated_as_unset_and_resolved_from_the_archive(self, tmp_path):
+        # Nothing validates the field as positive (no ge= on any plate_id in
+        # schemas/print_queue.py) and the rest of the queue code reads it
+        # truthily, so a 0 means "not set" here too. Returning it would
+        # dispatch Metadata/plate_0.gcode, the exact wedge this fixes.
+        path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
+        assert _effective_plate_id(0, path) == 2
+
+    def test_negative_plate_id_is_treated_as_unset_too(self, tmp_path):
+        path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
+        assert _effective_plate_id(-1, path) == 2
+
+    def test_single_plate_file_numbered_two_resolves_to_two(self, tmp_path):
+        # The exact shape of the wedged printer in #2947: one G-code member,
+        # keeping its original (non-1) plate number.
+        path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
+        assert _effective_plate_id(None, path) == 2
+
+    def test_single_plate_file_numbered_one_resolves_to_one(self, tmp_path):
+        path = _write_gcode_members(tmp_path / "left.gcode.3mf", ["Metadata/plate_1.gcode"])
+        assert _effective_plate_id(None, path) == 1
+
+    def test_reversed_zip_order_picks_the_lowest_plate_not_first_member(self, tmp_path):
+        # Bambu Studio does not write plates in zip order: a real two-plate
+        # export stores plate_2.gcode ahead of plate_1.gcode.
+        path = _write_gcode_members(
+            tmp_path / "reversed.gcode.3mf",
+            ["Metadata/plate_2.gcode", "Metadata/plate_1.gcode"],
+        )
+        assert _effective_plate_id(None, path) == 1
+
+    def test_gcode_md5_sidecar_is_not_mistaken_for_the_toolpath(self, tmp_path):
+        path = _write_gcode_members(
+            tmp_path / "sidecar.gcode.3mf",
+            ["Metadata/plate_1.gcode.md5", "Metadata/plate_2.gcode", "Metadata/plate_1.gcode"],
+        )
+        assert _effective_plate_id(None, path) == 1
+
+    def test_unsliced_file_falls_back_to_plate_one(self, tmp_path):
+        path = _write_gcode_members(tmp_path / "unsliced.3mf", ["3D/3dmodel.model"])
+        assert _effective_plate_id(None, path) == 1
+
+    def test_gcode_member_without_plate_naming_falls_back_to_plate_one(self, tmp_path):
+        # default_plate_number returns None here (no number to dispatch);
+        # this is the one place that turns that None into the actual
+        # fallback a print command needs.
+        path = _write_gcode_members(tmp_path / "custom.gcode.3mf", ["Metadata/print.gcode"])
+        assert _effective_plate_id(None, path) == 1
+
+    def test_unreadable_file_falls_back_to_plate_one(self, tmp_path):
+        path = tmp_path / "broken.3mf"
+        path.write_bytes(b"not a zip")
+        assert _effective_plate_id(None, path) == 1
+
+    def test_missing_file_falls_back_to_plate_one(self, tmp_path):
+        assert _effective_plate_id(None, tmp_path / "does-not-exist.3mf") == 1
+
+
+class TestWhatGetsLogged:
+    """The fallbacks are silent recoveries from something that is wrong with
+    the file, so each one has to leave a trace naming the archive. None of
+    them may raise: this runs on a dispatch that is otherwise fine.
+    """
+
+    def test_an_unreadable_archive_is_logged_with_the_path_and_the_reason(self, tmp_path, caplog):
+        path = tmp_path / "broken.3mf"
+        path.write_bytes(b"not a zip")
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
+            assert _effective_plate_id(None, path) == 1
+
+        assert "broken.3mf" in caplog.text
+        assert "BadZipFile" in caplog.text or "not a zip file" in caplog.text.lower()
+
+    def test_an_explicit_plate_the_archive_does_not_hold_is_logged_not_redirected(self, tmp_path, caplog):
+        # The wedge of #2947 seen from the other side: the operator named
+        # plate 1, the file only has plate 2. Redirecting to 2 would print a
+        # model nobody asked for, so the command goes out as asked.
+        path = _write_gcode_members(tmp_path / "right.gcode.3mf", ["Metadata/plate_2.gcode"])
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
+            assert _effective_plate_id(1, path) == 1
+
+        assert "right.gcode.3mf" in caplog.text
+        assert "Metadata/plate_2.gcode" in caplog.text
+
+    def test_a_plate_the_archive_does_hold_logs_nothing(self, tmp_path, caplog):
+        path = _write_gcode_members(
+            tmp_path / "two.gcode.3mf",
+            ["Metadata/plate_1.gcode", "Metadata/plate_2.gcode"],
+        )
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
+            assert _effective_plate_id(2, path) == 2
+
+        assert caplog.text == ""
+
+    def test_a_slicer_that_does_not_number_its_plates_is_not_warned_about(self, tmp_path, caplog):
+        # There is no plate numbering to contradict here, so an explicit plate
+        # is not evidence of a mismatch and must not be reported as one.
+        path = _write_gcode_members(tmp_path / "custom.gcode.3mf", ["Metadata/print.gcode"])
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
+            assert _effective_plate_id(1, path) == 1
+
+        assert caplog.text == ""
+
+    def test_an_unsliced_archive_is_not_warned_about_either(self, tmp_path, caplog):
+        path = _write_gcode_members(tmp_path / "unsliced.3mf", ["3D/3dmodel.model"])
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
+            assert _effective_plate_id(1, path) == 1
+
+        assert caplog.text == ""