diff --git a/CHANGELOG.md b/CHANGELOG.md
index 805c592dc..6b6fccac4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green.
### Fixed
+- **Skip Objects listed the wrong plate's objects (#2522, reporter @bordermultimedia)** — The reporter uploads all-plates sliced files (`*.gcode.3mf`) and picks the plate at dispatch, which works. But Skip Objects then offered him four objects while the plate he was printing had one — and the four IDs it showed were, verbatim, the four copies sitting on a *different* plate of the same file. His 3MF confirms it exactly: plate 1 holds four `stand_pillow_01.stl` instances (identify_ids 2040 / 2062 / 2084 / 2106), plate 2 holds one (2168). He printed plate 2 and got plate 1's list. **Root cause, two defects in one helper.** `extract_printable_objects_from_3mf()` has taken a `plate_number` argument all along, and not one of its three call sites ever passed it — print start from an archived file, print start from the FTP-downloaded file, and the modal's `reload` all called it bare, so it fell to `root.find(".//plate")`, the first plate in the file. And passing one would not have helped: the lookup was `.//plate[@plate_idx='N']`, a predicate on an attribute neither Bambu Studio nor OrcaSlicer writes — the plate index lives in a `` child, which is how the rest of the codebase (`threemf_tools.py`, `filament_requirements.py`) already reads it. The XPath simply never matched and fell back to plate 1 regardless. The thumbnail behind the markers was right the whole time, because `/cover` resolves the plate properly via `resolve_plate_id()` — which is why the reporter saw plate 1's four markers arranged in a square over plate 2's single pillow. **Fix.** The plate is now selected on its `index` metadata, and all three call sites pass `resolve_plate_id(client.state)` — the same resolver `/cover` uses, so the object list and the thumbnail it is drawn over cannot disagree again. It covers both cases: Bambuddy-dispatched prints (where the plate is known from the dispatch, and must be — the reporter's P1S firmware echoes a `gcode_file` with no plate path, #1166) and prints started from the printer or Studio (where it is parsed from that path). When no plate can be resolved, or the requested plate isn't in the file, the first plate is still used — the single-plate export, i.e. the common case, is unaffected. **Also fixed, same file shape.** `peek_plate_index_in_3mf()` read the first plate too. It backs the #1204 guard, which compares the plate inside a freshly-downloaded 3MF against the plate the printer reports and *discards the file* on mismatch. Fed an all-plates upload it always answered "plate 1", so anyone printing plate 2+ of such a file from the printer screen had their perfectly good 3MF thrown away and got a no-3MF fallback archive — no metadata, no skip objects at all. It now returns `None` when the file carries more than one plate, because "which plate is this file" has no answer for an all-plates export; single-plate exports still report their index and #1204 behaves as before. **Tests.** 10 cases over a fixture mirroring the reporter's file: per-plate object lists and marker positions resolve independently, an unknown plate falls back without mixing one plate's objects with another's positions, a single-plate export keeps its own index, the multi-plate file yields no peek index, and the print-start wiring passes the dispatched plate and the gcode-parsed plate. Verified by mutation — dropping the plate argument reproduces the reporter's exact symptom, "Loaded 4 printable objects" for a one-object plate. **Scope.** Backend only. No DB migration, no schema change, no new permission, no i18n change.
- **Large prints uploaded twice at once and never landed (#2529, reporter @PDXDave23)** — On a 14-printer A1 farm, dispatches "failed over and over" and looked like flaky WiFi. The reporter's screen recording is the proof: the dispatch toast tracks a single job (96.1 MB, one printer), yet its byte counter alternates between two *independently rising* series — 69.4 → 70.8 MB and 1.6 → 2.9 MB, both climbing at ~75 KB/s. That is not a progress bar jumping. That is two FTP transfers of the same file, to the same printer, at the same time, reporting into the same bar. **Root cause.** `upload_file_async` carried a flat `timeout: float = 600.0` and ran the transfer as `asyncio.wait_for(loop.run_in_executor(...))`. `wait_for` cancels the *future*; it cannot cancel a thread already running in an executor. So on a link slow enough that a big file needs more than ten minutes — 96 MB at 75 KB/s needs twenty — the await gave up at 600 s, returned `False`, and `with_ftp_retry` started attempt two *while attempt one was still streaming*, both writing the same `remote_path`. Do the arithmetic and the stale series sits right where the ten-minute clock expires. With `ftp_retry_count: 3` plus the A1's prot_p→prot_c fallback that is up to six concurrent STORs onto one SD card; each orphaned thread also permanently held a slot in the default executor pool, which on a farm can starve every other FTP operation. The flat cap was never a failure detector in the first place — a link that has actually *died* is caught within `socket_timeout` (30 s) by the blocking `sendall`. It only ever punished large files. **Fix, three parts.** The deadline is now derived from the file size against a deliberately pessimistic 25 KB/s floor (`_upload_deadline`), so a slow-but-healthy transfer is allowed to finish. A deadline expiry now genuinely *stops* the transfer: it signals the worker, which raises `UploadCancelled` from its progress callback — the existing cancel path in `upload_file`, which breaks the send loop and deletes the partial file from the printer — and the caller waits for the thread to actually go before returning. And `with_ftp_retry` never retries an `UploadCancelled`: the deadline means the link sustained less than the floor rate for the whole transfer, so a retry would only spend another full deadline learning that again, and with `check_queue` serialized four of those would block the entire print queue for hours. A per-printer upload lock closes the last hole, so no two uploads can ever overlap on one printer regardless of how they were triggered. Queue items that hit the deadline now say the upload was too slow and to check the printer's WiFi, rather than the old (and here entirely misleading) "check if SD card is inserted". **Tests.** 6 cases: the deadline scales with file size and floors correctly; a timeout stops the worker thread and cleans its partial file off the printer; a timeout is not retried; concurrent dispatches to one printer serialize; and the real client's cancel path removes the partial file, driven end to end against the mock FTPS server. Verified by mutation — dropping the cancel signal, the retry guard, or the lock each fails its test. **Scope.** Backend only. No DB migration, no schema change, no new permission, no i18n change.
- **"Start Drying" gave no feedback and reported success the printer never delivered (#2533, reporter @naldo29)** — On the reporter's P1S the button appeared dead: no toast, no badge, no drying. Their bundle shows Bambuddy doing everything right — the `ams_filament_drying` payload matches BambuStudio field-for-field, was sent three times with the printer idle, and the firmware answered `result: success` to each — while the AMS 2 Pro (`module_type: n3f`) stayed at `dry_status: 0`. The printer takes the command and drops it. Two gaps on our side made that indistinguishable from a broken button. **No confirmation.** `startDryingMutation` / `stopDryingMutation` closed the popover and invalidated the status cache without ever toasting, alone among the printer card's actions — so a user who clicked Start had nothing at all to tell them the click had registered. Both now toast on success — and the start toast says "Drying command sent", not "Drying started", because at that moment the printer has only *taken* the command. What says the cycle is genuinely live is the amber countdown badge, which is keyed on `dry_time` and therefore only appears when firmware reports one. **Success was inferred from the MQTT ack.** The #971 guard that turns firmware's refusal into a real message ("Plug in the external AMS power adapter", "AMS is already drying", …) reads the per-unit `dry_sf_reason` array — and P1-family firmware never publishes that field, so on a P1S the guard is inert and the ack is all we have. The card now watches the unit after the ack: `dry_status` and `dry_time` come straight from the `info` bitmask on every push, and firmware moves to `DryStatus 1` (Checking) within seconds of a real start. If the unit is still at zero 30 seconds later the cycle never began, and Bambuddy says so and names the two things that cause it (AMS power adapter not connected; printer not idle — P1S cannot dry mid-print, see `supports_drying_while_printing`). Unlike the `dry_sf_reason` guard this is model-agnostic: it catches any firmware that acks and declines. **Tests.** 4 component cases — the send toast, the stop toast, the warning firing when the AMS never leaves `dry_status: 0`, and no warning when it does enter a cycle. **Scope.** Frontend only; 3 new i18n keys across all 11 locales. No DB migration, no schema change, no new permission.
- **Enabling authentication silently disconnected Bambu Cloud (#2530, reporter @hburn7)** — Cloud credentials live in two different places depending on auth state: `get_stored_token()` reads the global `Settings` rows (`bambu_cloud_token` / `_email` / `_region`) when auth is off, and `User.cloud_token` when it's on. Completing `POST /auth/setup` flipped which store the `/cloud/*` routes consult, but nothing carried the token across — so an operator who linked their Bambu account *before* turning on auth found the freshly-created admin had `cloud_token = NULL`. `build_authenticated_cloud()` then returned `None` and every cloud route degraded: `get_filament_info` skipped its cloud phase entirely and answered `200` from the local-preset and built-in-name fallbacks, while `/cloud/devices` began returning `401`. Nothing surfaced the disconnect. The reporter observed the *symptom inverted* — cloud `400` warnings vanished after enabling auth — and reasonably read that as a fix; in fact the warnings stopped because Bambuddy had stopped calling the cloud at all. The tell is in their own timestamps: the pre-auth request spent ~960 ms on cloud round-trips, the post-auth one answered immediately. **Fix.** `setup_auth()` now migrates a globally-stored token onto the owning admin (and deletes the global rows, so a live credential isn't left at rest in a table nothing reads), and `disable_auth()` performs the mirror hand-off back to global storage. Both refuse to guess when ownership is ambiguous: setup migrates only when it creates the admin or exactly one admin already exists — with several admins it leaves the credential in place and logs a warning rather than handing one admin another's Bambu session; disable declines to overwrite a pre-existing global token. The `region` survives both hops rather than silently resetting to `global`. **Note for existing installs.** The migration runs at the auth on/off transition, so instances that already crossed it must re-link their Bambu account once from Settings → Bambu Cloud; the stranded `bambu_cloud_*` rows in `settings` can then be deleted. **Tests.** 7 integration cases pinning both directions, the two refuse-to-guess paths, the `auth_enabled=false` no-op, and region preservation. **Scope.** No DB migration, no schema change, no new permission, no i18n change.
diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py
index eb7ed9981..df250a33a 100644
--- a/backend/app/api/routes/printers.py
+++ b/backend/app/api/routes/printers.py
@@ -3362,7 +3362,14 @@ async def get_printable_objects(
if downloaded and temp_path.exists():
with open(temp_path, "rb") as f:
data = f.read()
- objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
+ # Scope to the running plate: an all-plates 3MF lists every
+ # plate's objects, and offering plate 1's while the printer
+ # runs plate 2 makes every skip a misfire (#2522).
+ objects, bbox_all = extract_printable_objects_from_3mf(
+ data,
+ plate_number=resolve_plate_id(client.state),
+ include_positions=True,
+ )
if objects:
client.state.printable_objects = objects
client.state.printable_objects_bbox_all = bbox_all
diff --git a/backend/app/main.py b/backend/app/main.py
index 5aa929731..4af28cc45 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -106,6 +106,7 @@ from backend.app.services.printer_manager import (
parse_plate_id,
printer_manager,
printer_state_to_dict,
+ resolve_plate_id,
)
from backend.app.services.smart_plug_manager import smart_plug_manager
from backend.app.services.spool_assignment_notifications import (
@@ -2300,19 +2301,28 @@ def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
try:
from backend.app.services.archive import extract_printable_objects_from_3mf
+ client = printer_manager.get_client(printer_id)
+ if not client:
+ return
+
file_path = app_settings.base_dir / archive.file_path
if file_path.is_file() and str(file_path).endswith(".3mf"):
with open(file_path, "rb") as f:
threemf_data = f.read()
- # Extract with positions for UI overlay
- printable_objects, bbox_all = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+ # Extract with positions for UI overlay, scoped to the plate that
+ # is printing — resolve_plate_id is the same resolver /cover uses,
+ # so the object list can't disagree with the thumbnail it is drawn
+ # over (#2522).
+ printable_objects, bbox_all = extract_printable_objects_from_3mf(
+ threemf_data,
+ plate_number=resolve_plate_id(client.state),
+ include_positions=True,
+ )
if printable_objects:
- client = printer_manager.get_client(printer_id)
- if client:
- client.state.printable_objects = printable_objects
- client.state.printable_objects_bbox_all = bbox_all
- client.state.skipped_objects = []
- logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id)
+ client.state.printable_objects = printable_objects
+ client.state.printable_objects_bbox_all = bbox_all
+ client.state.skipped_objects = []
+ logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id)
except Exception as e:
logger.debug("Failed to extract printable objects from archive: %s", e)
@@ -3351,16 +3361,20 @@ async def on_print_start(printer_id: int, data: dict):
try:
from backend.app.services.archive import extract_printable_objects_from_3mf
- with open(temp_path, "rb") as f:
- threemf_data = f.read()
- # Extract with positions for UI overlay
- printable_objects, bbox_all = extract_printable_objects_from_3mf(
- threemf_data, include_positions=True
- )
- if printable_objects:
- # Store objects in printer state
- client = printer_manager.get_client(printer_id)
- if client:
+ client = printer_manager.get_client(printer_id)
+ if client:
+ with open(temp_path, "rb") as f:
+ threemf_data = f.read()
+ # Extract with positions for UI overlay, scoped to the
+ # plate that is printing — an all-plates 3MF carries
+ # every plate's objects (#2522).
+ printable_objects, bbox_all = extract_printable_objects_from_3mf(
+ threemf_data,
+ plate_number=resolve_plate_id(client.state),
+ include_positions=True,
+ )
+ if printable_objects:
+ # Store objects in printer state
client.state.printable_objects = printable_objects
client.state.printable_objects_bbox_all = bbox_all
client.state.skipped_objects = [] # Reset skipped objects for new print
diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py
index 816480ead..240c9cb64 100644
--- a/backend/app/services/archive.py
+++ b/backend/app/services/archive.py
@@ -69,13 +69,37 @@ def resolve_display_stem(filename: str) -> str:
return Path(name).stem
+def _read_plate_index(plate) -> int | None:
+ """Return the 1-based index of a ``slice_info.config`` ```` element, or None.
+
+ Bambu Studio and OrcaSlicer record it as a ```` child — there is no ``plate_idx`` attribute on ````
+ itself, so an XPath predicate on one never matches (#2522).
+ """
+ for meta in plate.findall("metadata"):
+ if meta.get("key") == "index":
+ value = meta.get("value")
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ return None
+ return None
+
+
def peek_plate_index_in_3mf(file_path: Path) -> int | None:
- """Return the plate index recorded inside a Bambu 3MF, or None.
+ """Return the plate index a single-plate Bambu 3MF represents, or None.
Reads only ``Metadata/slice_info.config`` to keep this cheap — used by
the print-start callback to verify that the 3MF we just downloaded over
FTP actually matches the plate the printer is running (#1204). The full
ThreeMFParser does much more work and runs later inside ArchiveService.
+
+ An all-plates export carries every plate, so "which plate is this file"
+ has no answer; returning None there keeps the #1204 guard from reading
+ plate 1 out of such a file, declaring a mismatch against the plate that
+ is really running, and discarding a perfectly good 3MF (#2522).
"""
try:
with zipfile.ZipFile(file_path, "r") as zf:
@@ -83,20 +107,12 @@ def peek_plate_index_in_3mf(file_path: Path) -> int | None:
return None
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
- plate = root.find(".//plate")
- if plate is None:
+ plates = root.findall(".//plate")
+ if len(plates) != 1:
return None
- for meta in plate.findall("metadata"):
- if meta.get("key") == "index":
- value = meta.get("value")
- if value:
- try:
- return int(value)
- except ValueError:
- return None
+ return _read_plate_index(plates[0])
except Exception:
return None
- return None
_PLATE_SUFFIX_RE = re.compile(r"^(.*?)(\s*-\s*Plate\s+|_plate_)(\d+)$", re.IGNORECASE)
@@ -615,26 +631,26 @@ def extract_printable_objects_from_3mf(
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
- # Find the correct plate
- if plate_number:
- plate = root.find(f".//plate[@plate_idx='{plate_number}']")
- if plate is None:
- plate = root.find(".//plate")
- else:
- plate = root.find(".//plate")
-
- if plate is None:
+ plates = root.findall(".//plate")
+ if not plates:
return printable_objects
- # Get actual plate index from metadata (sliced files only have one plate)
- plate_idx = plate_number or 1
- for meta in plate.findall("metadata"):
- if meta.get("key") == "index":
- try:
- plate_idx = int(meta.get("value", "1"))
- except ValueError:
- pass # Use default plate_idx if value is non-numeric
- break
+ # Pick the plate that is actually printing. An all-plates export
+ # lists every plate, so without this we offered the objects (and
+ # the marker positions) of plate 1 whatever the printer was
+ # running (#2522). Falling back to the first plate keeps the
+ # single-plate export — the common case — working when the caller
+ # has no plate to give us.
+ plate = None
+ if plate_number is not None:
+ plate = next((p for p in plates if _read_plate_index(p) == plate_number), None)
+ if plate is None:
+ plate = plates[0]
+
+ # Derive plate_idx from the plate we settled on, never from the
+ # requested one: on a fallback they differ, and plate_idx also
+ # selects the plate_N.json the positions come from.
+ plate_idx = _read_plate_index(plate) or 1
# Load position data from plate_N.json if we need positions
# Build a lookup by name - use list to handle duplicate names
diff --git a/backend/tests/unit/services/test_printable_objects_plate.py b/backend/tests/unit/services/test_printable_objects_plate.py
new file mode 100644
index 000000000..3e46526b5
--- /dev/null
+++ b/backend/tests/unit/services/test_printable_objects_plate.py
@@ -0,0 +1,199 @@
+"""Skip-object extraction must follow the plate that is actually printing (#2522).
+
+An "all plates" sliced export lists every plate in ``slice_info.config`` and
+ships a ``plate_N.json`` per plate. Bambuddy read the *first* plate whatever
+the printer was running, so a reporter printing plate 2 (one object) was
+offered plate 1's four objects, with plate 1's marker positions drawn over
+plate 2's thumbnail.
+
+Two defects fed that: no call site passed ``plate_number``, and the lookup it
+would have used (``.//plate[@plate_idx='N']``) tested an attribute Bambu and
+Orca never write — the index lives in a ```` child, so
+the selector silently fell back to plate 1 regardless.
+
+The fixture below mirrors the reporter's real file: plate 1 holds four copies
+of ``stand_pillow_01.stl`` (identify_ids 2040/2062/2084/2106), plate 2 holds
+one (2168).
+"""
+
+import json
+import logging
+import zipfile
+from io import BytesIO
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.services.archive import (
+ extract_printable_objects_from_3mf,
+ peek_plate_index_in_3mf,
+)
+
+PLATE_1_IDS = [2040, 2062, 2084, 2106]
+PLATE_2_ID = 2168
+
+
+def _plate_xml(index: int, ids: list[int]) -> str:
+ objects = "".join(f'' for i in ids)
+ return f'{objects}'
+
+
+def _plate_json(boxes: list[list[float]]) -> str:
+ """A plate_N.json with one bbox_objects entry per box, plus their union."""
+ return json.dumps(
+ {
+ "bbox_all": [
+ min(b[0] for b in boxes),
+ min(b[1] for b in boxes),
+ max(b[2] for b in boxes),
+ max(b[3] for b in boxes),
+ ],
+ "bbox_objects": [
+ # The ids here are the slicer's own bbox ids, which do NOT equal
+ # identify_id in real files — matching is by name, as before.
+ {"id": 9000 + n, "name": "stand_pillow_01.stl", "bbox": box}
+ for n, box in enumerate(boxes)
+ ],
+ }
+ )
+
+
+# Four copies in a square (plate 1) vs. a single copy elsewhere (plate 2).
+PLATE_1_BOXES = [[0, 0, 10, 10], [90, 0, 100, 10], [0, 90, 10, 100], [90, 90, 100, 100]]
+PLATE_2_BOXES = [[40, 40, 60, 60]]
+
+
+def _multi_plate_3mf() -> bytes:
+ buf = BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr(
+ "Metadata/slice_info.config",
+ f"{_plate_xml(1, PLATE_1_IDS)}{_plate_xml(2, [PLATE_2_ID])}",
+ )
+ zf.writestr("Metadata/plate_1.json", _plate_json(PLATE_1_BOXES))
+ zf.writestr("Metadata/plate_2.json", _plate_json(PLATE_2_BOXES))
+ return buf.getvalue()
+
+
+def _single_plate_3mf(index: int) -> bytes:
+ """A per-plate export: one , but its index is the original plate number."""
+ buf = BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr(
+ "Metadata/slice_info.config",
+ f"{_plate_xml(index, [PLATE_2_ID])}",
+ )
+ zf.writestr(f"Metadata/plate_{index}.json", _plate_json(PLATE_2_BOXES))
+ return buf.getvalue()
+
+
+class TestExtractPrintableObjectsPlateScope:
+ def test_returns_only_the_requested_plates_objects(self):
+ objects, bbox_all = extract_printable_objects_from_3mf(
+ _multi_plate_3mf(), plate_number=2, include_positions=True
+ )
+ assert list(objects) == [PLATE_2_ID]
+ # Positions come from plate_2.json, not plate_1.json.
+ assert objects[PLATE_2_ID]["x"] == 50
+ assert objects[PLATE_2_ID]["y"] == 50
+ assert bbox_all == [40, 40, 60, 60]
+
+ def test_other_plate_of_the_same_file_resolves_independently(self):
+ objects, bbox_all = extract_printable_objects_from_3mf(
+ _multi_plate_3mf(), plate_number=1, include_positions=True
+ )
+ assert sorted(objects) == PLATE_1_IDS
+ assert bbox_all == [0, 0, 100, 100]
+
+ def test_no_plate_given_falls_back_to_the_first(self):
+ objects = extract_printable_objects_from_3mf(_multi_plate_3mf())
+ assert sorted(objects) == PLATE_1_IDS
+
+ def test_unknown_plate_falls_back_without_mixing_plates(self):
+ # Plate 9 doesn't exist. We fall back to the first plate — and must read
+ # ITS plate_1.json, not a plate_9.json that isn't there. Getting this
+ # wrong would return plate 1's objects with no positions at all.
+ objects, bbox_all = extract_printable_objects_from_3mf(
+ _multi_plate_3mf(), plate_number=9, include_positions=True
+ )
+ assert sorted(objects) == PLATE_1_IDS
+ assert bbox_all == [0, 0, 100, 100]
+ assert all(o["x"] is not None for o in objects.values())
+
+ def test_single_plate_export_keeps_its_own_index(self):
+ # Bambu Studio's "current plate" export carries one whose index
+ # is still the original plate number, and its positions live in
+ # plate_3.json. Asking for plate 3 must match it.
+ objects, bbox_all = extract_printable_objects_from_3mf(
+ _single_plate_3mf(3), plate_number=3, include_positions=True
+ )
+ assert list(objects) == [PLATE_2_ID]
+ assert bbox_all == [40, 40, 60, 60]
+
+
+class TestPeekPlateIndexMultiPlate:
+ def test_multi_plate_file_has_no_single_plate_index(self, tmp_path):
+ # The #1204 guard compares this against the plate parsed from gcode_file
+ # and throws the 3MF away on mismatch. An all-plates upload has no one
+ # answer, and reporting plate 1 made the guard discard a correct file.
+ path = tmp_path / "all-plates.3mf"
+ path.write_bytes(_multi_plate_3mf())
+ assert peek_plate_index_in_3mf(path) is None
+
+ def test_single_plate_file_still_reports_its_index(self, tmp_path):
+ path = tmp_path / "one-plate.3mf"
+ path.write_bytes(_single_plate_3mf(2))
+ assert peek_plate_index_in_3mf(path) == 2
+
+
+class TestLoadObjectsFromArchiveWiring:
+ """The extractor took a plate_number all along — no caller ever passed one."""
+
+ @pytest.fixture
+ def archive_3mf(self, tmp_path, monkeypatch):
+ path = tmp_path / "job.3mf"
+ path.write_bytes(_multi_plate_3mf())
+ monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+ return SimpleNamespace(file_path="job.3mf")
+
+ def _client(self, **state):
+ fields = {
+ "printable_objects": {},
+ "printable_objects_bbox_all": None,
+ "skipped_objects": [1],
+ "gcode_file": None,
+ "subtask_name": None,
+ "dispatched_plate_id": None,
+ "dispatched_subtask": None,
+ }
+ fields.update(state)
+ return SimpleNamespace(state=SimpleNamespace(**fields))
+
+ def _load(self, monkeypatch, client, archive):
+ monkeypatch.setattr(main_module.printer_manager, "get_client", lambda pid: client)
+ main_module._load_objects_from_archive(archive, 1, logging.getLogger(__name__))
+
+ def test_uses_the_plate_bambuddy_dispatched(self, monkeypatch, archive_3mf):
+ # Bambuddy-dispatched print: the plate is known from the dispatch itself,
+ # which is what the reporter's P1S does (its gcode_file echo carries no
+ # plate path — #1166).
+ client = self._client(dispatched_plate_id=2, dispatched_subtask="job", subtask_name="job")
+ self._load(monkeypatch, client, archive_3mf)
+
+ assert list(client.state.printable_objects) == [PLATE_2_ID]
+ assert client.state.printable_objects_bbox_all == [40, 40, 60, 60]
+ assert client.state.skipped_objects == []
+
+ def test_uses_the_plate_parsed_from_gcode_file(self, monkeypatch, archive_3mf):
+ # Print started outside Bambuddy: the plate comes from the gcode path.
+ client = self._client(gcode_file="/data/Metadata/plate_2.gcode")
+ self._load(monkeypatch, client, archive_3mf)
+
+ assert list(client.state.printable_objects) == [PLATE_2_ID]
+
+ def test_unknown_plate_still_loads_the_first(self, monkeypatch, archive_3mf):
+ client = self._client()
+ self._load(monkeypatch, client, archive_3mf)
+
+ assert sorted(client.state.printable_objects) == PLATE_1_IDS