mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Resolve dispatch plate_id from the archive instead of assuming 1 (#2951)
This commit is contained in:
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from collections import deque
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
@@ -74,8 +75,10 @@ from backend.app.utils.printer_models import (
|
||||
normalize_printer_model,
|
||||
)
|
||||
from backend.app.utils.threemf_tools import (
|
||||
default_plate_number,
|
||||
extract_rack_plan_from_3mf,
|
||||
extract_slot_extruders_from_3mf,
|
||||
select_plate_gcode_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:
|
||||
"""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)
|
||||
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)
|
||||
injected_path = None
|
||||
# #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
|
||||
|
||||
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:
|
||||
file_path = injected_path
|
||||
@@ -7052,7 +7130,11 @@ class PrintScheduler:
|
||||
ams_mapping=ams_mapping,
|
||||
created_by_id=item.created_by_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
|
||||
# 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.
|
||||
resolved_nozzle_mapping = None
|
||||
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:
|
||||
try:
|
||||
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 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:
|
||||
nozzle_slot_extruders = json.dumps(slot_extruders)
|
||||
|
||||
@@ -7348,7 +7430,7 @@ class PrintScheduler:
|
||||
from backend.app.services.filament_requirements import extract_filament_requirements
|
||||
|
||||
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):
|
||||
effective_use_ams = False
|
||||
@@ -7356,7 +7438,7 @@ class PrintScheduler:
|
||||
"Queue item %s: every filament plate %s prints is on the external spool "
|
||||
"(mapping %s) — dispatching with use_ams=False (#3087)",
|
||||
item.id,
|
||||
item.plate_id or 1,
|
||||
effective_plate_id,
|
||||
ams_mapping,
|
||||
)
|
||||
|
||||
@@ -7369,7 +7451,7 @@ class PrintScheduler:
|
||||
started = printer_manager.start_print(
|
||||
item.printer_id,
|
||||
remote_filename,
|
||||
plate_id=item.plate_id or 1,
|
||||
plate_id=effective_plate_id,
|
||||
ams_mapping=ams_mapping,
|
||||
bed_levelling=item.bed_levelling,
|
||||
flow_cali=item.flow_cali,
|
||||
|
||||
@@ -1243,6 +1243,29 @@ def carries_gcode(file_path: Path | str) -> bool:
|
||||
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
|
||||
# much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
|
||||
# would inflate all of it to reach ~40 lines.
|
||||
|
||||
@@ -46,13 +46,20 @@ pytestmark = pytest.mark.integration
|
||||
_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)
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr(
|
||||
"Metadata/slice_info.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:
|
||||
@@ -70,11 +77,24 @@ async def dispatch_case(tmp_path):
|
||||
base_dir = tmp_path / "external-spool"
|
||||
|
||||
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:
|
||||
_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:
|
||||
_write_3mf_without_slice_info(base_dir / archive_rel)
|
||||
|
||||
@@ -177,6 +197,23 @@ class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
|
||||
|
||||
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:
|
||||
async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
|
||||
|
||||
@@ -47,7 +47,13 @@ _FILAMENTS = (
|
||||
_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)
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr(
|
||||
@@ -62,8 +68,10 @@ def _write_3mf(path: Path) -> None:
|
||||
)
|
||||
zf.writestr(
|
||||
"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)):
|
||||
@@ -84,7 +92,16 @@ async def rack_case(tmp_path):
|
||||
archive_rel = Path("archives") / "benchy.gcode.3mf"
|
||||
_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:
|
||||
printer = Printer(
|
||||
name="H2C-1",
|
||||
@@ -107,7 +124,7 @@ async def rack_case(tmp_path):
|
||||
item = PrintQueueItem(
|
||||
printer_id=printer.id,
|
||||
archive_id=archive.id,
|
||||
plate_id=1,
|
||||
plate_id=plate_id,
|
||||
status="pending",
|
||||
nozzle_rack_choice=json.dumps(choice) if choice else None,
|
||||
)
|
||||
@@ -233,6 +250,92 @@ class TestAPickThatNoLongerFits:
|
||||
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:
|
||||
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."""
|
||||
|
||||
@@ -9,6 +9,7 @@ opening plate 2 on a file whose first plate is plate 1.
|
||||
|
||||
from backend.app.utils.threemf_tools import (
|
||||
default_plate_gcode_name,
|
||||
default_plate_number,
|
||||
select_plate_gcode_name,
|
||||
)
|
||||
|
||||
@@ -42,6 +43,40 @@ class TestDefaultPlateGcodeName:
|
||||
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:
|
||||
def test_selects_the_named_plate_regardless_of_zip_order(self):
|
||||
assert select_plate_gcode_name(REVERSED_ORDER, 1) == "Metadata/plate_1.gcode"
|
||||
|
||||
@@ -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 == ""
|
||||
Reference in New Issue
Block a user