Procházet zdrojové kódy

fix(queue): cache per-plate 3MF metadata so queue polling stops re-parsing every row (#2573)

The Queue listing serialized each item by opening its 3MF and re-parsing
slice_info.config three times (print time, filament usage, bed type) on
every poll, per connected client, even for unchanged files. Add a single
combined extract_plate_metadata_from_3mf() cached by (path, plate_id,
mtime_ns, size); the three legacy helpers delegate to it. An unchanged
queue now does no repeat 3MF parsing.
maziggy před 1 měsícem
rodič
revize
f8e49fed91

+ 1 - 0
CHANGELOG.md

@@ -12,6 +12,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \<model\>" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again.
 - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled.
 - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push. Follow-up: the auto-unlink now also broadcasts `spool_assignment_changed` for each cleared slot — previously only the manual assign/unassign endpoints did, so an open browser kept rendering the now-unlinked spool on the slot until an unrelated refetch, which read as "the fix didn't work" even though the server state was already correct (reporter confirmed a browser refresh showed the right state all along).
+- **Queue polling re-parsed every 3MF from scratch on each poll (#2573, reporter @Jostxxl)** — The Queue page polls `GET /api/v1/queue/` every few seconds, and for each item with a `plate_id` the serializer called three separate helpers — `extract_print_time_from_3mf`, `extract_filament_usage_from_3mf`, `extract_bed_type_from_3mf` — each of which independently opened the item's ZIP and re-parsed `Metadata/slice_info.config`. With 22 queued items that is 66 ZIP-open + XML-parse operations per poll, run in the event-loop thread, repeated for *every* connected browser even though the files never changed. The three values now come from a single combined parse (`extract_plate_metadata_from_3mf`) cached by file revision — the key is `(path, plate_id, mtime_ns, size)`, so an unchanged file is parsed at most once and a replaced or edited file transparently re-parses with no manual invalidation. The three legacy helpers still exist (other callers use them) but now delegate to the same cached parse, so usage-tracking and Spoolman paths benefit too; the queue hot path calls the combined helper once per row. The cache is a bounded (512-entry) LRU guarded by a lock so it stays small and is safe from worker threads. Listing an unchanged queue now serializes DB data and does no repeat 3MF parsing. (The reporter's broader farm-scale asks — a WebSocket-delta queue, an initial snapshot endpoint, ETag/304 support, per-row plate-request batching — are a separate queue-page redesign, not part of this fix.)
 - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo).
 - **Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl)** — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat `idle in transaction` for the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O.
 - **Timelapse scan held a DB connection open across every FTP round-trip (#2572, reporter @Jostxxl)** — After a print completes, `_scan_for_timelapse_with_retries` polls the printer's FTP server for the new timelapse file (up to 4 retry attempts, plus a name-match fallback). Each attempt opened one database session and held it across the FTP directory listing *and* the multi-MB video download — so a pooled connection sat `idle in transaction` for the whole transfer, once per attempt, per completed print. When several prints finish together on a farm that adds up. The scan now reads the archive + printer in a short session, releases the connection, does the FTP list/download with no session held, and re-opens a fresh short session only to attach the downloaded file. Behaviour is unchanged; the existing scan tests already exercise the read→download→attach path. Continues the #2572 effort (after the camera-stream fix) to stop holding sessions across slow I/O; the scheduler paths were reviewed and found already bounded (single loop + capped concurrent uploads, with an explicit pre-dispatch commit) so they were left as-is.

+ 17 - 24
backend/app/api/routes/print_queue.py

@@ -43,8 +43,7 @@ from backend.app.utils.printer_models import (
     normalize_printer_model_id,
 )
 from backend.app.utils.threemf_tools import (
-    extract_bed_type_from_3mf,
-    extract_filament_usage_from_3mf,
+    extract_plate_metadata_from_3mf,
     extract_print_time_from_3mf,
 )
 
@@ -254,17 +253,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
-                    plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
-                    plate_weight = sum(
-                        f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
-                    )
-                    plate_bed = extract_bed_type_from_3mf(archive_path, item.plate_id)
-                    if plate_time is not None:
-                        response.print_time_seconds = plate_time
-                    if plate_weight > 0:
-                        response.filament_used_grams = plate_weight
-                    if plate_bed:
-                        response.bed_type = plate_bed
+                    # One cached parse for all three per-plate overrides (#2573).
+                    plate_meta = extract_plate_metadata_from_3mf(archive_path, item.plate_id)
+                    if plate_meta.print_time_seconds is not None:
+                        response.print_time_seconds = plate_meta.print_time_seconds
+                    if plate_meta.filament_used_grams > 0:
+                        response.filament_used_grams = plate_meta.filament_used_grams
+                    if plate_meta.bed_type:
+                        response.bed_type = plate_meta.bed_type
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -286,17 +282,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
-                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
-                plate_weight = sum(
-                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
-                )
-                plate_bed = extract_bed_type_from_3mf(library_file_path, item.plate_id)
-                if plate_time is not None:
-                    response.print_time_seconds = plate_time
-                if plate_weight > 0:
-                    response.filament_used_grams = plate_weight
-                if plate_bed:
-                    response.bed_type = plate_bed
+                # One cached parse for all three per-plate overrides (#2573).
+                plate_meta = extract_plate_metadata_from_3mf(library_file_path, item.plate_id)
+                if plate_meta.print_time_seconds is not None:
+                    response.print_time_seconds = plate_meta.print_time_seconds
+                if plate_meta.filament_used_grams > 0:
+                    response.filament_used_grams = plate_meta.filament_used_grams
+                if plate_meta.bed_type:
+                    response.bed_type = plate_meta.bed_type
     if item.printer:
         response.printer_name = item.printer.name
     return response

+ 175 - 132
backend/app/utils/threemf_tools.py

@@ -11,7 +11,10 @@ import logging
 import math
 import re
 import zipfile
+from collections import OrderedDict
+from dataclasses import dataclass, field
 from pathlib import Path
+from threading import Lock
 
 import defusedxml.ElementTree as ET
 
@@ -424,6 +427,173 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         return None
 
 
+@dataclass(frozen=True)
+class PlateMetadata:
+    """Combined per-plate slice_info.config values from a single 3MF parse.
+
+    Bundles the three fields the queue listing needs so a queue poll opens and
+    parses each 3MF once instead of three times (#2573). ``filament_usage`` is
+    the full per-filament list (other callers — usage tracking, Spoolman — need
+    it); ``filament_used_grams`` is its ``used_g`` sum, precomputed here so the
+    queue path doesn't re-sum on every hit.
+    """
+
+    print_time_seconds: int | None = None
+    filament_usage: list[dict] = field(default_factory=list)
+    bed_type: str | None = None
+    filament_used_grams: float = 0.0
+
+
+_EMPTY_PLATE_METADATA = PlateMetadata()
+
+# Revision-keyed cache for parsed per-plate metadata. Queue polling re-lists the
+# same unchanged 3MFs every few seconds per connected client (#2573); without a
+# cache each row costs a ZIP open + XML parse. The key includes the file's
+# mtime_ns and size so a replaced or edited file transparently gets a fresh
+# entry — no manual invalidation needed. Bounded LRU + lock so it stays small
+# and is safe to touch from worker threads.
+_PLATE_METADATA_CACHE: "OrderedDict[tuple, PlateMetadata]" = OrderedDict()
+_PLATE_METADATA_CACHE_LOCK = Lock()
+_PLATE_METADATA_CACHE_MAX = 512
+
+
+def clear_plate_metadata_cache() -> None:
+    """Drop all cached per-plate metadata (used by tests)."""
+    with _PLATE_METADATA_CACHE_LOCK:
+        _PLATE_METADATA_CACHE.clear()
+
+
+def _parse_plate_metadata_uncached(file_path: Path, plate_id: int | None) -> PlateMetadata:
+    """Open the 3MF once and pull print time, filament usage and bed type.
+
+    Replicates the per-field ``plate_id=None`` behaviour of the three legacy
+    helpers exactly: usage collects every ``<filament>`` in the file, while
+    print time and bed type come from the first ``<plate>``.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return _EMPTY_PLATE_METADATA
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+    except Exception as e:
+        logger.warning("Failed to read plate metadata from %s: %s", file_path, e)
+        return _EMPTY_PLATE_METADATA
+
+    def _plate_index(plate_elem) -> int | None:
+        for meta in plate_elem.findall("metadata"):
+            if meta.get("key") == "index":
+                try:
+                    return int(meta.get("value", "0"))
+                except ValueError:
+                    return None
+        return None
+
+    def _collect_filaments(plate_elem) -> list[dict]:
+        out: list[dict] = []
+        for f in plate_elem.findall("filament"):
+            filament_id = f.get("id")
+            try:
+                used_amount = float(f.get("used_g", "0"))
+            except (ValueError, TypeError):
+                continue
+            if filament_id:
+                out.append(
+                    {
+                        "slot_id": int(filament_id),
+                        "used_g": used_amount,
+                        "type": f.get("type", ""),
+                        "color": f.get("color", ""),
+                    }
+                )
+        return out
+
+    print_time: int | None = None
+    bed_type: str | None = None
+    filament_usage: list[dict] = []
+    matched_plate = None
+
+    if plate_id is not None:
+        for plate_elem in root.findall(".//plate"):
+            if _plate_index(plate_elem) == plate_id:
+                matched_plate = plate_elem
+                break
+    else:
+        matched_plate = root.find(".//plate")
+
+    if matched_plate is not None:
+        for meta in matched_plate.findall("metadata"):
+            key = meta.get("key")
+            if key == "prediction" and print_time is None:
+                try:
+                    print_time = int(meta.get("value", "0"))
+                except ValueError:
+                    print_time = None
+            elif key == "curr_bed_type" and meta.get("value"):
+                bed_type = (meta.get("value") or "").strip()
+
+    if plate_id is not None:
+        if matched_plate is not None:
+            filament_usage = _collect_filaments(matched_plate)
+    else:
+        # Legacy plate_id=None usage: every filament in the file, not just plate 1.
+        for f in root.findall(".//filament"):
+            filament_id = f.get("id")
+            try:
+                used_amount = float(f.get("used_g", "0"))
+            except (ValueError, TypeError):
+                continue
+            if filament_id:
+                filament_usage.append(
+                    {
+                        "slot_id": int(filament_id),
+                        "used_g": used_amount,
+                        "type": f.get("type", ""),
+                        "color": f.get("color", ""),
+                    }
+                )
+
+    return PlateMetadata(
+        print_time_seconds=print_time,
+        filament_usage=filament_usage,
+        bed_type=bed_type,
+        filament_used_grams=sum(f["used_g"] for f in filament_usage),
+    )
+
+
+def extract_plate_metadata_from_3mf(file_path: Path, plate_id: int | None = None) -> PlateMetadata:
+    """Return combined per-plate metadata, cached by file revision (#2573).
+
+    The result is keyed by ``(path, plate_id, mtime_ns, size)`` so an unchanged
+    file is parsed at most once; a replaced/edited file re-parses automatically.
+    The returned ``PlateMetadata`` is shared and MUST be treated as read-only —
+    callers that need a mutable filament list get a copy from the wrappers below.
+    """
+    file_path = Path(file_path)
+    try:
+        stat = file_path.stat()
+    except OSError:
+        # File missing/unreadable: parse (which will return empty) but don't
+        # cache — the file may appear later and we don't want a sticky miss.
+        return _parse_plate_metadata_uncached(file_path, plate_id)
+
+    key = (str(file_path), plate_id, stat.st_mtime_ns, stat.st_size)
+    with _PLATE_METADATA_CACHE_LOCK:
+        cached = _PLATE_METADATA_CACHE.get(key)
+        if cached is not None:
+            _PLATE_METADATA_CACHE.move_to_end(key)
+            return cached
+
+    metadata = _parse_plate_metadata_uncached(file_path, plate_id)
+
+    with _PLATE_METADATA_CACHE_LOCK:
+        _PLATE_METADATA_CACHE[key] = metadata
+        _PLATE_METADATA_CACHE.move_to_end(key)
+        while len(_PLATE_METADATA_CACHE) > _PLATE_METADATA_CACHE_MAX:
+            _PLATE_METADATA_CACHE.popitem(last=False)
+    return metadata
+
+
 def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 
@@ -438,68 +608,9 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
         List of filament usage dictionaries:
         [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
     """
-    filament_usage = []
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return []
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                # Find the plate element with matching index
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass
-                            break
-
-                    if plate_index == plate_id:
-                        for f in plate_elem.findall("filament"):
-                            filament_id = f.get("id")
-                            used_g = f.get("used_g", "0")
-                            try:
-                                used_amount = float(used_g)
-                                if filament_id:
-                                    filament_usage.append(
-                                        {
-                                            "slot_id": int(filament_id),
-                                            "used_g": used_amount,
-                                            "type": f.get("type", ""),
-                                            "color": f.get("color", ""),
-                                        }
-                                    )
-                            except (ValueError, TypeError):
-                                pass
-                        break
-            else:
-                # No plate_id specified - extract all filaments
-                for f in root.findall(".//filament"):
-                    filament_id = f.get("id")
-                    used_g = f.get("used_g", "0")
-                    try:
-                        used_amount = float(used_g)
-                        if filament_id:
-                            filament_usage.append(
-                                {
-                                    "slot_id": int(filament_id),
-                                    "used_g": used_amount,
-                                    "type": f.get("type", ""),
-                                    "color": f.get("color", ""),
-                                }
-                            )
-                    except (ValueError, TypeError):
-                        pass  # Skip filament entries with unparseable usage values
-
-    except Exception:
-        pass  # Return whatever usage data was collected before the error
-
-    return filament_usage
+    # Delegate to the cached combined parse (#2573). Return fresh dicts so callers
+    # that mutate the list don't corrupt the shared cached PlateMetadata.
+    return [dict(f) for f in extract_plate_metadata_from_3mf(file_path, plate_id).filament_usage]
 
 
 def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
@@ -518,46 +629,7 @@ def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) ->
     Returns:
         Predicted print time in seconds, or None if not found / unparseable.
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass  # Skip plate with unparseable index
-                            break
-
-                    if plate_index == plate_id:
-                        for meta in plate_elem.findall("metadata"):
-                            if meta.get("key") == "prediction":
-                                try:
-                                    return int(meta.get("value", "0"))
-                                except ValueError:
-                                    return None
-                        break
-            else:
-                plate_elem = root.find(".//plate")
-                if plate_elem is not None:
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "prediction":
-                            try:
-                                return int(meta.get("value", "0"))
-                            except ValueError:
-                                return None
-    except Exception as e:
-        logger.warning("Failed to extract print time from %s: %s", file_path, e)
-
-    return None
+    return extract_plate_metadata_from_3mf(file_path, plate_id).print_time_seconds
 
 
 def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
@@ -578,36 +650,7 @@ def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> s
     Returns:
         Bed type string (e.g. "Textured PEI Plate"), or None if not found.
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            for plate_elem in root.findall(".//plate"):
-                plate_index = None
-                bed_value: str | None = None
-                for meta in plate_elem.findall("metadata"):
-                    key = meta.get("key")
-                    if key == "index":
-                        try:
-                            plate_index = int(meta.get("value", "0"))
-                        except ValueError:
-                            pass  # Skip plate with unparseable index
-                    elif key == "curr_bed_type" and meta.get("value"):
-                        bed_value = (meta.get("value") or "").strip()
-
-                if plate_id is None:
-                    # First plate wins when no plate_id is requested.
-                    return bed_value
-                if plate_index == plate_id:
-                    return bed_value
-    except Exception:
-        pass  # Return None on any failure rather than raising — caller decides
-
-    return None
+    return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
 
 
 # Header values exposed as `{placeholder}` substitutions inside snippets.

+ 133 - 0
backend/tests/unit/test_threemf_tools.py

@@ -1034,3 +1034,136 @@ class TestExtractSupportFilamentSlotsFrom3mf:
         cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"})
         with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
             assert extract_support_filament_slots_from_3mf(zf) == set()
+
+
+class TestExtractPlateMetadataFrom3mf:
+    """The combined per-plate helper parses slice_info.config once and caches
+    the result by file revision so queue polling doesn't re-open the same 3MF
+    three times per row on every poll (#2573)."""
+
+    _MULTI_PLATE = """<?xml version="1.0" encoding="UTF-8"?>
+    <config>
+        <plate>
+            <metadata key="index" value="1"/>
+            <metadata key="prediction" value="3600"/>
+            <metadata key="curr_bed_type" value="Textured PEI Plate"/>
+            <filament id="1" used_g="50.0" type="PLA" color="#FF0000"/>
+        </plate>
+        <plate>
+            <metadata key="index" value="2"/>
+            <metadata key="prediction" value="7200"/>
+            <metadata key="curr_bed_type" value="Engineering Plate"/>
+            <filament id="1" used_g="12.5" type="ABS" color="#00FF00"/>
+            <filament id="2" used_g="7.5" type="ABS" color="#0000FF"/>
+        </plate>
+    </config>
+    """
+
+    def _write(self, tmp_path, xml, name="test.3mf"):
+        from backend.app.utils.threemf_tools import clear_plate_metadata_cache
+
+        clear_plate_metadata_cache()
+        file_path = tmp_path / name
+        file_path.write_bytes(create_mock_3mf(xml).read())
+        return file_path
+
+    def test_combines_all_three_fields_for_plate(self, tmp_path):
+        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        meta = extract_plate_metadata_from_3mf(file_path, plate_id=2)
+        assert meta.print_time_seconds == 7200
+        assert meta.bed_type == "Engineering Plate"
+        assert meta.filament_used_grams == 20.0
+        assert {f["slot_id"] for f in meta.filament_usage} == {1, 2}
+
+    def test_plate_id_none_matches_legacy_behaviour(self, tmp_path):
+        # Legacy None behaviour: time+bed from the first plate, but usage
+        # collects EVERY filament in the file (not just plate 1).
+        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        meta = extract_plate_metadata_from_3mf(file_path, plate_id=None)
+        assert meta.print_time_seconds == 3600
+        assert meta.bed_type == "Textured PEI Plate"
+        assert len(meta.filament_usage) == 3  # 1 from plate 1 + 2 from plate 2
+
+    def test_second_call_hits_cache_without_reparsing(self, tmp_path):
+        from unittest.mock import patch
+
+        import backend.app.utils.threemf_tools as tools
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
+            first = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
+            second = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
+
+        assert spy.call_count == 1  # parsed once, served from cache the second time
+        assert first is second
+        assert second.print_time_seconds == 7200
+
+    def test_changed_file_reparses(self, tmp_path):
+        from unittest.mock import patch
+
+        import backend.app.utils.threemf_tools as tools
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
+            tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
+            # Replace the file with different content (and a different size, so the
+            # revision key changes even if mtime resolution is coarse).
+            new_xml = """<?xml version="1.0" encoding="UTF-8"?>
+            <config>
+                <plate>
+                    <metadata key="index" value="1"/>
+                    <metadata key="prediction" value="999"/>
+                    <metadata key="curr_bed_type" value="Cool Plate"/>
+                    <filament id="1" used_g="1.0" type="PLA" color="#FFFFFF"/>
+                </plate>
+            </config>
+            """
+            file_path.write_bytes(create_mock_3mf(new_xml).read())
+            fresh = tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
+
+        assert spy.call_count == 2  # revision changed -> re-parsed
+        assert fresh.print_time_seconds == 999
+        assert fresh.bed_type == "Cool Plate"
+
+    def test_wrapper_returns_mutable_copy(self, tmp_path):
+        # extract_filament_usage_from_3mf callers mutate the list; that must not
+        # corrupt the shared cached PlateMetadata.
+        from backend.app.utils.threemf_tools import (
+            extract_filament_usage_from_3mf,
+            extract_plate_metadata_from_3mf,
+        )
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        usage = extract_filament_usage_from_3mf(file_path, plate_id=2)
+        usage.append({"slot_id": 99, "used_g": 0.0, "type": "", "color": ""})
+        usage[0]["used_g"] = -1.0
+
+        cached = extract_plate_metadata_from_3mf(file_path, plate_id=2)
+        assert len(cached.filament_usage) == 2
+        assert all(f["used_g"] > 0 for f in cached.filament_usage)
+
+    def test_missing_file_returns_empty_and_is_not_cached(self, tmp_path):
+        from unittest.mock import patch
+
+        import backend.app.utils.threemf_tools as tools
+
+        tools.clear_plate_metadata_cache()
+        missing = tmp_path / "nope.3mf"
+
+        with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
+            meta = tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
+            tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
+
+        assert meta.print_time_seconds is None
+        assert meta.filament_usage == []
+        # Missing file must not create a sticky cache entry (it may appear later).
+        assert spy.call_count == 2