diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fceb9df0..93da1a09f 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). @@ -70,6 +71,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed +- **False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba)** — Reporter's P1S with a heavily-queued workload sporadically fired a "Print Stopped" push notification while the print was still actively running (screenshots showed 48% progress + a "50% Complete" notification 10 minutes after the spurious stop). Caught in a support bundle: `[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31 — subtask_id changed ('1844213296' → '2103771517')` at 10:26:08, immediately after the MQTT reconnect signature (`Requesting firmware version info` + `Sending K-profile priming request`) and immediately followed by `gcode_state: RUNNING` on the same wire — proof the print never actually stopped. **Root cause.** `bambu_mqtt.py:3647` mints a fresh `submission_id` (`int(time.time() * 1000) % 2_147_483_647 or 1`) per dispatch, so the queue-reprinting flow sends a NEW `subtask_id` to the printer each time. `main.py:2564` and `main.py:2773` set `archive.subtask_id` only when the stored value was empty (`not archive.subtask_id`) — so on reprint the row kept the FIRST run's id. When MQTT reconnects mid-print (network blip, printer reboot, Bambuddy restart), `reconcile_stale_active_prints` from #1542 runs against every `status="printing"` archive; `_is_active_archive_stale` at `main.py:3722` compared the stale stored id against the printer's live id, returned `(True, "subtask_id changed (…)")`, and the synthesizer fired a `status="aborted"` payload through `on_print_complete` — which is the exact code path the "Print Stopped" notification listens to. Collateral: the in-progress queue item was marked `cancelled` because the synthesized complete killed Bambuddy's tracking state. **Fix.** Two places in `main.py` — the expected-print promotion branch (line 2564) and the duplicate-printing-archive branch (line 2773) — now update `archive.subtask_id` whenever the new effective id **differs** from the stored one, not only when the stored one is empty. The comparison-based gate preserves the noop-on-stable-push behaviour the original `not archive.subtask_id` guard provided (the same id from a repeat push doesn't trigger a rewrite) while picking up reprint dispatches that mint a fresh id. The path is already inside expected-print promotion — meaning Bambuddy itself dispatched this print in-process — so the live id genuinely belongs to this archive; the write is safe. **Why the second notification ("Nozzle/Extruder Error") in the screenshot is unrelated.** That's a separate HMS event at 10:26:25, likely a stored error code the printer flushed on reconnect. The queue scheduler correctly observed `state=RUNNING, awaiting_plate_clear=True` at 10:26:08 and did NOT try to dispatch the next queue item, so the HMS "Device is busy" reason is NOT a side-effect of the bogus reconcile — both notifications fired in the same minute because they share a common trigger (the MQTT reconnect). **Tests.** 3 new cases in `test_reprint_updates_subtask_id.py` exercising the actual `on_print_start` expected-archive path: stale stored id gets rewritten on reprint (the #1807 scenario, asserts `archive.subtask_id == "2103771517"`); first-run still sets the id on an archive with `subtask_id=None`; stable repeat-push with the same id leaves the field untouched. Mocks the full session / printer / notification / WS / smart-plug / MQTT-relay surface that `on_print_start` touches so the test stays unit-fast. Full backend `pytest -n 8`: 552/552 in 17 s on the print-lifecycle suite. Ruff clean. **Scope.** Two-line guard rewrite; no DB migration, no schema change, no notification surface change, no frontend change. The reconciler itself (the consumer of `archive.subtask_id`) stays unchanged — it was doing the right thing given the data it had. + - **SpoolBuddy "Assign to AMS" pushed Generic instead of the user's custom slicer preset (#1815, reported by @Bgabor997)** — On P1S, assigning a spool with a Bambu Cloud user preset (PFUS-prefix `slicer_filament`) via SpoolBuddy left Bambu Studio's Device tab showing "Generic " instead of the actual custom preset (e.g. "Jayo PETG HF (Custom)"). Manual "Configure" from Bambuddy's AMS card with the same preset worked correctly — reporter triple-checked the preset existed in their slicer and that Bambuddy's inventory showed the right name. **Root cause** in `slicer_filament_resolver.py:197-203`: the defensive filter that catches PFUS / PFCN cloud-preset IDs leaking into `tray_info_idx` (the printer's calibration table can't key on cloud-preset hashes) cleared `setting_id` alongside `tray_info_idx`. `setting_id` is what the slicer uses to find the actual preset, and PFUS / PFCN are VALID values there — they're only invalid as `tray_info_idx`. When the Bambu Cloud detail lookup didn't return a `filament_id` (cloud-unauth on the `on_ams_change` replay path, transient cloud failure, or older custom presets whose detail JSON omits `filament_id`), the resolver fell through to `normalize_slicer_filament` which round-tripped the PFUS as `tray_info_idx`, the defensive filter fired, BOTH fields were cleared, and the caller's generic-material fallback at `inventory.py:157-165` filled `tray_info_idx=GFG99` AND `setting_id=GFSG99` — Bambu Studio resolved the slot to Generic PETG. The manual Configure modal works because the frontend at `ConfigureAmsSlotModal.tsx:503-512` calls the cloud detail API itself, sets `trayInfoIdx` to the resolved `filament_id`, and **preserves** the `PFUS` as `setting_id` in the request — so the backend's MQTT push lands both correct fields. **Fix.** The defensive filter still clears `tray_info_idx` for PFUS / PFCN / material-name leaks, but now preserves `setting_id` when it's a valid slicer reference (`PFUS` / `PFCN` cloud user/shared preset, or `GFS` Bambu official preset). Material-name leaks (e.g. `setting_id="PETG"`) are still cleared — those are never valid slicer references. Post-fix MQTT push carries `tray_info_idx=GFG99` (generic — firmware-acceptable for HMS / drying / colour matching) AND `setting_id=PFUS` (slicer uses this to load the user's actual custom preset). **What stays the same.** Bambuddy's own AMS card still reads `tray_info_idx` from `raw_data` and displays the generic material on cloud-unauth paths — same fundamental limitation as today, because without cloud resolution the backend has no way to know the real `P*` filament_id. This is the secondary symptom the reporter mentioned ("Bambuddy configure modal also shows Generic as default"); fixing it requires a deeper layered fallback (look up `LocalPreset` by name, or query the printer's live `kprofiles`, or cache the resolved cloud detail) and is out of scope for this drop. The slicer-side fix is the user's explicit ask. **Tests.** 5 new cases in `test_slicer_filament_resolver.py`: PFUS cloud-unavailable preserves setting_id (reporter's scenario); PFCN cloud-unavailable preserves setting_id (#1648 partner-preset shape); PFUS cloud-resolved still works as before (regression guard); GFS cloud-unavailable resolves via `normalize_slicer_filament` (regression guard for the Bambu-official cloud-down path); literal material name ("PETG") still clears both (regression guard that PFUS preservation doesn't accidentally preserve material-name leaks). Full backend `pytest -n 30`: 6410/6410 in 73 s. Ruff clean. **Scope.** No DB / API surface / i18n / frontend changes. No change to the caller (`apply_spool_to_slot_via_mqtt`) — its `if tray_info_idx and not setting_id` guard at `inventory.py:172` already preserves whatever setting_id the resolver returns. No change to the manual Configure modal path (already carried both fields end-to-end). The `on_ams_change` replay path in `main.py` (which passes `current_user=None` and was the original motivator for the defensive filter) now also preserves setting_id — same desired outcome since the replay only fires when SpoolBuddy pre-assigned an empty slot and the spool was later inserted, and the slicer needs the setting_id to resolve the right preset. - **H2S active-tray highlight stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** — On H2S (single-nozzle, `n3f` AMS), prints feeding from the external spool showed AMS SLOT 1 highlighted in the UI for the entire job. Display-only — Spoolman usage credit was already correct via the #1276 `ams_mapping=[-1]→254` path — but the active-tray ring on the printer card pointed at the wrong spool. **Root cause** in `bambu_mqtt.py::_handle_ams_data`: X1C / P1S / A1 firmware correctly reports `tray_now=254` when the external spool is the active feed, so the single-nozzle branch's `0–3` passthrough never sees it. H2S firmware instead reports `tray_now=0` (the AMS's idle slot) throughout external-only prints — reporter's MQTT debug log captured 2883 pushes with `tray_now=0`, 143 with `255` (unloaded), zero with `254`. The single-nozzle branch then trusted the wire value and `state.tray_now` landed on slot 0. **Fix.** The single-nozzle branch now checks `_captured_ams_mapping` (the slicer-captured per-filament mapping that the request-topic intercept already tracks) before the existing P2S multi-AMS resolver runs. When every entry is `-1` (the print uses ONLY the external spool — `-1` is the canonical external sentinel), `state.tray_now` is promoted to `254` regardless of what the AMS dict says. The override is intentionally narrow: it only fires when the captured mapping is non-empty AND every entry equals -1. Mixed prints (`[5, -1]`) and AMS-only prints (`[5]`) are NOT overridden — reporter only confirmed the bug for the all-external case, and we have no evidence H2S misreports mid-print swaps; trusting the firmware on those paths preserves correctness for users with multi-filament setups. Prints started without a captured mapping (printer-screen start, or before Bambuddy connected to MQTT) fall through unchanged — the wrong value persists in that edge case, but no other regression. **No model gating.** Future single-nozzle models with the same firmware quirk inherit the fix for free, and printers that already report `254` correctly enter the override branch but the assignment is a no-op (assigning 254 when the wire said `tray_now=254` requires `parsed_tray_now <= 3` to be false in the first place — the branch never even reaches them). Dual-nozzle (H2D / H2C / X2D), the P2S multi-AMS local-slot resolver (#420), `tray_now > 3` (already a global ID), and `tray_now=255` (unloaded) are all unchanged. **Tests.** 7 new cases in `TestTrayNowH2SExternalSpoolOverride` pinning every limb of the contract: all-external `[-1]` promotes; multi-external `[-1, -1, -1]` also promotes; AMS-only `[5]` does NOT override; mixed `[5, -1]` does NOT override; `_captured_ams_mapping=None` does NOT override; empty list `[]` does NOT override (defensive — `all([])` returns True, so we explicitly guard); unload after override correctly transitions `254 → 255`. Adjacent single-nozzle X1E and P2S test classes stay green — they don't set `_captured_ams_mapping` so the new branch falls through to the unchanged path. Full backend `pytest -n 30`: 6405/6405 in 71 s. Ruff clean. **No frontend, schema, or API surface change** — the wire format and the `PrinterStatus.tray_now` field shape are unchanged; only the value computed for that field on H2S external-only prints is now correct. 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/main.py b/backend/app/main.py index 4aa371bb9..8d32f83bf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2561,7 +2561,16 @@ async def on_print_start(printer_id: int, data: dict): _dispatched = getattr(_client, "last_dispatch_subtask_id", None) if _client else None if _dispatched: effective_subtask_id = str(_dispatched).strip() or None - if effective_subtask_id and not archive.subtask_id: + # Update on first-set OR on reprint (the queue dispatcher mints + # a fresh subtask_id per dispatch in bambu_mqtt:3647). Skipping + # the rewrite for reprints leaves the archive holding the FIRST + # run's id; if MQTT then reconnects mid-print, the reconciler + # (#1542) compares the stale stored id against the printer's + # live id, sees a mismatch, and synthesises a bogus PRINT + # COMPLETE — exactly the false-positive "Print Stopped" reported + # in #1807. Inequality check preserves the noop-on-stable-push + # behaviour the earlier `not archive.subtask_id` guard provided. + if effective_subtask_id and archive.subtask_id != effective_subtask_id: archive.subtask_id = effective_subtask_id # #1403 follow-up: VP-queue archives are created with # printer_id=None at queue-add time (we don't know which @@ -2769,8 +2778,11 @@ async def on_print_start(printer_id: int, data: dict): ) # Track this as the active print _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id - # Attach subtask_id retroactively so future restarts can resume - if subtask_id and not existing_archive.subtask_id: + # Attach subtask_id retroactively so future restarts can resume. + # Compare for inequality (not "is empty") to also pick up reprint + # dispatches that mint a fresh id — see #1807 for the bogus + # "Print Stopped" the strict-empty guard caused on reconnect. + if subtask_id and existing_archive.subtask_id != subtask_id: existing_archive.subtask_id = subtask_id await db.commit() # Also set up energy tracking if not already tracked (#941: persisted column) 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_reprint_updates_subtask_id.py b/backend/tests/unit/test_reprint_updates_subtask_id.py new file mode 100644 index 000000000..625d3212f --- /dev/null +++ b/backend/tests/unit/test_reprint_updates_subtask_id.py @@ -0,0 +1,223 @@ +"""Regression for #1807: false-positive "Print Stopped" notification on the +expected-archive reprint path. + +Bambuddy mints a fresh subtask_id per dispatch (``bambu_mqtt.py:3647``). On a +reprint, the archive row is reused — so the stored ``archive.subtask_id`` is +still the value from the FIRST run. The earlier ``not archive.subtask_id`` +guard at ``on_print_start`` skipped the rewrite, so the row kept the stale id. + +Then, if MQTT reconnects mid-print (which it routinely does — network blips, +printer reboots, Bambuddy restarts), ``reconcile_stale_active_prints`` (#1542) +compares the printer's live subtask_id against the stored one, sees a +mismatch, and synthesises a "missed PRINT COMPLETE" → bogus Print Stopped +notification while the print keeps running. + +The fix: update ``archive.subtask_id`` whenever the new effective id differs +from the stored one, not only when the stored one is empty. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.core.config import settings as app_settings +from backend.app.main import ( + _active_prints, + _expected_print_creators, + _expected_print_registered_at, + _expected_prints, + _print_ams_mappings, + _timelapse_baselines, + register_expected_print, +) + + +@pytest.fixture(autouse=True) +def _clear_dicts(): + _expected_prints.clear() + _expected_print_registered_at.clear() + _expected_print_creators.clear() + _print_ams_mappings.clear() + _active_prints.clear() + _timelapse_baselines.clear() + yield + _expected_prints.clear() + _expected_print_registered_at.clear() + _expected_print_creators.clear() + _print_ams_mappings.clear() + _active_prints.clear() + _timelapse_baselines.clear() + + +def _patches(): + return ( + patch("backend.app.main.async_session"), + patch("backend.app.main.notification_service"), + patch("backend.app.main.smart_plug_manager"), + patch("backend.app.main.ws_manager"), + patch("backend.app.main.printer_manager"), + patch("backend.app.main.mqtt_relay"), + patch("backend.app.main._record_energy_start", new_callable=AsyncMock), + patch("backend.app.main._load_objects_from_archive"), + patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock), + patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock), + patch( + "backend.app.main._list_timelapse_videos", + new=AsyncMock(return_value=([], "/timelapse")), + ), + ) + + +def _build_mocks(mock_printer, mock_archive): + def execute_router(stmt, *args, **kwargs): + sql = str(stmt).lower() + if "from printers" in sql or "from printer " in sql: + return MagicMock( + scalar_one_or_none=MagicMock(return_value=mock_printer), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))), + ) + if "from print_archives" in sql or "from print_archive" in sql: + return MagicMock( + scalar_one_or_none=MagicMock(return_value=mock_archive), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))), + ) + return MagicMock( + scalar_one_or_none=MagicMock(return_value=None), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))), + ) + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock() + mock_session.execute = AsyncMock(side_effect=execute_router) + mock_session.commit = AsyncMock() + return mock_session + + +def _make_archive(*, archive_id: int, stored_subtask_id: str | None): + mock_archive = MagicMock() + mock_archive.id = archive_id + mock_archive.filename = "Ikea-drybox_silicabox.3mf" + mock_archive.subtask_id = stored_subtask_id + mock_archive.print_time_seconds = None + mock_archive.created_by_id = None + mock_archive.printer_id = 1 + mock_archive.print_name = "Ikea-drybox_silicabox" + mock_archive.status = "archived" + mock_archive.file_path = f"archives/{archive_id}/Ikea-drybox_silicabox.3mf" + mock_archive.energy_start_kwh = None + mock_archive.timelapse_path = None + return mock_archive + + +def _make_printer(): + mock_printer = MagicMock() + mock_printer.id = 1 + mock_printer.auto_archive = True + mock_printer.external_camera_enabled = False + mock_printer.external_camera_url = None + mock_printer.name = "TestP1S" + return mock_printer + + +async def _drive(tmp_path, mock_archive, mqtt_subtask_id: str | None): + """Drive ``on_print_start`` with a print-start payload carrying the + given ``subtask_id`` (the printer-echoed id at PRINT START — set by the + queue dispatcher's fresh ``submission_id``).""" + mock_printer = _make_printer() + register_expected_print(1, mock_archive.filename, archive_id=mock_archive.id, ams_mapping=None) + mock_session = _build_mocks(mock_printer, mock_archive) + + ( + async_session_p, + notif_p, + plug_p, + ws_p, + pm_p, + relay_p, + _energy, + _load_obj, + _store_spoolman, + _send_start, + _list_tl, + ) = _patches() + + with ( + async_session_p as mock_session_maker, + notif_p as mock_notif, + plug_p as mock_plug, + ws_p as mock_ws, + pm_p as mock_pm, + relay_p as mock_relay, + _energy, + _load_obj, + _store_spoolman, + _send_start, + _list_tl, + patch.object(app_settings, "base_dir", tmp_path), + ): + mock_session_maker.return_value = mock_session + mock_notif.on_print_start = AsyncMock() + mock_plug.on_print_start = AsyncMock() + mock_ws.send_print_start = AsyncMock() + mock_ws.send_archive_updated = AsyncMock() + mock_relay.on_print_start = AsyncMock() + mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123")) + # last_dispatch_subtask_id fallback shouldn't fire — MQTT carried one. + mock_pm.get_client = MagicMock(return_value=MagicMock(last_dispatch_subtask_id=None)) + + from backend.app.main import on_print_start + + await on_print_start( + 1, + { + "filename": mock_archive.filename, + "subtask_name": mock_archive.print_name, + "raw_data": {"subtask_id": mqtt_subtask_id} if mqtt_subtask_id is not None else {}, + }, + ) + + +@pytest.mark.asyncio +async def test_reprint_updates_stale_subtask_id(tmp_path): + """The #1807 case: archive stored an OLD subtask_id from the first run. + On reprint dispatch the printer echoes a fresh one — the archive's + stored id must be rewritten so the reconciler doesn't flag the live + print as stale on next MQTT reconnect.""" + archive = _make_archive(archive_id=31, stored_subtask_id="1844213296") + + await _drive(tmp_path, archive, mqtt_subtask_id="2103771517") + + assert archive.subtask_id == "2103771517", ( + "expected-archive reprint promotion must update archive.subtask_id to the " + "new dispatch id; leaving the old value lets reconcile_stale_active_prints " + "synthesise a bogus PRINT COMPLETE on the next MQTT reconnect (#1807)" + ) + + +@pytest.mark.asyncio +async def test_first_run_still_sets_subtask_id(tmp_path): + """Regression guard for the previously-correct first-run path: an + archive with no stored subtask_id must still have it written on the + first MQTT-echoed PRINT START.""" + archive = _make_archive(archive_id=99, stored_subtask_id=None) + + await _drive(tmp_path, archive, mqtt_subtask_id="2103771517") + + assert archive.subtask_id == "2103771517" + + +@pytest.mark.asyncio +async def test_stable_push_does_not_rewrite(tmp_path): + """The original `not archive.subtask_id` guard's intent was to avoid + rewriting on every push that carries the same id. The inequality check + preserves that no-op behaviour: same id in, no rewrite.""" + archive = _make_archive(archive_id=15, stored_subtask_id="2103771517") + # Replace the bare attribute with a MagicMock so we can detect any write, + # not just observe the post-call value (which would match even on a + # spurious "store the same value back" rewrite). + initial = archive.subtask_id + + await _drive(tmp_path, archive, mqtt_subtask_id="2103771517") + + assert archive.subtask_id == initial 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