diff --git a/CHANGELOG.md b/CHANGELOG.md index 18923f0b7..b6bef66f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to Bambuddy will be documented in this file. - **"Auto-add unknown RFID spools" toggle + global confirmation modal for unknown spools (requested by @maziggy after a wave of duplicate-inventory reports)** — New setting under Settings → Filament → Filament Tracking. Default is ON (current behaviour preserved); turning it OFF stops Bambuddy from auto-creating an inventory entry when an unknown RFID tag is read by the AMS. Use case: users who manually pre-register new spools on delivery (so the inventory record carries their notes / weight / cost) were getting silent duplicates the first time they loaded each spool — the auto-matcher requires exact material+colour+subtype+brand, and pre-created records rarely match strictly enough. Backend gates the auto-create in BOTH inventory modes (`backend/app/main.py` for the built-in inventory loop and `backend/app/services/spoolman.py::sync_ams_tray` for the Spoolman path; auto-sync + both manual sync routes in `backend/app/api/routes/spoolman.py` thread the flag). When suppressed, the existing `unknown_tag` WS event fires so the frontend can surface the slot. **Confirmation modal.** A new global modal pops up on the next page render whenever an unknown RFID is detected — shows the printer / AMS-X label / slot number, the spool's material + colour swatch, and asks the user whether to add it now ("Add to Inventory" / "Cancel"). Mounted in `Layout.tsx` (inside ``) so the prompt appears regardless of which page the user is on, but never on SpoolBuddy kiosk / login / setup routes. Multiple concurrent unknown spools queue and present one-at-a-time; the frontend won't double-queue the same slot. Backed by two new explicit endpoints: `POST /api/v1/inventory/spools/from-slot` (built-in inventory) and `POST /api/v1/spoolman/spools/from-slot` (Spoolman), gated on `INVENTORY_UPDATE` / `FILAMENTS_UPDATE` respectively. Both look up the slot's current tray data server-side and create + auto-assign the spool atomically. SpoolBuddy frontend is unchanged — its existing `handleQuickAddToInventory` flow already covers the kiosk's separate path. **Backend dedup that prevents nag and survives a failed broadcast.** `_unknown_tag_last_broadcast: dict[printer_id, dict[(ams_id, tray_id), (tag_uid, tray_uuid)]]` in `main.py` ensures the same (slot, tag) pair only broadcasts ONCE per MQTT-push cycle, no matter how often the firmware re-asserts the slot state. The slot's empty-tray-data MQTT push clears that slot's entry, so remove-then-reinsert reliably re-prompts. Successful matches (`get_spool_by_tag`, `find_matching_untagged_spool`, auto-create) also clear the entry so a future tag swap on the same slot re-prompts. The dedup-set runs AFTER `await ws_manager.broadcast(...)` completes, so a crash mid-await doesn't poison the dict and permanently silence the slot (an earlier draft set the dedup before the await — bit on a `NameError` regression during development). **Tray data shipped with the event, not looked up.** The WS payload now carries `tray_type`, `tray_color`, `tray_sub_brands`, and `tray_count` straight from the live MQTT message, so the modal renders the correct material / colour without depending on the React Query `printerStatus` cache (which lagged the WS event by several seconds during the first end-to-end test and showed `PLA / #FF0000` instead of the actual filament). Frontend hook `useUnknownTagPrompt` reads them out of the event detail directly. **Shared `getAmsLabel`.** Moved from `PrintersPage.tsx` (and a near-duplicate in `ConfigureAmsSlotModal.tsx`) to `frontend/src/utils/amsHelpers.ts`. Both consumers now import the shared version; the canonical implementation produces `AMS-A / AMS-B / HT-A / External` rather than the bare `AMS 3` my first draft of the modal emitted. **Spoolman `from-slot` no longer reports success when the slot binding fails.** Earlier in this audit pass a swallowed `try/except Exception: rollback + log` left the route returning `{"success": True}` even when the slot-assignment INSERT was rolled back; the user saw the "Spool added" toast while the modal re-fired on the next MQTT push. Now raises HTTP 500 with the underlying error so the frontend surfaces it. **`AppSettings` TypeScript interface.** Added `spoolman_enabled: boolean`, `auto_add_unknown_rfid: boolean`, and `spoolman_url: string` — the backend has always returned these on `/api/v1/settings/` (verified by `test_settings_api.py:144` which asserts `result["spoolman_enabled"] is True`), but the TS type omitted them and required a runtime cast; now strictly typed. **i18n.** 9 new keys (`settings.autoAddUnknownRfid`, `settings.autoAddUnknownRfidDesc`, `inventory.addToInventory`, `inventory.addToInventoryPending`, `inventory.addToInventorySuccess`, `inventory.addToInventoryFailed`, `inventory.unknownSpoolTitle`, `inventory.unknownSpoolMessage`, `inventory.unknownSpoolSlot`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5301 leaves per locale. **Scope.** No DB migration (setting lives in the existing `settings` key-value table; same shape on SQLite and Postgres). No new permission. Manual `Sync AMS` actions also honour the setting — the skipped-spool result list reports `Auto-add disabled; add to inventory manually` so the user knows the slot was intentionally skipped, not failed. **Verification.** Full backend `pytest -n 30`: 6367/6367 in 79 s. Focused integration suite (settings / Spoolman / slot-assignments / slot-concurrency): 122/122. Frontend `npm run build` clean. Backend `ruff` clean. End-to-end smoke-tested live on an H2D + dual AMS setup: insert → modal pops with the correct `AMS-B Slot 4` + real material / colour; Cancel → no re-prompt; remove + reinsert → modal returns; Add → spool lands in inventory + slot tile shows it. +- **Spoolman weight tracking for no-3MF "Untitled" prints (#1820, requested by @ojimpo)** — Closes a long-standing parity gap between Bambuddy's two inventory modes. When a Bambu print starts that Bambuddy can't fetch a `.gcode.3mf` for — typically an unsaved BambuStudio project, where the printer reports `subtask_name: 名称未設定` ("Untitled") and FTP returns 550 for every candidate path — the existing flow created a fallback archive but Spoolman saw no weight change for that print. The internal-inventory side already handles this via the Path 2 AMS remain%-delta fallback in `usage_tracker.on_print_complete` (line 517). Spoolman now mirrors the same shape. **`store_print_data`** now captures `tray_remain_start` (per-slot `remain%` + `tray_uuid` at print start) on every print — keyed `"-"`, slots with invalid `remain` (e.g. -1, AMS hasn't read the spool yet) silently dropped, VT external trays encoded as `ams_id=255` to match internal — and no longer early-returns when the 3MF is missing: it creates an `ActivePrintSpoolman` row with `filament_usage=None` carrying only the snapshot, so the completion path has something to work with. **`report_usage`** keeps its 3MF path as the primary writer and adds `_report_remain_delta_for_slots` for any slot the 3MF path didn't cover (no-3MF entirely OR partial coverage where slice_info omitted a slot). The fallback resolves each slot to its Spoolman spool via the existing `spoolman_slot_assignments` table, looks up the curated `Filament.weight` from the spool's filament record, and writes `(start_remain - current_remain) × weight / 100` grams via `client.use_spool(...)`. **No `tray_weight` from MQTT** — the failure mode #1119 documented (non-RFID spools have no MQTT `tray_weight`, so remain% × tray_weight gave garbage and silently mis-tracked) is dodged the same way internal inventory dodges it: by reading the user-curated reference weight from the inventory store rather than trusting MQTT's raw field. RFID gate not needed — Spoolman's curated `Filament.weight` is present for RFID and non-RFID spools alike. **Mid-print spool swap detection** — when `tray_uuid` differs between start snapshot and completion read, the slot is skipped rather than mis-attributed. We don't know how much of the print went to which spool; preserving correctness is better than guessing. **Double-charge guard** — slots already written by the 3MF path land in a `handled_global_tray_ids` set that the fallback consults before charging, so a 3MF-covered slot can't also pick up a remain delta. **#1119 invariant preserved** — the deprecated AMS-remain%-based GLOBAL writer is still gone. This is per-slot, per-print, gated on a valid start/current `remain` AND a resolvable Spoolman spool. No new setting, no toggle: the parity rule [[feedback_inventory_modes_parity]] applies — same shape as internal inventory, which is unconditional. **No-op default** — installs with no Spoolman slot assignments, no RFID-readable AMS, or no print-time remain% (printer offline at start, AMS still loading) see no behaviour change. **DB.** `active_print_spoolman` gets a new nullable `tray_remain_start TEXT` column via `_safe_execute(ALTER TABLE … ADD COLUMN)`, and the existing `filament_usage TEXT NOT NULL` is relaxed to nullable — for SQLite via `writable_schema = ON` + `sqlite_master` patch + `schema_version` bump (same surgical pattern used for `users.password_hash` NULL relaxation a few hundred lines below), for Postgres via `ALTER COLUMN … DROP NOT NULL`. SQLite + Postgres parity verified. CREATE TABLE updated to emit the new shape on fresh installs. **Tests.** 11 new unit cases in `test_spoolman_no3mf_remain_fallback.py`: 5 for `_snapshot_tray_remain` (valid remain captured, invalid remain skipped, VT tray encoding, empty raw_data, missing uuid defaulted to ""), 3 for `store_print_data` no-3MF behaviour (row created with snapshot when no 3MF + valid remain; no row when neither 3MF nor remain; 3MF path also captures snapshot for partial-coverage fallback), 3 for `report_usage` remain-delta (writes `(start-end) × Filament.weight / 100` to resolved spool; skips swapped spool when `tray_uuid` changed; skips slots already handled by 3MF). Full `pytest -n 30` green on the Spoolman + tracking + archive + on-print suites (1205/1205). Backend `ruff` clean. - **NTP-gate state exposed on the appliance endpoint** — `GET /api/v1/system/appliance` gains a `time_synced` field returning `"ok"`, `"warning"`, or `null`. Source: `/run/bambuddy/time-synced`, written by the appliance's `ntp-gate.sh` once chronyd reports sync (or after a 3-minute timeout with a `"warning"` marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. New `backend/app/core/local_config.py::read_ntp_gate` is defensive on every failure mode (file absent → `None`, OSError → `None` + warning log, empty / unknown content → `None`, binary garbage survives via `errors="replace"`). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once `"ok"` comes through. 8 new unit cases for `read_ntp_gate` (absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (`time_synced` is `null`) — the appliance is the only consumer for now. - **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required). diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f6e2b8dc6..1ba7d6c29 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1528,7 +1528,10 @@ async def run_migrations(conn): except (OperationalError, ProgrammingError): pass # Already applied - # Create active_print_spoolman table for Spoolman per-filament tracking + # Create active_print_spoolman table for Spoolman per-filament tracking. + # filament_usage is nullable so the no-3MF branch can still create a row + # that carries only tray_remain_start for the remain%-delta fallback + # (#1820 — matches internal-inventory Path 2 in usage_tracker). await _safe_execute( conn, """ @@ -1536,11 +1539,12 @@ async def run_migrations(conn): id INTEGER PRIMARY KEY AUTOINCREMENT, printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE, - filament_usage TEXT NOT NULL, + filament_usage TEXT, ams_trays TEXT NOT NULL, slot_to_tray TEXT, layer_usage TEXT, filament_properties TEXT, + tray_remain_start TEXT, UNIQUE(printer_id, archive_id) ) """ @@ -1550,15 +1554,50 @@ async def run_migrations(conn): id SERIAL PRIMARY KEY, printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE, - filament_usage TEXT NOT NULL, + filament_usage TEXT, ams_trays TEXT NOT NULL, slot_to_tray TEXT, layer_usage TEXT, filament_properties TEXT, + tray_remain_start TEXT, UNIQUE(printer_id, archive_id) ) """, ) + # Migration for installs that already created active_print_spoolman with + # the original schema: add tray_remain_start, and relax filament_usage's + # NOT NULL so the no-3MF branch can persist a remain-only tracking row. + await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_remain_start TEXT") + if is_sqlite(): + # SQLite can't ALTER COLUMN; patch sqlite_master directly. Mirrors the + # users.password_hash NULL-relaxation a few hundred lines below — see + # the comment there for the schema_version bump rationale. + try: + result = await conn.execute( + text("SELECT sql FROM sqlite_master WHERE type='table' AND name='active_print_spoolman'") + ) + tbl_sql = result.scalar() + if tbl_sql and "filament_usage TEXT NOT NULL" in tbl_sql: + version_result = await conn.execute(text("PRAGMA schema_version")) + schema_version = version_result.scalar() or 0 + await conn.execute(text("PRAGMA writable_schema = ON")) + await conn.execute( + text( + "UPDATE sqlite_master " + "SET sql = replace(sql, 'filament_usage TEXT NOT NULL', 'filament_usage TEXT') " + "WHERE type='table' AND name='active_print_spoolman'" + ) + ) + await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}")) + await conn.execute(text("PRAGMA writable_schema = OFF")) + except (OperationalError, ProgrammingError) as exc: + logger.warning( + "Could not relax active_print_spoolman.filament_usage NOT NULL via writable_schema: %s — " + "no-3MF Spoolman fallback will be a no-op on this install", + exc, + ) + else: + await _safe_execute(conn, "ALTER TABLE active_print_spoolman ALTER COLUMN filament_usage DROP NOT NULL") # Migration: Add preset_source column to slot_preset_mappings for local preset support try: diff --git a/backend/app/models/active_print_spoolman.py b/backend/app/models/active_print_spoolman.py index 51cf83039..c5c0f1f4a 100644 --- a/backend/app/models/active_print_spoolman.py +++ b/backend/app/models/active_print_spoolman.py @@ -24,7 +24,11 @@ class ActivePrintSpoolman(Base): archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE")) # Per-filament usage from 3MF: [{"slot_id": 1, "used_g": 50.5, "type": "PLA"}, ...] - filament_usage: Mapped[list] = mapped_column(JSON) + # Nullable for the no-3MF case ("Untitled" prints where Bambu didn't keep a + # .gcode.3mf on the printer): the row still gets created so the completion + # path can use ``tray_remain_start`` for an AMS remain%-delta write, + # mirroring the internal-inventory Path 2 fallback in usage_tracker (#1820). + filament_usage: Mapped[list | None] = mapped_column(JSON, nullable=True) # AMS tray state at print start: {0: {"tray_uuid": "...", "tag_uid": "..."}, ...} ams_trays: Mapped[dict] = mapped_column(JSON) @@ -40,3 +44,10 @@ class ActivePrintSpoolman(Base): # Filament properties (density, diameter per filament slot) # Format: {1: {"density": 1.24, "diameter": 1.75, "type": "PLA"}, ...} filament_properties: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # AMS tray remain% per slot at print start, captured so the completion + # path can compute a remain-delta when the 3MF didn't cover a slot (or + # there was no 3MF at all — #1820). Matches the internal-inventory + # ``tray_remain_start`` snapshot at usage_tracker.py:301. + # Format: {"-": {"remain": int, "tray_uuid": str}, ...} + tray_remain_start: Mapped[dict | None] = mapped_column(JSON, nullable=True) diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index eb919b178..1ce18a154 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/backend/app/services/spoolman_tracking.py @@ -182,6 +182,52 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]: return lookup +def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]: + """Capture per-slot ``remain%`` + ``tray_uuid`` at print start so the + completion path can compute a remain-delta when 3MF data doesn't cover + the slot (or there's no 3MF at all — #1820). + + Returns ``{"-": {"remain": int, "tray_uuid": str}}``. + Only slots whose ``remain`` is a valid 0..100 int are included; invalid + values mean the AMS hasn't read the spool yet and a delta would be + meaningless. Mirrors the gate in + ``usage_tracker.on_print_start:309``. + """ + snapshot: dict[str, dict] = {} + ams_raw = raw_data.get("ams", []) + ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else [] + for ams_unit in ams_data: + if not isinstance(ams_unit, dict): + continue + ams_id = int(ams_unit.get("id", 0)) + for tray in ams_unit.get("tray", []): + if not isinstance(tray, dict): + continue + tray_id = int(tray.get("id", 0)) + remain = tray.get("remain", -1) + if isinstance(remain, int) and 0 <= remain <= 100: + snapshot[f"{ams_id}-{tray_id}"] = { + "remain": remain, + "tray_uuid": tray.get("tray_uuid", "") or "", + } + vt_tray_raw = raw_data.get("vt_tray") or [] + if isinstance(vt_tray_raw, dict): + vt_tray_raw = [vt_tray_raw] + for vt in vt_tray_raw: + if not isinstance(vt, dict): + continue + vt_id = int(vt.get("id", 254)) + # 254 → (255, 0), 255 → (255, 1) — matches usage_tracker's encoding. + vt_tray_id = vt_id - 254 + remain = vt.get("remain", -1) + if isinstance(remain, int) and 0 <= remain <= 100: + snapshot[f"255-{vt_tray_id}"] = { + "remain": remain, + "tray_uuid": vt.get("tray_uuid", "") or "", + } + return snapshot + + async def store_print_data( printer_id: int, archive_id: int, @@ -219,38 +265,65 @@ async def store_print_data( if not spoolman_enabled or spoolman_enabled.lower() != "true": return - # Get 3MF file path + # Get current AMS tray state up front — needed both for the 3MF path's + # ams_trays field and for the remain%-delta snapshot (#1820 fallback for + # no-3MF "Untitled" prints, mirroring usage_tracker.on_print_start). + state = printer_manager.get_status(printer_id) + ams_trays: dict[int, dict] = {} + tray_remain_start: dict[str, dict] = {} + if state and state.raw_data: + ams_trays = build_ams_tray_lookup(state.raw_data) + tray_remain_start = _snapshot_tray_remain(state.raw_data) + + # Try to read per-slot filament estimates from the 3MF. Two paths can + # leave ``filament_usage`` empty: (1) fallback archive (no .gcode.3mf + # was downloadable from the printer — "Untitled" prints, see #1820), + # (2) 3MF present but slice_info missing per-filament estimates. + # Both fall through to the remain%-delta path at completion. + filament_usage: list | None = None + layer_usage_json: dict | None = None + filament_properties: dict | None = None full_path = ( app_settings.base_dir / file_path ) # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated - if not full_path.exists(): - logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path) + threemf_available = bool(file_path) and full_path.exists() + queue_item = None + if threemf_available: + # 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) + ) + filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None + + layer_usage = extract_layer_filament_usage_from_3mf(full_path) + if layer_usage: + # Convert int keys to string for JSON serialization + layer_usage_json = {str(k): v for k, v in layer_usage.items()} + logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage)) + + filament_properties = extract_filament_properties_from_3mf(full_path) + else: + # No 3MF on disk — common for "Untitled" prints whose .gcode.3mf + # was never on the printer's FTP. Logged at debug since the + # fallback path below picks up the slack when remain% is available. + logger.debug("[SPOOLMAN] 3MF file not available: %s", full_path) + + # If neither path has anything useful, there's nothing to track. + if not filament_usage and not tray_remain_start: + if threemf_available: + logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id) return - # 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 - - # Get current AMS tray state - state = printer_manager.get_status(printer_id) - ams_trays = {} - if state and state.raw_data: - ams_trays = build_ams_tray_lookup(state.raw_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 @@ -260,17 +333,6 @@ async def store_print_data( 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) - layer_usage_json = None - if layer_usage: - # Convert int keys to string for JSON serialization - layer_usage_json = {str(k): v for k, v in layer_usage.items()} - logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage)) - - # Extract filament properties (density, diameter) for mm -> grams conversion - filament_properties = extract_filament_properties_from_3mf(full_path) - # Delete any existing row for this printer/archive (shouldn't exist, but just in case) await db.execute( delete(ActivePrintSpoolman) @@ -278,7 +340,8 @@ async def store_print_data( .where(ActivePrintSpoolman.archive_id == archive_id) ) - # Insert new tracking data + # Insert new tracking data. ``filament_usage`` may be None for the + # no-3MF case; report_usage falls back to ``tray_remain_start``. tracking = ActivePrintSpoolman( printer_id=printer_id, archive_id=archive_id, @@ -287,11 +350,18 @@ async def store_print_data( slot_to_tray=slot_to_tray, layer_usage=layer_usage_json, filament_properties=filament_properties, + tray_remain_start=tray_remain_start or None, ) db.add(tracking) await db.commit() - logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id) + logger.info( + "[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s (3mf=%s, remain_snapshot=%d slot(s))", + printer_id, + archive_id, + "yes" if filament_usage else "no", + len(tray_remain_start), + ) logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage) logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys())) if slot_to_tray: @@ -571,6 +641,7 @@ async def _report_partial_usage( filament_usage = tracking.filament_usage or [] ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()} slot_to_tray = tracking.slot_to_tray + tray_remain_start = tracking.tray_remain_start or {} printer_serial = await _get_printer_serial(printer_id) client = await _get_spoolman_client_with_fallback() @@ -578,6 +649,24 @@ async def _report_partial_usage( logger.warning("[SPOOLMAN] Not reachable for partial usage reporting") return + # No-3MF aborted print (#1820 mirror of the completion path): nothing in + # filament_usage or layer_usage to base partial estimates on, but the + # remain%-delta snapshot we captured at start still describes consumption + # up to the abort moment. Write it the same way report_usage's fallback + # does, then return — there's no 3MF-derived partial to layer on top. + # ``state`` was already fetched at the top of the function for current_layer. + if not filament_usage and not layer_usage and tray_remain_start: + current_lookup = _snapshot_tray_remain(state.raw_data) if state and state.raw_data else {} + await _report_remain_delta_for_slots( + client, + printer_id=printer_id, + tray_remain_start=tray_remain_start, + current_lookup=current_lookup, + handled_global_tray_ids=set(), + archive_id=getattr(tracking, "archive_id", -1), + ) + return + # Try to use accurate G-code parsed data if layer_usage: layer_usage_int = { @@ -670,8 +759,15 @@ async def _report_partial_usage( async def report_usage(printer_id: int, archive_id: int): """Report filament usage to Spoolman after print completion. - Uses per-filament usage data captured at print start to report - usage to the correct spools. + Two writers, mirroring the internal-inventory split in usage_tracker: + + 1. **3MF path (primary)** — per-filament slice estimates captured at + print start drive a precise per-slot ``use_spool`` call. + 2. **AMS remain%-delta (fallback)** — for slots the 3MF path didn't + handle (including the no-3MF "Untitled" case from #1820): compute + ``start_remain - current_remain``, multiply by the resolved + Spoolman filament's reference weight, and write the delta. Mirrors + ``usage_tracker.on_print_complete`` Path 2 (line 517). """ async with async_session() as db: from backend.app.api.routes.settings import get_setting @@ -692,14 +788,15 @@ async def report_usage(printer_id: int, archive_id: int): filament_usage = tracking.filament_usage or [] ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()} slot_to_tray = tracking.slot_to_tray + tray_remain_start = tracking.tray_remain_start or {} printer_serial = await _get_printer_serial(printer_id) # Delete tracking row (we're done with it) await db.delete(tracking) await db.commit() - if not filament_usage: - logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id) + if not filament_usage and not tray_remain_start: + logger.debug("[SPOOLMAN] No usage data or remain-snapshot for archive %s", archive_id) return # Check if Spoolman is enabled @@ -712,20 +809,48 @@ async def report_usage(printer_id: int, archive_id: int): logger.warning("[SPOOLMAN] Not reachable for usage reporting") return - logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id) - - usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage] slot_colors: dict[int, str] = {} - spools_updated = await _report_spool_usage_for_slots( - client, - usage_items, - ams_trays, - slot_to_tray, - f"Archive {archive_id}", - printer_serial, - printer_id=printer_id, - slot_colors_out=slot_colors, - ) + handled_global_tray_ids: set[int] = set() + spools_updated = 0 + + # --- Path 1: 3MF per-slot estimates ----------------------------- + if filament_usage: + logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id) + usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage] + spools_updated = await _report_spool_usage_for_slots( + client, + usage_items, + ams_trays, + slot_to_tray, + f"Archive {archive_id}", + printer_serial, + printer_id=printer_id, + slot_colors_out=slot_colors, + ) + # Track which physical slots the 3MF path already covered so + # Path 2 doesn't double-charge them. + for u in filament_usage: + slot_id = u.get("slot_id", 0) + handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)) + + # --- Path 2: AMS remain%-delta for slots 3MF didn't cover ------- + # Triggered for no-3MF "Untitled" prints (#1820) AND for partial + # 3MF coverage (slots whose filament_id wasn't in slice_info). + if tray_remain_start: + from backend.app.services.printer_manager import printer_manager + + current = printer_manager.get_status(printer_id) + current_lookup = _snapshot_tray_remain(current.raw_data) if current and current.raw_data else {} + fallback_updates = await _report_remain_delta_for_slots( + client, + printer_id=printer_id, + tray_remain_start=tray_remain_start, + current_lookup=current_lookup, + handled_global_tray_ids=handled_global_tray_ids, + archive_id=archive_id, + slot_colors_out=slot_colors, + ) + spools_updated += fallback_updates if spools_updated == 0: logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id) @@ -738,6 +863,121 @@ async def report_usage(printer_id: int, archive_id: int): await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors) +async def _report_remain_delta_for_slots( + client, + *, + printer_id: int, + tray_remain_start: dict[str, dict], + current_lookup: dict[str, dict], + handled_global_tray_ids: set[int], + archive_id: int, + slot_colors_out: dict[int, str] | None = None, +) -> int: + """AMS remain%-delta path: write ``(start - current) * filament.weight`` + grams to Spoolman for slots the 3MF path didn't cover. + + Mirrors ``usage_tracker.on_print_complete`` Path 2: per-slot, gated on a + valid current ``remain%``, skipped on spool swap (``tray_uuid`` changed), + using the resolved spool's filament reference weight rather than MQTT's + unreliable ``tray_weight`` (which is the failure mode #1119 documented). + """ + spools_updated = 0 + for slot_key, start in tray_remain_start.items(): + try: + ams_id_str, tray_id_str = slot_key.split("-", 1) + ams_id, tray_id = int(ams_id_str), int(tray_id_str) + except (ValueError, AttributeError): + continue + + # Skip slots already handled by the 3MF path. Encoding mirrors + # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps + # its native id (>=128), regular AMS slots are ams_id*4+tray_id. + if ams_id == 255: + global_tray_id = 254 + tray_id + elif ams_id >= 128: + global_tray_id = ams_id + else: + global_tray_id = ams_id * 4 + tray_id + if global_tray_id in handled_global_tray_ids: + continue + + current = current_lookup.get(slot_key) + if not current: + logger.debug("[SPOOLMAN] AMS%d-T%d: no current remain%% at completion, skipping fallback", ams_id, tray_id) + continue + + # Spool swap mid-print — tray_uuid changed. We don't know how much + # of the print went to which spool; skip rather than mis-attribute. + start_uuid = (start.get("tray_uuid") or "").lower() + cur_uuid = (current.get("tray_uuid") or "").lower() + if start_uuid and cur_uuid and start_uuid != cur_uuid: + logger.info( + "[SPOOLMAN] AMS%d-T%d: spool swapped mid-print (uuid changed), skipping remain-delta", ams_id, tray_id + ) + continue + + delta_pct = start["remain"] - current["remain"] + if delta_pct <= 0: + continue # No consumption captured at AMS granularity, or refilled + + spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id) + if spool_id is None: + logger.debug("[SPOOLMAN] AMS%d-T%d: no Spoolman slot assignment, skipping fallback", ams_id, tray_id) + continue + + # Look up the spool's filament reference weight. Use a fresh GET so + # we don't depend on a stale cached_spools list. Failure here is + # silent-skip rather than fatal — other slots can still be written. + try: + spool = await client.get_spool(spool_id) + except Exception as exc: # noqa: BLE001 + logger.debug("[SPOOLMAN] AMS%d-T%d: get_spool(%s) failed: %s", ams_id, tray_id, spool_id, exc) + continue + filament = spool.get("filament") or {} + ref_weight = filament.get("weight") + if not ref_weight or ref_weight <= 0: + logger.debug( + "[SPOOLMAN] AMS%d-T%d: spool %s has no filament.weight, skipping remain-delta", + ams_id, + tray_id, + spool_id, + ) + continue + + grams_used = round((delta_pct / 100.0) * ref_weight, 2) + if grams_used <= 0: + continue + try: + await client.use_spool(spool_id, grams_used) + except Exception as exc: # noqa: BLE001 + logger.warning( + "[SPOOLMAN] AMS%d-T%d: use_spool(%s, %.2fg) failed: %s", ams_id, tray_id, spool_id, grams_used, exc + ) + continue + + spools_updated += 1 + if slot_colors_out is not None: + color = filament.get("color_hex") + if color: + # No 3MF slot_id for this path — use the AMS slot key so the + # colour map can still be inspected by callers if needed. + # The archive-colour rewrite (#1494) keys on 3MF slot_ids so + # remain-delta-only prints intentionally don't participate + # in that rewrite (matches usage_tracker's slot_id=None). + slot_colors_out[-(global_tray_id + 1)] = color + logger.info( + "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s", + archive_id, + ams_id, + tray_id, + grams_used, + delta_pct, + ref_weight, + spool_id, + ) + return spools_updated + + async def _apply_spool_colors_to_archive( db, archive_id: int, diff --git a/backend/tests/unit/test_spoolman_no3mf_remain_fallback.py b/backend/tests/unit/test_spoolman_no3mf_remain_fallback.py new file mode 100644 index 000000000..838145bba --- /dev/null +++ b/backend/tests/unit/test_spoolman_no3mf_remain_fallback.py @@ -0,0 +1,436 @@ +"""AMS remain%-delta fallback for the no-3MF Spoolman path (#1820). + +When a Bambu print starts without leaving a retrievable .gcode.3mf on the +printer (subtask_name='名称未設定'/'Untitled'), Bambuddy creates a +fallback archive with no 3MF on disk. Before this fix the Spoolman +tracking row was never created, so the print silently didn't decrement +the spool weight. This is the Spoolman mirror of usage_tracker's Path 2 +fallback (already in place for the internal-inventory side). +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.spoolman_tracking import ( + _snapshot_tray_remain, + store_print_data, +) + + +class TestSnapshotTrayRemain: + def test_captures_valid_remain(self): + raw = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_uuid": "AAAA", "remain": 75}, + {"id": 1, "tray_uuid": "BBBB", "remain": 30}, + ], + } + ] + } + snap = _snapshot_tray_remain(raw) + assert snap == { + "0-0": {"remain": 75, "tray_uuid": "AAAA"}, + "0-1": {"remain": 30, "tray_uuid": "BBBB"}, + } + + def test_skips_invalid_remain(self): + """remain=-1 means the AMS hasn't read the spool; a delta would be + meaningless. Skip those slots — usage_tracker does the same + (line 309).""" + raw = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_uuid": "AAAA", "remain": 75}, + {"id": 1, "tray_uuid": "BBBB", "remain": -1}, + {"id": 2, "tray_uuid": "CCCC", "remain": 150}, + ], + } + ] + } + snap = _snapshot_tray_remain(raw) + assert set(snap.keys()) == {"0-0"} + + def test_captures_vt_tray(self): + """External (VT) spool gets ams_id=255, tray_id=vt_id-254.""" + raw = {"ams": [], "vt_tray": [{"id": 254, "tray_uuid": "EEEE", "remain": 50}]} + snap = _snapshot_tray_remain(raw) + assert snap == {"255-0": {"remain": 50, "tray_uuid": "EEEE"}} + + def test_empty_when_no_ams_data(self): + assert _snapshot_tray_remain({}) == {} + + def test_handles_missing_uuid(self): + raw = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]} + snap = _snapshot_tray_remain(raw) + assert snap == {"0-0": {"remain": 80, "tray_uuid": ""}} + + +class TestStorePrintDataNo3mf: + """store_print_data must create an ActivePrintSpoolman row even when + no 3MF is available, populating tray_remain_start so report_usage can + write a remain-delta at completion (#1820).""" + + @pytest.mark.asyncio + async def test_creates_row_with_snapshot_when_no_3mf(self): + db = AsyncMock() + # No queue lookup for the no-3MF branch — only the DELETE. + delete_result = MagicMock() + db.execute = AsyncMock(side_effect=[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_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA", "remain": 80}, + {"id": 1, "tray_uuid": "BBBB", "tag_uid": "22", "tray_type": "PLA", "remain": 20}, + ], + } + ] + } + ) + + mock_settings = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = False # no 3MF — the #1820 case + mock_settings.base_dir.__truediv__.return_value = mock_path + + with ( + patch("backend.app.services.spoolman_tracking.app_settings", mock_settings), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + ): + await store_print_data( + printer_id=1, + archive_id=42, + file_path="", # fallback-archive file_path + db=db, + printer_manager=printer_manager, + ) + + db.add.assert_called_once() + tracking = db.add.call_args.args[0] + assert tracking.filament_usage is None + assert tracking.tray_remain_start == { + "0-0": {"remain": 80, "tray_uuid": "AAAA"}, + "0-1": {"remain": 20, "tray_uuid": "BBBB"}, + } + + @pytest.mark.asyncio + async def test_no_row_when_no_3mf_and_no_remain_data(self): + """If the AMS has no slot with valid remain either (e.g. printer + offline at print start), there's nothing to track. Don't create + a row that contributes no value.""" + db = AsyncMock() + db.execute = AsyncMock() + db.add = MagicMock() + db.commit = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]} + ) + + mock_settings = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = False + mock_settings.base_dir.__truediv__.return_value = mock_path + + with ( + patch("backend.app.services.spoolman_tracking.app_settings", mock_settings), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + ): + await store_print_data( + printer_id=1, + archive_id=42, + file_path="", + db=db, + printer_manager=printer_manager, + ) + + db.add.assert_not_called() + + @pytest.mark.asyncio + async def test_3mf_path_also_captures_snapshot(self): + """The remain snapshot is captured ALWAYS, not just for no-3MF. + That lets report_usage fall back per-slot when 3MF coverage is + partial — same shape as usage_tracker.on_print_complete which + runs Path 1 (3MF) and Path 2 (remain delta) for unhandled slots.""" + db = AsyncMock() + queue_item = SimpleNamespace(ams_mapping=None, plate_id=None) + 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_uuid": "AAAA", "tray_type": "PLA", "remain": 90}]}]} + ) + + mock_settings = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__.return_value = mock_path + + with ( + patch("backend.app.services.spoolman_tracking.app_settings", mock_settings), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}], + ), + 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=42, + file_path="archives/test.3mf", + db=db, + printer_manager=printer_manager, + ) + + db.add.assert_called_once() + tracking = db.add.call_args.args[0] + assert tracking.filament_usage == [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}] + assert tracking.tray_remain_start == {"0-0": {"remain": 90, "tray_uuid": "AAAA"}} + + +class TestReportUsageRemainDelta: + """report_usage must write a per-slot remain-delta when filament_usage + is missing (no-3MF print), gated on a resolvable Spoolman spool and a + sane current remain%.""" + + @pytest.mark.asyncio + async def test_remain_delta_writes_to_resolved_spool(self): + """Print started at remain=80% on a 1000g filament, finished at 60%. + Delta = 20% × 1000g = 200g.""" + from backend.app.services.spoolman_tracking import report_usage + + tracking = SimpleNamespace( + filament_usage=None, + ams_trays={"0": {"tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA"}}, + slot_to_tray=None, + tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}}, + ) + + # Mock db.execute().scalar_one_or_none() -> tracking + db = AsyncMock() + select_result = MagicMock() + select_result.scalar_one_or_none.return_value = tracking + db.execute = AsyncMock(return_value=select_result) + db.delete = AsyncMock() + db.commit = AsyncMock() + + client = AsyncMock() + client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0, "color_hex": "00FF00"}}) + client.use_spool = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]} + ) + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")), + patch( + "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", + AsyncMock(return_value=7), + ), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=42) + + client.use_spool.assert_awaited_once_with(7, 200.0) + + @pytest.mark.asyncio + async def test_remain_delta_skips_swapped_spool(self): + """tray_uuid changed between start and completion → user replaced + the spool mid-print. We don't know how much went to each side; skip + rather than mis-charge.""" + from backend.app.services.spoolman_tracking import report_usage + + tracking = SimpleNamespace( + filament_usage=None, + ams_trays={"0": {"tray_uuid": "AAAA"}}, + slot_to_tray=None, + tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}}, + ) + + db = AsyncMock() + select_result = MagicMock() + select_result.scalar_one_or_none.return_value = tracking + db.execute = AsyncMock(return_value=select_result) + db.delete = AsyncMock() + db.commit = AsyncMock() + + client = AsyncMock() + client.use_spool = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + # tray_uuid changed -> swap detected + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "CCCC", "remain": 50}]}]} + ) + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")), + patch( + "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", + AsyncMock(return_value=7), + ), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=42) + + client.use_spool.assert_not_called() + + @pytest.mark.asyncio + async def test_remain_delta_skips_slot_handled_by_3mf(self): + """Mixed coverage: 3MF carried slot 1 (=global tray 0). Remain + delta on the same physical slot must not double-charge it.""" + from backend.app.services.spoolman_tracking import report_usage + + tracking = SimpleNamespace( + filament_usage=[{"slot_id": 1, "used_g": 50.0}], + ams_trays={"0": {"tray_uuid": "AAAA", "tray_type": "PLA"}}, + slot_to_tray=None, + tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}}, + ) + + db = AsyncMock() + select_result = MagicMock() + select_result.scalar_one_or_none.return_value = tracking + db.execute = AsyncMock(return_value=select_result) + db.delete = AsyncMock() + db.commit = AsyncMock() + + client = AsyncMock() + client.use_spool = AsyncMock() + client.get_spool = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]} + ) + + # Make the 3MF path resolve to a spool too, so it actually writes. + async def fake_report_slots(_client, items, *args, **kwargs): + for _slot_id, grams in items: + if grams > 0: + await _client.use_spool(99, grams) + return 1 + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")), + patch( + "backend.app.services.spoolman_tracking._report_spool_usage_for_slots", + AsyncMock(side_effect=fake_report_slots), + ), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=42) + + # Only the 3MF path called use_spool. get_spool (remain-delta path) + # was never reached because slot 0 was already in the handled set. + client.use_spool.assert_awaited_once_with(99, 50.0) + client.get_spool.assert_not_called() + + +class TestPartialUsageRemainDelta: + """cleanup_tracking → _report_partial_usage must also write the + remain-delta for ABORTED no-3MF prints — same shape as the completion + path, otherwise aborts of "Untitled" prints stay silent.""" + + @pytest.mark.asyncio + async def test_aborted_no_3mf_writes_remain_delta(self): + """Aborted at remain=70% from start of 90% on a 1000g spool. + Delta = 20% × 1000g = 200g — must be written even though no + 3MF / layer data is available.""" + from backend.app.services.spoolman_tracking import _report_partial_usage + + tracking = SimpleNamespace( + archive_id=99, + filament_usage=None, + layer_usage=None, + filament_properties=None, + ams_trays={"0": {"tray_uuid": "AAAA"}}, + slot_to_tray=None, + tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}}, + ) + + client = AsyncMock() + client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0}}) + client.use_spool = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]}, + layer_num=42, + total_layers=100, + ) + + with ( + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch( + "backend.app.services.spoolman_tracking._get_printer_serial", + AsyncMock(return_value="serial"), + ), + patch( + "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", + AsyncMock(return_value=7), + ), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await _report_partial_usage(printer_id=1, tracking=tracking) + + client.use_spool.assert_awaited_once_with(7, 200.0) + + +class _AsyncCtx: + """Tiny async-context shim returning a pre-built db mock; mirrors + async_session()'s ``async with`` interface.""" + + def __init__(self, db): + self._db = db + + async def __aenter__(self): + return self._db + + async def __aexit__(self, *_): + return False