mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(usage): scope 3MF filament tracking to dispatched plate (#1697)
When a print targets a single plate from a multi-plate 3MF, both the
internal Filament Inventory tracker and the Spoolman-mode tracker parsed
the 3MF without a plate filter and summed every plate's filament — so a
single lid print debited the spool the entire file's grey + black totals.
The 3MF parser already supports plate_id (queue pre-flight uses it at
print_queue.py:254/:286). Plumbed it through both dispatch paths:
Queue path:
- PrintSession gains a plate_id field; on_print_start queries the
printer's currently-printing queue row and records queue_item.plate_id
onto the session.
- _track_from_3mf accepts plate_id and passes it to the extractor.
- store_print_data moves its existing queue-item lookup above the
extract and uses queue_item.plate_id as the plate filter.
Direct-Print path (reprintArchive / printLibraryFile — never goes
through the queue):
- _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
- register_expected_print accepts plate_id and stores it; the 2 sites in
background_dispatch.py and the 1 site in print_scheduler.py now pass
it (resolve was already happening, just needed reordering before the
register call so the value is available).
- Expected-print promotion in main.py injects _print_plate_ids[archive_id]
into the session, guarded so a queue capture wins over the dict.
- _get_start_plate_id helper feeds plate_id into all 3
_store_spoolman_print_data call sites; spoolman_tracking.store_print_data
takes the caller value first, falls back to queue_item.plate_id.
PrintArchive.filament_used_grams stays file-level summed by design
(#1593's contract — the archive describes the file, not the run); only
the per-run usage attribution becomes plate-aware. Single-plate direct
prints resolve to plate_id=1 → plate 1 = whole file, identical to the
prior no-filter behaviour.
This commit is contained in:
@@ -13,6 +13,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning).
|
||||
|
||||
### Fixed
|
||||
- **Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by @volodymyr-doba)** — Reporter on P1S printed a single lid (~190 g grey PETG) from `gridfinity-storage-box-5x4x6.gcode.3mf` (a multi-plate file with 5×box + 5×lid plates) and the spool's Usage History recorded 242 g of grey + 31 g of black — the **whole file's** filament total, not the dispatched plate. The print took 5 h 47 m which matches the lid alone, and the queue card correctly previewed 190 g, but the spool got debited for everything. **Root cause: usage tracking parsed the 3MF without a plate filter.** `extract_filament_usage_from_3mf(file_path, plate_id)` in `backend/app/utils/threemf_tools.py` already supports filtering and the queue's pre-flight capacity check at `api/routes/print_queue.py:254/:286` passes `item.plate_id`, but the two completion-time recorders did not: `_track_from_3mf` in `services/usage_tracker.py:907` (internal Filament Inventory) and `store_print_data` in `services/spoolman_tracking.py:223` (Spoolman mode) both called the extractor with no plate_id and summed every plate. Per `feedback_inventory_modes_parity` both modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (`api.reprintArchive` / `api.printLibraryFile` with `plate_id: selectedPlate` in `PrintModal/index.tsx:739/750`) hits the same bug because it never goes through the queue — caught before merge by tracing the frontend dispatch surface end-to-end. **Fix — two complementary captures:** (1) `PrintSession` gains a `plate_id: int | None` field; `on_print_start` queries `PrintQueueItem` for the printer's currently-printing row and records `queue_item.plate_id` onto the session — covers the queue path. (2) `register_expected_print` in `main.py` accepts a new `plate_id` parameter and stores it in a parallel `_print_plate_ids: dict[int, int]` dict (mirror of `_print_ams_mappings`); `background_dispatch.py`'s 2 register sites and `print_scheduler.py`'s 1 register site now pass plate_id (the dispatch already resolved it via `_resolve_plate_id`; reordering the resolve to run before register is a no-op since the resolver is pure). At expected-print promotion, `main.py` injects `_print_plate_ids[archive_id]` into `_active_sessions[printer_id].plate_id` (only when the session has no plate_id yet — queue captures win), mirroring the existing `ams_mapping` injection pattern. The dict drains on `on_print_complete` and on TTL eviction of the matching `_expected_prints` entry — same lifecycle as `_print_ams_mappings`. (3) `_track_from_3mf` accepts a new `plate_id` kwarg, threads it from `session.plate_id`, and passes it to `extract_filament_usage_from_3mf`. (4) `store_print_data` accepts a `plate_id` kwarg; the 3 call sites in `main.py` pass `_get_start_plate_id(archive_id)` (new helper, parallel to `_get_start_ams_mapping`); within `store_print_data` the caller value wins, falling back to `queue_item.plate_id` for the queue path. **The PrintArchive's `filament_used_grams` stays file-level summed by design** (#1593's contract — the archive describes the file, not the run); only the per-run usage attribution becomes plate-aware. **What this intentionally does NOT touch:** for direct Print of a single-plate file, `_resolve_plate_id` returns 1 → registered as `plate_id=1`, which extracts plate 1 = the whole file — identical to the prior no-filter behaviour. The change is observable only for multi-plate 3MFs where a specific non-first plate was dispatched. **Tests:** 9 new across `test_usage_tracker.py` + `test_spoolman_tracking.py` + `test_print_start_expected_promotion.py` — plate_id propagation through `_track_from_3mf`; absence leaves it `None`; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract; `register_expected_print` stores `plate_id` in `_print_plate_ids`; `_get_start_plate_id` reads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existing `test_prefers_explicit_ams_mapping_over_queue_mapping` updated for the new unconditional queue lookup (was conditional, now always queries to capture plate_id). Full 5830-test backend suite green. Ruff clean across the entire backend, not just touched files.
|
||||
|
||||
- **AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by @kleinwareio)** — On a 3-AMS P1S the reporter's screenshot showed AMS-C slots labelled "Empty" even though spools were physically loaded; OrcaSlicer's Device view showed the same slots as loaded. **Root cause:** the compact label below the AMS slot circle in PrintersPage rendered `tray.tray_type || t('ams.slotEmpty')`, falling back to "Empty" whenever the printer firmware hadn't been told which material is in the slot. The codebase already had a `getEmptySlotKind` helper that distinguishes `'physical'` (firmware confirmed empty via state 9/10) from `'reset'` (tray_type absent but firmware hasn't confirmed empty — i.e. spool loaded, just unassigned). The hover-card / circle border already used that distinction (line 814+ comment); the compact label did not. **Fix:** label now branches on `emptyKind` — `'physical'` keeps "Empty" (the firmware-confirmed empty case), `'reset'` shows "?" (matching the slicer's own convention for "loaded but unknown material"). External / VT tray label is unchanged (external trays have no "configured/unconfigured" distinction — they're either loaded or not). The SpoolBuddy kiosk's `AmsUnitCard` was carrying the same bug and got the same fix (mirror of `getEmptySlotKind`, "?" vs "Empty" label, tooltip "Spool loaded — slot not configured"). **i18n:** new `ams.slotUnconfigured: '?'` key added to all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — value is universal so it's identical in every locale. The existing `ams.emptySlotReset = 'No filament assigned'` tooltip surface in FilamentHoverCard already covers the "what does this mean" question on hover, so no new tooltip key needed for the main card. **Tests:** AmsUnitCard vitest gains `shows "?" for loaded-but-unconfigured slot (#1694)` pinning both branches in one render (one slot with `state: 9` → "Empty", one with no state → "?"). Existing AMS tests stay green (9/9 SpoolBuddy AmsUnitCard; AMS load/unload page tests untouched and green); i18n parity green (5100 leaves × 11 locales); frontend build clean; ESLint clean.
|
||||
|
||||
- **Virtual Printer MQTT no longer disconnects idle OrcaSlicer at keepalive×1.5 (#1548 round 2, reported by @hollajandro)** — Round 1 (commits b6636053 + 4ffefa60) shipped the keepalive parser + 1.5× idle disconnect per MQTT spec §4.4 and a per-minute status-push diagnostic. Reporter's follow-up pcap proved the round-1 logic was correct as designed, but exposed the actual root cause: the same OrcaSlicer install which stays connected to a real Bambu P1S indefinitely sends zero MQTT packets after the initial CONNECT / SUBSCRIBE / pushall / get_version burst — no PINGREQ at all — so any §4.4-compliant server disconnects it at `keep_alive × 1.5`. **Real Bambu firmware does not enforce §4.4** (verified: the reporter's identical Orca install holds an idle session against real hardware on the same network), so spec compliance is itself the regression. **Fix:** after CONNECT/auth, drop the application-level read timeout entirely (`read_timeout = None`) and set `SO_KEEPALIVE` on the underlying socket so the OS TCP stack detects truly dead connections within a few minutes. The 60 s pre-CONNECT timeout is preserved — a client that opens TCP but never sends CONNECT still gets reaped to prevent half-open resource leaks. Negotiated keepalive is still parsed and now logged at INFO ("MQTT client … authenticated (negotiated keepalive=Xs, idle disconnect disabled)") for support-bundle visibility. **Tests:** TestHandleClientIdleConnection adds `test_idle_client_stays_open_past_one_and_a_half_times_keepalive` (negotiates keep_alive=2, sits idle for 4 s, asserts handler still running and writer not closed — direct round-1 inversion), `test_so_keepalive_set_on_socket_after_connect` pins `setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1)` runs on the wrapped socket the moment auth succeeds. PINGREQ test docstring updated since there's no longer a timeout for it to "reset". All 33 VP MQTT server tests green; ruff clean. After this ships, OrcaSlicer should stay connected to the VP indefinitely while idle and reconnect cleanly on real network drops.
|
||||
|
||||
+49
-5
@@ -349,6 +349,13 @@ _expected_prints: dict[tuple[int, str], int] = {}
|
||||
# Used by usage tracker to map 3MF slots to physical AMS trays
|
||||
_print_ams_mappings: dict[int, list[int]] = {}
|
||||
|
||||
# Track plate_id for prints from multi-plate 3MFs: {archive_id: plate_id}
|
||||
# Used by usage tracker to scope 3MF parsing to the dispatched plate (#1697).
|
||||
# Populated by direct-Print and queue dispatch paths; queue prints also have a
|
||||
# redundant queue-item lookup in on_print_start so this dict isn't load-bearing
|
||||
# for the queue path. Cleared on print completion or TTL eviction.
|
||||
_print_plate_ids: dict[int, int] = {}
|
||||
|
||||
# Track progress milestones for notifications: {printer_id: last_milestone_notified}
|
||||
# Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
|
||||
_last_progress_milestone: dict[int, int] = {}
|
||||
@@ -567,6 +574,7 @@ def register_expected_print(
|
||||
archive_id: int,
|
||||
ams_mapping: list[int] | None = None,
|
||||
created_by_id: int | None = None,
|
||||
plate_id: int | None = None,
|
||||
):
|
||||
"""Register an expected print from reprint/scheduled so we don't create duplicate archives."""
|
||||
# Store with multiple filename variations to catch different naming patterns
|
||||
@@ -579,6 +587,11 @@ def register_expected_print(
|
||||
# Store AMS mapping for usage tracking at print completion
|
||||
if ams_mapping is not None:
|
||||
_print_ams_mappings[archive_id] = ams_mapping
|
||||
# Store plate_id for usage tracking when this is a single-plate dispatch from
|
||||
# a multi-plate 3MF — without this, the direct-Print path attributes the whole
|
||||
# file's filament total to the spool instead of just the printed plate (#1697).
|
||||
if plate_id is not None:
|
||||
_print_plate_ids[archive_id] = plate_id
|
||||
# Store created_by_id so the user start email can be sent even when the archive
|
||||
# itself has no created_by_id (e.g. library-file-based queue prints)
|
||||
if created_by_id is not None:
|
||||
@@ -595,7 +608,7 @@ def register_expected_print(
|
||||
_expected_print_registered_at[(printer_id, base)] = _registered_at
|
||||
_expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
|
||||
logging.getLogger(__name__).info(
|
||||
f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
|
||||
f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}, plate_id={plate_id}"
|
||||
)
|
||||
|
||||
|
||||
@@ -639,6 +652,19 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
|
||||
return stored_ams_mapping
|
||||
|
||||
|
||||
def _get_start_plate_id(archive_id: int | None) -> int | None:
|
||||
"""Resolve plate_id for print start without consuming stored direct-Print state.
|
||||
|
||||
Direct-Print of a single plate from a multi-plate 3MF registers plate_id in
|
||||
``_print_plate_ids`` at dispatch time; this lets the spoolman / usage tracker
|
||||
read it back at print-start without popping (the entry is popped on print
|
||||
completion or TTL eviction, mirroring ``_print_ams_mappings``).
|
||||
"""
|
||||
if archive_id is None:
|
||||
return None
|
||||
return _print_plate_ids.get(archive_id)
|
||||
|
||||
|
||||
def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
|
||||
"""Best-effort filament metadata from the MQTT print-start snapshot.
|
||||
|
||||
@@ -2215,14 +2241,21 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
# before expected-print promotion, so it may have ams_mapping=None when
|
||||
# the MQTT request topic subscription failed (common on P1S/A1).
|
||||
_stored_map = _print_ams_mappings.get(expected_archive_id)
|
||||
if _stored_map:
|
||||
_stored_plate_id = _print_plate_ids.get(expected_archive_id)
|
||||
if _stored_map or _stored_plate_id is not None:
|
||||
try:
|
||||
from backend.app.services.usage_tracker import _active_sessions
|
||||
|
||||
_ut_session = _active_sessions.get(printer_id)
|
||||
if _ut_session and not _ut_session.ams_mapping:
|
||||
if _ut_session and _stored_map and not _ut_session.ams_mapping:
|
||||
_ut_session.ams_mapping = _stored_map
|
||||
logger.info("[CALLBACK] Injected ams_mapping into usage tracker session: %s", _stored_map)
|
||||
# plate_id injection covers direct-Print of plate N of a multi-plate
|
||||
# 3MF — queue prints already capture it via the on_print_start queue
|
||||
# lookup, but direct-Print never goes through the queue (#1697).
|
||||
if _ut_session and _stored_plate_id is not None and _ut_session.plate_id is None:
|
||||
_ut_session.plate_id = _stored_plate_id
|
||||
logger.info("[CALLBACK] Injected plate_id into usage tracker session: %s", _stored_plate_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2265,6 +2298,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
db,
|
||||
printer_manager,
|
||||
ams_mapping=_get_start_ams_mapping(data, archive.id),
|
||||
plate_id=_get_start_plate_id(archive.id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
|
||||
@@ -2798,6 +2832,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
db,
|
||||
printer_manager,
|
||||
ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),
|
||||
plate_id=_get_start_plate_id(fallback_archive.id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
|
||||
@@ -2899,6 +2934,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
db,
|
||||
printer_manager,
|
||||
ams_mapping=_get_start_ams_mapping(data, archive.id),
|
||||
plate_id=_get_start_plate_id(archive.id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
|
||||
@@ -3923,6 +3959,12 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
if not stored_ams_mapping and archive_id:
|
||||
stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
|
||||
|
||||
# Always drain the plate_id register on completion — the session already
|
||||
# consumed it at print-start injection; leaving it would leak into the next
|
||||
# print on the same archive_id (rare but possible with reprints) (#1697).
|
||||
if archive_id:
|
||||
_print_plate_ids.pop(archive_id, None)
|
||||
|
||||
# Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
@@ -5126,12 +5168,14 @@ def _evict_stale_expected_prints() -> None:
|
||||
_expected_print_creators.pop(key, None)
|
||||
_expected_print_registered_at.pop(key, None)
|
||||
|
||||
# Also clean up _print_ams_mappings for archive_ids that have no remaining
|
||||
# live keys in _expected_prints (i.e. all variants were just evicted).
|
||||
# Also clean up _print_ams_mappings and _print_plate_ids for archive_ids
|
||||
# that have no remaining live keys in _expected_prints (all variants
|
||||
# were just evicted).
|
||||
live_archive_ids = set(_expected_prints.values())
|
||||
for archive_id in evicted_archive_ids:
|
||||
if archive_id not in live_archive_ids:
|
||||
_print_ams_mappings.pop(archive_id, None)
|
||||
_print_plate_ids.pop(archive_id, None)
|
||||
|
||||
logging.getLogger(__name__).info(
|
||||
"Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
|
||||
|
||||
@@ -670,15 +670,19 @@ class BackgroundDispatchService:
|
||||
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
|
||||
)
|
||||
|
||||
# Resolve plate_id before register so usage tracking can scope the
|
||||
# 3MF parse to the dispatched plate at print-start (#1697). Pure
|
||||
# transform of file_path + options, safe to reorder.
|
||||
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
|
||||
|
||||
register_expected_print(
|
||||
job.printer_id,
|
||||
remote_filename,
|
||||
job.source_id,
|
||||
ams_mapping=job.options.get("ams_mapping"),
|
||||
plate_id=plate_id,
|
||||
)
|
||||
|
||||
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
|
||||
@@ -874,15 +878,18 @@ class BackgroundDispatchService:
|
||||
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
|
||||
)
|
||||
|
||||
# Resolve plate_id before register so usage tracking can scope the
|
||||
# 3MF parse to the dispatched plate at print-start (#1697).
|
||||
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
|
||||
|
||||
register_expected_print(
|
||||
job.printer_id,
|
||||
remote_filename,
|
||||
archive.id,
|
||||
ams_mapping=job.options.get("ams_mapping"),
|
||||
plate_id=plate_id,
|
||||
)
|
||||
|
||||
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
|
||||
|
||||
@@ -2143,6 +2143,7 @@ class PrintScheduler:
|
||||
archive.id,
|
||||
ams_mapping=ams_mapping,
|
||||
created_by_id=item.created_by_id,
|
||||
plate_id=item.plate_id,
|
||||
)
|
||||
|
||||
# Propagate the queue item's owner into printer_manager so the
|
||||
|
||||
@@ -189,6 +189,7 @@ async def store_print_data(
|
||||
db,
|
||||
printer_manager,
|
||||
ams_mapping: list[int] | None = None,
|
||||
plate_id: int | None = None,
|
||||
):
|
||||
"""Store Spoolman tracking data at print start (persisted to database).
|
||||
|
||||
@@ -196,6 +197,13 @@ async def store_print_data(
|
||||
how the internal Filament Inventory works. The legacy AMS-remain%-based sync
|
||||
is no longer used as a weight writer (#1119), so this runs whenever Spoolman
|
||||
is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
|
||||
|
||||
``plate_id``, when set, scopes the 3MF filament extract to a single plate so
|
||||
queue / direct-Print dispatch of plate N of a multi-plate file doesn't
|
||||
attribute every plate's filament to the printed spool (#1697). When unset,
|
||||
the queue item's plate_id (if any) is used; otherwise the whole-file sum is
|
||||
extracted, which is correct for direct prints that target the first/only
|
||||
plate of a single-plate file.
|
||||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
||||
@@ -219,8 +227,20 @@ async def store_print_data(
|
||||
logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
|
||||
return
|
||||
|
||||
# Extract per-filament usage from 3MF (total usage per slot)
|
||||
filament_usage = extract_filament_usage_from_3mf(full_path)
|
||||
# Resolve the queue item once — used both for the plate-scoped 3MF parsing
|
||||
# fallback (#1697: multi-plate file dispatched for one plate must only count
|
||||
# that plate's filament) and for the ams_mapping fallback below.
|
||||
queue_result = await db.execute(
|
||||
select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
|
||||
)
|
||||
queue_item = queue_result.scalar_one_or_none()
|
||||
# Caller-supplied plate_id wins (direct-Print path); fall back to the queue
|
||||
# item's plate_id (queue dispatch path).
|
||||
effective_plate_id = plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
|
||||
|
||||
# Extract per-filament usage from 3MF (total usage for the dispatched plate,
|
||||
# or the whole file for direct/library prints with no plate_id).
|
||||
filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id)
|
||||
if not filament_usage:
|
||||
logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
|
||||
return
|
||||
@@ -234,18 +254,11 @@ async def store_print_data(
|
||||
# Prefer the explicit mapping captured from the print command, then fall back
|
||||
# to any queue mapping stored for scheduled/reprint jobs.
|
||||
slot_to_tray = ams_mapping if ams_mapping is not None else None
|
||||
if not slot_to_tray:
|
||||
queue_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.archive_id == archive_id)
|
||||
.where(PrintQueueItem.status == "printing")
|
||||
)
|
||||
queue_item = queue_result.scalar_one_or_none()
|
||||
if queue_item and queue_item.ams_mapping:
|
||||
try:
|
||||
slot_to_tray = json.loads(queue_item.ams_mapping)
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore malformed AMS mapping; fall back to default slot assignment
|
||||
if not slot_to_tray and queue_item and queue_item.ams_mapping:
|
||||
try:
|
||||
slot_to_tray = json.loads(queue_item.ams_mapping)
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore malformed AMS mapping; fall back to default slot assignment
|
||||
|
||||
# Parse G-code for per-layer filament usage (for accurate partial usage tracking)
|
||||
layer_usage = extract_layer_filament_usage_from_3mf(full_path)
|
||||
|
||||
@@ -214,6 +214,10 @@ class PrintSession:
|
||||
spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
|
||||
# AMS mapping from print command (captured at start, needed when auto-archive is off)
|
||||
ams_mapping: list[int] | None = None
|
||||
# Queue item's plate_id when this print is a multi-plate 3MF dispatched for a
|
||||
# single plate (#1697). None for non-queue prints — the file's first/only plate
|
||||
# is the default and the 3MF parser already returns the full file in that case.
|
||||
plate_id: int | None = None
|
||||
|
||||
|
||||
# Module-level storage, keyed by printer_id
|
||||
@@ -380,6 +384,21 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
|
||||
{f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
|
||||
)
|
||||
|
||||
# Capture the queue item's plate_id so 3MF parsing at completion is scoped to
|
||||
# the plate that actually ran, not the whole multi-plate file (#1697).
|
||||
plate_id: int | None = None
|
||||
if db:
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
queue_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == printer_id)
|
||||
.where(PrintQueueItem.status == "printing")
|
||||
)
|
||||
queue_item = queue_result.scalars().first()
|
||||
if queue_item is not None:
|
||||
plate_id = queue_item.plate_id
|
||||
|
||||
# Always create session (even without valid remain data) so print_name
|
||||
# is available at completion for 3MF-based tracking
|
||||
session = PrintSession(
|
||||
@@ -390,6 +409,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
|
||||
tray_now_at_start=tray_now_at_start,
|
||||
spool_assignments=spool_assignments,
|
||||
ams_mapping=data.get("ams_mapping"),
|
||||
plate_id=plate_id,
|
||||
)
|
||||
_active_sessions[printer_id] = session
|
||||
|
||||
@@ -490,6 +510,7 @@ async def on_print_complete(
|
||||
spool_assignments=session.spool_assignments if session else None,
|
||||
print_started_at=session.started_at if session else None,
|
||||
threemf_path=threemf_path,
|
||||
plate_id=session.plate_id if session else None,
|
||||
)
|
||||
results.extend(threemf_results)
|
||||
|
||||
@@ -855,6 +876,7 @@ async def _track_from_3mf(
|
||||
spool_assignments: dict[tuple[int, int], int] | None = None,
|
||||
print_started_at: datetime | None = None,
|
||||
threemf_path=None,
|
||||
plate_id: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Track usage from 3MF per-filament slicer data (primary path).
|
||||
|
||||
@@ -865,6 +887,11 @@ async def _track_from_3mf(
|
||||
When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
|
||||
can be provided to still track filament usage from slicer data.
|
||||
|
||||
When ``plate_id`` is set (queue prints of a single plate from a multi-plate
|
||||
3MF), only that plate's filaments contribute. Without it the 3MF parser sums
|
||||
every plate, which is correct for direct/library Print flows that always
|
||||
target the first or only plate (#1697).
|
||||
|
||||
Slot-to-tray mapping priority:
|
||||
1. Stored ams_mapping from print command (reprints/direct prints)
|
||||
2. MQTT mapping field from printer state (universal, all print sources)
|
||||
@@ -904,12 +931,12 @@ async def _track_from_3mf(
|
||||
logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
|
||||
return []
|
||||
|
||||
filament_usage = extract_filament_usage_from_3mf(file_path)
|
||||
filament_usage = extract_filament_usage_from_3mf(file_path, plate_id)
|
||||
if not filament_usage:
|
||||
logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
|
||||
return []
|
||||
|
||||
logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
|
||||
logger.info("[UsageTracker] 3MF: archive %s, plate_id=%s, filament_usage=%s", archive_id, plate_id, filament_usage)
|
||||
|
||||
# --- Resolve slot-to-tray mapping ---
|
||||
mapping_source = None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for Spoolman tracking service helpers."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -213,8 +214,14 @@ class TestStorePrintData:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
|
||||
db = AsyncMock()
|
||||
# store_print_data now queries the queue item unconditionally (to pick up
|
||||
# plate_id for multi-plate 3MFs, #1697), then deletes any stale spoolman
|
||||
# row before inserting the new one. Two execute calls in that order.
|
||||
queue_item = SimpleNamespace(ams_mapping=json.dumps([2, -1, -1, -1]), plate_id=None)
|
||||
queue_result = MagicMock()
|
||||
queue_result.scalar_one_or_none.return_value = queue_item
|
||||
delete_result = MagicMock()
|
||||
db.execute = AsyncMock(side_effect=[delete_result])
|
||||
db.execute = AsyncMock(side_effect=[queue_result, delete_result])
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
@@ -250,7 +257,7 @@ class TestStorePrintData:
|
||||
db.add.assert_called_once()
|
||||
tracking = db.add.call_args.args[0]
|
||||
assert tracking.slot_to_tray == [1, -1, -1, -1]
|
||||
db.execute.assert_called_once()
|
||||
assert db.execute.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_tracking_when_disable_weight_sync_is_false(self):
|
||||
|
||||
@@ -18,7 +18,9 @@ from backend.app.main import (
|
||||
_expected_print_creators,
|
||||
_expected_print_registered_at,
|
||||
_expected_prints,
|
||||
_get_start_plate_id,
|
||||
_print_ams_mappings,
|
||||
_print_plate_ids,
|
||||
register_expected_print,
|
||||
)
|
||||
|
||||
@@ -30,12 +32,14 @@ def _clear_dicts():
|
||||
_expected_print_registered_at.clear()
|
||||
_expected_print_creators.clear()
|
||||
_print_ams_mappings.clear()
|
||||
_print_plate_ids.clear()
|
||||
_active_prints.clear()
|
||||
yield
|
||||
_expected_prints.clear()
|
||||
_expected_print_registered_at.clear()
|
||||
_expected_print_creators.clear()
|
||||
_print_ams_mappings.clear()
|
||||
_print_plate_ids.clear()
|
||||
_active_prints.clear()
|
||||
|
||||
|
||||
@@ -69,6 +73,24 @@ class TestRegisterExpectedPrint:
|
||||
ts = _expected_print_registered_at[(1, "test.3mf")]
|
||||
assert before <= ts <= after
|
||||
|
||||
def test_stores_plate_id(self):
|
||||
"""plate_id is registered so usage tracking can scope multi-plate 3MFs (#1697)."""
|
||||
register_expected_print(1, "test.3mf", archive_id=10, plate_id=2)
|
||||
assert _print_plate_ids[10] == 2
|
||||
|
||||
def test_no_plate_id_when_none(self):
|
||||
"""Direct-Print of a single-plate file passes plate_id=None; nothing stored."""
|
||||
register_expected_print(1, "test.3mf", archive_id=10, plate_id=None)
|
||||
assert 10 not in _print_plate_ids
|
||||
|
||||
def test_get_start_plate_id_reads_back(self):
|
||||
register_expected_print(1, "test.3mf", archive_id=10, plate_id=3)
|
||||
assert _get_start_plate_id(10) == 3
|
||||
|
||||
def test_get_start_plate_id_returns_none_for_unregistered(self):
|
||||
assert _get_start_plate_id(10) is None
|
||||
assert _get_start_plate_id(None) is None
|
||||
|
||||
|
||||
class TestExpectedPrintDetection:
|
||||
"""Verify the expected-print detection logic used in on_print_start.
|
||||
@@ -339,3 +361,79 @@ class TestAMSMappingInjection:
|
||||
assert ut_session.ams_mapping == [5, 6] # unchanged
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
|
||||
class TestPlateIdInjection:
|
||||
"""Verify plate_id injection into usage tracker session for direct-Print of
|
||||
a non-first plate from a multi-plate 3MF (#1697)."""
|
||||
|
||||
def test_injection_into_session(self):
|
||||
"""plate_id from _print_plate_ids gets injected when session has none."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.app.services.usage_tracker import PrintSession, _active_sessions
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
# Session created by on_print_start before expected-print promotion;
|
||||
# plate_id is None because no queue item was found (direct-Print path).
|
||||
session = PrintSession(
|
||||
printer_id=1,
|
||||
print_name="Box",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
tray_remain_start={},
|
||||
tray_now_at_start=-1,
|
||||
spool_assignments={},
|
||||
ams_mapping=None,
|
||||
plate_id=None,
|
||||
)
|
||||
_active_sessions[1] = session
|
||||
|
||||
register_expected_print(1, "Box.3mf", archive_id=54, plate_id=2)
|
||||
|
||||
# Mirror the injection branch from main.py.
|
||||
_stored_plate_id = _print_plate_ids.get(54)
|
||||
assert _stored_plate_id == 2
|
||||
|
||||
ut_session = _active_sessions.get(1)
|
||||
assert ut_session is not None
|
||||
assert ut_session.plate_id is None # before injection
|
||||
|
||||
ut_session.plate_id = _stored_plate_id # injection
|
||||
assert ut_session.plate_id == 2
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
def test_no_injection_when_session_already_has_plate_id(self):
|
||||
"""Queue path: on_print_start already captured plate_id from queue_item;
|
||||
don't overwrite with the dict value."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.app.services.usage_tracker import PrintSession, _active_sessions
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
session = PrintSession(
|
||||
printer_id=1,
|
||||
print_name="Box",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
tray_remain_start={},
|
||||
tray_now_at_start=-1,
|
||||
spool_assignments={},
|
||||
ams_mapping=None,
|
||||
plate_id=3, # captured from queue_item by on_print_start
|
||||
)
|
||||
_active_sessions[1] = session
|
||||
|
||||
register_expected_print(1, "Box.3mf", archive_id=54, plate_id=2)
|
||||
|
||||
_stored_plate_id = _print_plate_ids.get(54)
|
||||
ut_session = _active_sessions.get(1)
|
||||
|
||||
# Guard: don't overwrite if session already has a plate_id
|
||||
if ut_session and ut_session.plate_id is None:
|
||||
ut_session.plate_id = _stored_plate_id
|
||||
|
||||
assert ut_session.plate_id == 3 # queue value preserved
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for Spoolman tracking service helpers."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -183,8 +184,14 @@ class TestStorePrintData:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
|
||||
db = AsyncMock()
|
||||
# store_print_data now queries the queue item unconditionally (to pick up
|
||||
# plate_id for multi-plate 3MFs, #1697), then deletes any stale spoolman
|
||||
# row before inserting the new one. Two execute calls in that order.
|
||||
queue_item = SimpleNamespace(ams_mapping=json.dumps([2, -1, -1, -1]), plate_id=None)
|
||||
queue_result = MagicMock()
|
||||
queue_result.scalar_one_or_none.return_value = queue_item
|
||||
delete_result = MagicMock()
|
||||
db.execute = AsyncMock(side_effect=[delete_result])
|
||||
db.execute = AsyncMock(side_effect=[queue_result, delete_result])
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
@@ -220,4 +227,48 @@ class TestStorePrintData:
|
||||
db.add.assert_called_once()
|
||||
tracking = db.add.call_args.args[0]
|
||||
assert tracking.slot_to_tray == [1, -1, -1, -1]
|
||||
db.execute.assert_called_once()
|
||||
assert db.execute.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_queue_plate_id_to_3mf_extract(self):
|
||||
"""Multi-plate 3MFs queued for one plate must only count that plate's filament (#1697)."""
|
||||
db = AsyncMock()
|
||||
queue_item = SimpleNamespace(ams_mapping=None, plate_id=2)
|
||||
queue_result = MagicMock()
|
||||
queue_result.scalar_one_or_none.return_value = queue_item
|
||||
delete_result = MagicMock()
|
||||
db.execute = AsyncMock(side_effect=[queue_result, delete_result])
|
||||
db.add = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]}
|
||||
)
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__.return_value = mock_path
|
||||
|
||||
extract_mock = MagicMock(return_value=[{"slot_id": 1, "used_g": 190.0, "type": "PETG", "color": "#888888"}])
|
||||
|
||||
with (
|
||||
patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
|
||||
patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true", "true"])),
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", extract_mock),
|
||||
patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
|
||||
):
|
||||
await store_print_data(
|
||||
printer_id=1,
|
||||
archive_id=15,
|
||||
file_path="archives/test.3mf",
|
||||
db=db,
|
||||
printer_manager=printer_manager,
|
||||
ams_mapping=[1, -1, -1, -1],
|
||||
)
|
||||
|
||||
# plate_id=2 must be passed as the second positional arg
|
||||
assert extract_mock.call_count == 1
|
||||
assert extract_mock.call_args.args[1] == 2
|
||||
|
||||
@@ -56,11 +56,12 @@ def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None
|
||||
return archive
|
||||
|
||||
|
||||
def _make_queue_item(ams_mapping=None, status="printing"):
|
||||
def _make_queue_item(ams_mapping=None, status="printing", plate_id=None):
|
||||
"""Create a mock PrintQueueItem object."""
|
||||
item = MagicMock()
|
||||
item.ams_mapping = ams_mapping
|
||||
item.status = status
|
||||
item.plate_id = plate_id
|
||||
return item
|
||||
|
||||
|
||||
@@ -2008,6 +2009,48 @@ class TestOnPrintStartAmsMapping:
|
||||
|
||||
assert _active_sessions[1].ams_mapping is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_captures_queue_plate_id(self):
|
||||
"""on_print_start records the queue item's plate_id onto the session (#1697)."""
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
queue_item = _make_queue_item(plate_id=2)
|
||||
# on_print_start now executes: SpoolAssignment lookup, then PrintQueueItem lookup.
|
||||
db = AsyncMock()
|
||||
assignment_result = MagicMock()
|
||||
assignment_result.scalars.return_value.all.return_value = []
|
||||
queue_result = MagicMock()
|
||||
queue_result.scalars.return_value.first.return_value = queue_item
|
||||
db.execute = AsyncMock(side_effect=[assignment_result, queue_result])
|
||||
|
||||
await on_print_start(1, {"subtask_name": "Test"}, printer_manager, db=db)
|
||||
|
||||
assert _active_sessions[1].plate_id == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plate_id_none_when_no_queue_item(self):
|
||||
"""Direct/library prints with no queue item leave session.plate_id = None."""
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
db = AsyncMock()
|
||||
assignment_result = MagicMock()
|
||||
assignment_result.scalars.return_value.all.return_value = []
|
||||
queue_result = MagicMock()
|
||||
queue_result.scalars.return_value.first.return_value = None
|
||||
db.execute = AsyncMock(side_effect=[assignment_result, queue_result])
|
||||
|
||||
await on_print_start(1, {"subtask_name": "Test"}, printer_manager, db=db)
|
||||
|
||||
assert _active_sessions[1].plate_id is None
|
||||
|
||||
|
||||
class TestFindThreemfByFilename:
|
||||
"""Tests for _find_3mf_by_filename() — library/archive search without archive_id."""
|
||||
@@ -2205,3 +2248,91 @@ class TestTrackFrom3mfWithPreresolvedPath:
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["weight_used"] == 2.0
|
||||
|
||||
|
||||
class TestTrackFrom3mfPlateId:
|
||||
"""plate_id must propagate from PrintSession through _track_from_3mf to the
|
||||
3MF parser, so multi-plate files dispatched for one plate only count that
|
||||
plate's filament (#1697)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_plate_id_to_3mf_extract(self):
|
||||
spool = _make_spool(spool_id=1, label_weight=1000)
|
||||
assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
|
||||
|
||||
db = _mock_db_sequential([assignment, spool])
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": []}]},
|
||||
tray_now=0,
|
||||
last_loaded_tray=0,
|
||||
tray_change_log=[],
|
||||
)
|
||||
|
||||
extract_mock = MagicMock(return_value=[{"slot_id": 1, "used_g": 190.0, "type": "PETG", "color": "#888888"}])
|
||||
|
||||
with (
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", extract_mock),
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=None,
|
||||
status="completed",
|
||||
print_name="GridfinityLid",
|
||||
handled_trays=set(),
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
tray_now_at_start=0,
|
||||
threemf_path=mock_path,
|
||||
plate_id=2,
|
||||
)
|
||||
|
||||
# plate_id=2 passed positionally as second arg
|
||||
assert extract_mock.call_count == 1
|
||||
assert extract_mock.call_args.args[1] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plate_id_none_for_non_queue_print(self):
|
||||
spool = _make_spool(spool_id=1, label_weight=1000)
|
||||
assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
|
||||
|
||||
db = _mock_db_sequential([assignment, spool])
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": []}]},
|
||||
tray_now=0,
|
||||
last_loaded_tray=0,
|
||||
tray_change_log=[],
|
||||
)
|
||||
|
||||
extract_mock = MagicMock(return_value=[{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": "#FF0000"}])
|
||||
|
||||
with (
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", extract_mock),
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
|
||||
# No plate_id kwarg — direct/library Print flow.
|
||||
await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=None,
|
||||
status="completed",
|
||||
print_name="DirectPrint",
|
||||
handled_trays=set(),
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
tray_now_at_start=0,
|
||||
threemf_path=mock_path,
|
||||
)
|
||||
|
||||
assert extract_mock.call_args.args[1] is None
|
||||
|
||||
Reference in New Issue
Block a user