Kaynağa Gözat

Merge pull request #1699 from maziggy/0.2.4.6

**Bambuddy 0.2.4.6**

**⚠ Upgrade Notes — Read Before Updating**

0.2.4.6 is a fix-led patch release on the same 0.2.4 code base — no schema breaks beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), no Docker entrypoint changes. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.1 or later.

Five behavior-change callouts to know about before you upgrade:

- /api/v1/inventory/spools/{id}/reset-usage renamed to /reset-consumed-counter (breaking for external API consumers). The old name implied the endpoint zeroed weight_used; in practice it only stamps the baseline so the consumed-counter widget reads 0 going forward while remaining weight is preserved. Same for the bulk variant and the Spoolman-mode mirror. No compat shim — the old name actively misled callers. If you scripted against the old path, the migration is a one-line URL swap. Frontend, Bambuddy UI, and SpoolBuddy kiosk all migrated in-tree.

- Slicer-API sidecar now ships as pre-built images on GHCR + Docker Hub (#1657). The old slicer-api/docker-compose.yml used build: { context: https://github.com/maziggy/orca-slicer-api.git#... } which required git inside the Docker BuildKit worker — Container Station, Synology DSM, and QNAP don't ship it there, so the build failed outright. New compose uses image: ghcr.io/maziggy/orca-slicer-api:${SIDECAR_TAG:-latest}. After pulling this release:

  cd slicer-api
  docker compose pull
  docker compose up -d

- No more --no-cache --pull build dance. SIDECAR_TAG env var defaults to latest; pin to bambuddy-X.Y.Z to lock to the sidecar image that shipped with a specific Bambuddy release. Both images are linux/amd64 only; ARM64 hosts (Pi 4/5, Apple Silicon Linux) should run the sidecar on a separate x86_64 box and point Bambuddy at it via the Sidecar URL field.

- VP MQTT bridge net.info[].ip rewrite: FQDN-configured printers now work without manual reconfiguration (#1429). If you added a printer to Bambuddy by hostname (p1s.fritz.box, a router-provided DNS name, a Tailscale MagicDNS name, etc.) on 0.2.4+, the bridge couldn't rewrite the slicer-facing IP and Send quietly went to the real printer instead of the Bambuddy archive whenever the printer was powered on. After upgrade the bridge resolves the configured hostname to IPv4 at arm time and the rewrite path engages automatically — no UI action required. The unresolvable-input case (typo, dead DNS) is now logged with a specific reason instead of silently no-opping. If you were on the IPv4 workaround you can switch back to hostnames after upgrade.

- VP access codes auto-derived from the target printer in non-proxy modes. A startup migration syncs any pre-existing diverged Queue / Archive / Review VPs to match their target printer's code on first boot, with one INFO log per VP for audit. If the codes had previously diverged and your slicer was failing to connect because of it, that bridge starts working automatically after this upgrade. No user action; you may need to re-add the device in your slicer if the code changed.

- Docker bridge-mode default port mapping narrowed from 50000-51000 (1001 ports) to 50000-50029 (3 VPs worth) by default (#1646). The wide range from 0.2.4.5 spawned ~2000 docker-proxy host processes consuming ~3.5 GB host RAM on the reporter's box. New compose file maps just the slices for the first 3 VPs by default; bridge-mode users with more than 3 VPs widen the mapping by editing the published comment (50000-500N9 where N = vp_count - 1). Host-mode Linux and proxy-mode VPs are unchanged. Per-VP slicing is non-overlapping by design; collisions beyond 100 VPs fall back to the per-session retry the pre-#1646 code already had.

Make a backup before upgrading via Settings → Backup → Create Backup. Native install with update.sh snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

**Docker**

  docker compose pull
  docker compose up -d

docker-compose.yml doesn't need refreshing unless you map the VP passive FTP port range (see Upgrade Notes above) or you run the slicer-API sidecar (see Upgrade Notes above).

**Native install — recommended path**

  sudo BRANCH=main /opt/bambuddy/install/update.sh

Snapshots the database first and rolls back on failure.

**Native install — manual path**

  sudo systemctl stop bambuddy
  cd /opt/bambuddy
  sudo -u bambuddy git fetch --prune --tags --force origin
  sudo -u bambuddy git checkout main
  sudo -u bambuddy git reset --hard origin/main
  sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
  sudo systemctl start bambuddy

requirements.txt picks up one new optional dependency this release — curl_cffi, used by the firmware-update check to clear Bambu Lab's Cloudflare JA3 challenge (#1666). If the wheel fails to install on your platform Bambuddy falls back to httpx automatically; only the in-app firmware download URL stops resolving and the wiki-based version-detection badge keeps working.

Native install — INSTALL_PATH under /home

If your install lives under /home/... rather than the default /opt/bambuddy/, the systemd unit's ProtectHome=true directive previously prevented ExecStart from resolving the venv binary (#1685). The new install.sh detects INSTALL_PATH == /home/* and emits ProtectHome=read-only automatically. Existing /opt/... installs keep the stricter ProtectHome=true.

**Windows install**

0.2.4.6 ships the same Windows installer as 0.2.4.5. Existing installs upgrade in place via the Service / Update entries on the installer.

---

**Highlights**

0.2.4.6 is a reporter-credited fix release on top of the 0.2.4 line. Two big-ticket adds: Orca Cloud profile sync end-to-end — read/list/slice with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration, with four sign-in providers (Google, Apple, GitHub, email+password), a 4-tier preset picker (Orca Cloud → Bambu Cloud → local → standard), and full AMS + SpoolBuddy surface integration — and native CSV import / export for the inventory (#1576, PR #1659 by   @samedyuksel), backed by a 18-column round-trippable schema with formula-injection hardening and a per-row dry-run preview.

The VP surface got another sweep. The hostname/FQDN side of #1429's net.info[].ip rewrite is finally closed (root-caused by @Mape6 — every IPv4 codepath now resolves the configured hostname before parsing). #1548 round 2 fixes the underlying reason OrcaSlicer dropped idle MQTT connections to the VP — the slicer never sends PINGREQ at all, so spec-compliant keep_alive * 1.5 disconnects fired against a slicer that holds idle sessions indefinitely against real Bambu firmware; the fix drops the application-level read timeout in favour of TCP SO_KEEPALIVE for dead-connection detection. #1646's FTP passive-port pool is now per-VP-sliced, dropping bridge-mode host RAM by ~16× on the reporter's single-VP setup. #1658 closes the BambuStudio 2.7.x "Downloading" hang — the slicer flipped the Send sequence to FTP-before-MQTT in 2.7.x, so the set_gcode_state(FINISH) synthesis fired before the real _send_print_response overwrote it back to PREPARE; the fix re-fires FINISH 1.5 s after the synthetic ack.

K-profile + AMS-slot work: #1688 / #1689 (both reported and diagnosed by @Spionkiller01 with H2C testing) fix two related symptoms with one shared root cause — spool preset ids (GFSG98_09) and K-profile filament_ids (GFG98) look different but normalise to the same value, and previously the matchers used name parsing only. The follow-up to #1689 (Spionkiller01's diff, applied verbatim) closes the residual unconfigured-but-loaded slot case where the original cali_idx safety net was unreachable. #1694 surfaces "?" instead of "Empty" on loaded-but-unconfigured AMS slots (matches the slicer's own convention). #1680 fixes the Assign-Spool toast that claimed success on the deferred-configure-on-insert path.

Reliability and dispatch: #1679 fixes the false-cancel-and-duplicate-archive bug when Bambuddy restarts mid-print (the connected-edge reconciliation fired against state.state="unknown" before the printer's first real push_status landed). #1678 fixes the print-queue wedge when a printer accepts project_file but never starts (the watchdog returned success on subtask_id advance alone; new Phase B waits up to 180 s for the active-state transition before reverting). #1697 fixes multi-plate filament over-counting — a 190 g lid from a multi-plate 3MF was getting debited the whole file's 273 g. #1397 closes the long-standing "bed already dropped" finish-photo regression by extracting the last frame of a brief timelapse Bambuddy now force-records on every dispatched print (post-park, pre-bed-drop window) — verified on N=2 H2C prints, bed-slinger field verification welcome.

Cloud / auth: #1666 routes the Bambu Lab firmware-download fetch through curl_cffi to clear the Cloudflare JA3 challenge that started 403-ing plain Python TLS — the HTTP-layer User-Agent stays Bambuddy/1.0 honest per the 2026-05-12 compliance commitment, only the TLS handshake bytes match Chrome (pinned by a test that fails the build if the UA override is ever dropped). #1698 fixes the silently-zombie tabs after JWT expiry — setAuthToken(null) from the API client wasn't reaching AuthContext.user, so ProtectedRoute kept rendering with no token and every subsequent request 401'd silently. Fix (mirrored from @TCL987's working fork patch) dispatches an auth:expired event the AuthContext listens to.

---
**New Features**

- Orca Cloud profile sync — end-to-end (slicer + AMS + SpoolBuddy). Bambuddy now reads, lists, and slices with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration. OrcaSlicer 2.4.0-alpha shipped its own Supabase-backed cloud (auth.orcaslicer.com / api.orcaslicer.com); this integrates using a standard PKCE handshake. Four sign-in providers: Google, Apple, GitHub (paste-flow PKCE, since Orca's Supabase project only allowlists localhost for redirect_to), and email+password (direct grant). UI: the Cloud Profiles tab is now two — "Bambu Cloud" (existing, unchanged) and "Orca Cloud" (new, same rich layout). Slicer integration surfaces Orca Cloud as the top tier in a 4-tier preset picker (Orca Cloud → Bambu Cloud → local → standard); AMS slot configuration accepts orca_cloud as a 4th preset   source; SpoolBuddy modal fetches Bambu + Orca filaments in parallel via Promise.allSettled. Persistent storage: 5 new columns on users (token / refresh / expiry / provider / email) plus 3 transient PKCE state columns with 10-min TTL, dialect-branched for SQLite and Postgres. Auth-disabled mode falls back to the global Settings table. Refresh tokens are single-use (Supabase behaviour) and rotated just-in-time with <5 min leeway; new pair persisted before the downstream call so a mid-flight crash doesn't strand the user. New explicit orca_cloud:auth permission flag folded into the existing can_access_cloud API-key scope. Filed OrcaSlicer/OrcaSlicer#14028 upstream to widen the redirect_to allowlist so future Bambuddy versions can ship clean OAuth instead of paste-flow. 32 backend unit + 6 preset-resolver + 6 sidecar-fetch + 6 frontend tests; ~35 new i18n keys translated across all 10 non-English locales.

- Native CSV import / export for the inventory (#1576, PR #1659 by @samedyuksel). Bulk-add spools without manually clicking through the form, and back up / migrate the local inventory in a single round-trip. Export downloads bambuddy-spools-YYYY-MM-DD.csv with one row per active spool; Import shows a per-row preview classifying each row as valid / error / skipped / duplicate-warn before anything hits the database, then a confirm click persists only the valid rows in one transaction. Fixed 18-column schema includes weight_used / last_used / storage_location / category / low_stock_threshold_pct for a lossless round-trip. Colour resolution: explicit rgba wins, otherwise brand + color_name resolves against the Color Catalog in a single in-memory pass. Hardening: 5 MB upload cap with bounded 64 KB chunked read (handles chunked uploads where file.size is None), spreadsheet formula-injection guard on export with the inverse strip on import for lossless round-trip, soft duplicate-warn flag when an active spool with the same material+brand+color exists. Local inventory only — in Spoolman mode the buttons render disabled with a tooltip pointing at Spoolman's own   CSV import/export. 25 backend integration tests + 3 frontend modal tests; full i18n in all 11 locales. Companion wiki PR maziggy/bambuddy-wiki#41.

- Archives page banner: reactive install-step-4 nudge for the slicer-side "Store sent files on external storage" setting. Companion to the new external_storage diagnostic check (below) — catches the slicer-side variant of the same setting that never reaches the printer on older BambuStudio / OrcaSlicer. New GET /archives/no-3mf-warning endpoint returns true iff any archive in the last 30 days has extra_data.no_3mf_available=True and isn't soft-deleted; banner is amber, dismissible, and one-shot via localStorage (persistent across browser restarts — "you've been told" should outlive a session). 5 backend integration tests; 4 i18n keys in all 11 locales.

- Connection diagnostic now verifies install step 4 ("Store sent files on external storage"). Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a .gcode.3mf on the printer's SD card, every archive falls back to no-thumbnail / no-metadata, and the cause is invisible. New external_storage check reads state.store_to_sdcard (the MQTT home_flag bit 11 Bambuddy already parses). Pass when the printer reports the bit on, fail when off, skip when no live state or the field has never been populated. Slot in the check list sits between port_ftps and mqtt_auth. The skip text explicitly calls out the older-slicer limitation so users on that path know to verify manually. Wiki updated on the System and Troubleshooting pages.

- Connection diagnostic now verifies the printer is publishing on its report topic (#1622). A printer with a wrong-cased serial — or one that isn't publishing for any reason — would previously pass mqtt_auth because the broker accepts the subscription regardless. User-visible symptom was "AMS / K-profiles / custom filaments missing on the slicer side". New printer_publishing check turns the existing log warning into a structured diagnostic result. Bounded 10 s polling at 0.5 s intervals; exits the moment a message arrives, with an elapsed-seconds counter on the modal so the spinner doesn't look hung. The support-package gathering path stays fast (instant pass/fail, no wait).

- "Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by @hasmar04). Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing preferred_slicer setting drove both, so picking one forced the other. New open_in_slicer setting ('bambu_studio' | 'orcaslicer' | null) drives only the desktop "Open in Slicer" URI handoff. Default is null — existing installs fall back to preferred_slicer and behave identically until a user changes it. Five call sites across ArchivesPage, MakerworldPage, ModelViewerModal migrated. MakerWorld's "Slice in {{slicer}}" button label additionally branches on useSlicerApi so the text always matches what the button does.

- Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by @CMW-ISS). Multi-printer farms with 40+ plate runs need to know which physical plate each queued job needs. New extract_bed_type_from_3mf(file_path, plate_id) helper alongside the existing filament-usage extractor; PrintQueueItemResponse gains bed_type; the /archives/{id}/plates (and library equivalent) include per-plate bed_type for the modal's plate selector. Per-plate accuracy matters because the archive-level capture stores only the first plate's value — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, but plate 17 may actually need Engineering. 8 unit cases on the extractor.

- Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905). Every row in Archives → Print Log now has a trash icon next to the filament cell, gated on archives:delete_own / archives:delete_all. Filament / time / cost contribution drops out of Quick Stats in the same response cycle (stats aggregate over PrintLogEntry). The matching archive (if any) stays untouched — the log row is a sibling, not a child.

- Print Log page: per-row failure-cause classification (#1687 part 4, reported by @IndividualGhost1905). Reporter clarified after part 1 shipped that what he actually wanted for failure-cause grouping on the log (spaghetti, jam, bed-adhesion, etc.) was different from archive tags (which describe the model). The storage and aggregation were already there; the gaps were the GET serialiser silently dropping failure_reason from PrintLogEntrySchema, and orphan log entries (no archive —
  dispatch errors, aborts, manual entries) having no edit path. New PATCH /print-log/{entry_id}, vocabulary validated against the same 11-key canonical set the Archive Edit modal uses. Pencil icon beside the trash icon; modal saves invalidate both print-log and archives-stats query keys.

- Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by @MartinNYHC, root-caused by @IndividualGhost1905). SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so the existing Discover pass couldn't find printers on a different subnet. New "Custom subnet..." sentinel in the AddPrinterModal picker reveals a CIDR text input. When custom is picked, discovery routes through POST /discovery/scan with the typed CIDR (which already does the unicast probe). Last custom CIDR persisted to localStorage. No backend changes — SubnetScanner.scan_subnet() already caps at /22 (1024 hosts) with batch-50 concurrency.

- VP MQTT bridge surfaces why net.info[].ip rewrite didn't arm (#1429 defensive). The bridge had 4 silent early-return paths in _refresh_ip_encoding. When the rewrite silently no-op'd, the only signal was the absence of the armed INFO line. Each path now emits one specific-reason INFO line, throttled by a dedup field so an idle unarmed bridge doesn't spam at 30 s tick. This is exactly the surface that pinpointed #1429's hostname/FQDN root cause on the reporter's bundle.

---
**Changes**

- Slicer-API sidecar ships as pre-built images on GHCR + Docker Hub (#1657, reported by @d3nn3s08). Removes the build-from-source git dependency that broke installs on QNAP, Synology DSM, and Container Station. See Upgrade Notes for the new docker compose pull workflow.

- VP access code is now auto-derived from the target printer in non-proxy modes (Discord report). With the live target-printer mirror that landed earlier in the 0.2.5 cycle forwarding slicer auth bytes through to the real printer, the slicer's stored code has to clear both checks (VP listener + real printer). If the codes diverged the bridge silently failed at the second hop. The fix removes the foot-gun: when a target printer is selected, the VP card's access-code field becomes read-only with an
  Eye-toggle reveal showing the target's code. One-shot startup migration syncs any pre-existing diverged VPs with one INFO log per VP. Wiki corrected (previously framed code-match as a camera-only concern — wrong, all bridged protocols inherit).

- File Manager sidebar: "All Files" now scopes to your own uploaded files; new "External" entry holds the combined linked-folder view (#1621, reported by @kcw96). Restores the pre-external semantics so long-time users get their muscle memory back. The combined "everything across every external mount" view moves to a sibling sidebar entry that only renders when at least one external folder is linked (zero-cost on installs without the feature). New internal_only / external_only query flags
  on /api/v1/library/files. Empty-state copy distinguishes "no internal files yet" from "no external files" so a user staring at an empty External view doesn't think their NAS is broken.

- Empty AMS units no longer trigger hourly humidity / temperature notifications (#1619). The hourly recorder fanned out alarms for every AMS unit above threshold without checking whether the unit was actually loaded. New _ams_has_filament() helper inspects the firmware-reported tray_exist_bits bitmap (with a fallback to the tray array's tray_type strings for early-pushall shapes). Sensor history still records regardless of the gate so the System page humidity / temperature charts stay continuous — only the outbound notification is suppressed.

- Inventory: /reset-usage renamed to /reset-consumed-counter; UI label is now "Reset counter". Old name implied the endpoint zeroed weight_used; in practice it only stamps the baseline. See Upgrade Notes for the breaking-change scope. Behaviour is unchanged in both internal and Spoolman modes — only the path and the UI label moved.

- docker-compose.yml: bridge-mode warning about the FTP passive range + docker-proxy RAM footprint (#1646, reported by @TheFou). Default port mapping narrowed from 50000-51000 (1001 ports / ~3.5 GB RSS in bridge mode) to 50000-50029 (3 VPs). Comment explains how to widen for more VPs. See Upgrade Notes.

- AMS drying now enabled for H2C starting at firmware 01.02.00.00. Moved from _DRYING_UNSUPPORTED_MODELS to _DRYING_MIN_FIRMWARE with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes the H2C advertises (O1C, O1C2) get the same gate.

---
**Fixed**

  **UI / rendering**

- AMS drying popover's "Start Drying" button no longer hidden behind iOS Safari's bottom URL bar on iPhone (#1669). Popover sizing switched from 100vh → 100dvh; popoverPosition now defaults viewportHeight from window.visualViewport?.height so the flip-above decision uses the actually-visible area.

- Project edit modal couldn't be scrolled on short screens, so Save / Cancel were unreachable (#1642, reported by @klevin92). Standard flex-modal-scroll fix: max-h-[calc(100vh-2rem)] + sticky footer with border-t separator.

- Label printing produced two identical PDFs per click (#1628). window.open(url, '_blank', 'noopener,noreferrer') returns null even on success — the fallback <a download> click fired on every click. Dropped noopener,noreferrer (same-origin blob URL, passive PDF preview, no script context).

- Service-worker activate handler no longer hangs first-install browsers (demo-site stuck spinner + Firefox Corrupted-Content). The forced client.navigate(client.url) added for kiosk-reload-on-deploy fired on every fresh origin, not just upgrades. Reload logic moved from sw.js to sw-register.js gated on hadController = !!navigator.serviceWorker.controller captured at script load — only fires when a previous SW was controlling the document.

  **Inventory / AMS**

- K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by @Spionkiller01 with H2C testing; #1689 also reported by @IndividualGhost1905). Spool preset ids (GFSG98_09) and K-profile filament_ids (GFG98) normalise to the same value via the new toFilamentId helper (drops _NN variant suffix, strips the S in GFS); generic GFx99 ids excluded from id-match so they don't over-match. Both surfaces — isMatchingCalibration (PA profile suggester) and matchingKProfiles (AMS slot modal) — switched to id-match-first with name fallback. SpoolBuddy kiosk surfaces inherit via shared components.

- Configure Slot now keeps the active K-profile on reopen for assigned-but-unconfigured slots (#1689 follow-up, reported and patched by @Spionkiller01). Residual case where the original cali_idx safety net was unreachable because selectedPresetInfo was null for slots with tray_type="" / no slot_preset_mappings row. Fix is Spionkiller01's diff verbatim with the existing extruder guard.

- AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by @kleinwareio). Compact label below the slot circle was rendering tray.tray_type || "Empty", falling back to Empty whenever the firmware hadn't been told which material is in the slot. New branch on getEmptySlotKind matches the slicer's own convention. SpoolBuddy kiosk's AmsUnitCard carried the same bug and got the same fix.

- "Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by @kleinwareio). Backend defers the ams_filament_setting MQTT publish for empty slots (Bambu firmware drops the push) and stores the assignment with pending_config=true; the toast was always "Spool assigned and AMS slot configured" regardless. Fix branches on pending_config and surfaces a new "Assigned. Slot will configure when you insert the spool." message.

- Home-page filament assign no longer leaves the slicer unaware of PFCN cloud presets (#1648, reported by @ferch-G). Polymaker's "(Custom)" Bambu Lab H2D variants use PFCN... preset IDs (alongside GFS... and PFUS...); the cloud-detail lookup branch in apply_spool_to_slot_via_mqtt only routed the first two prefixes. Fix extends the cloud-detail-lookup branch + the discard safety net to include PFCN.

- Bambu cloud A1 Mini filament / process profiles no longer hidden in AMS slot picker (#1649, root-caused by @technopaw). Bambu shifted the @BBL suffix from A1 Mini to A1M across 106 cloud profiles mid-2026; Bambuddy's filter compared the token verbatim against the display name. New PRINTER_MODEL_SUFFIX_ALIASES table holds the bidirectional A1 Mini ⇄ A1M mapping. Same helper covers SliceModal Process/Filament compatibility. Aliases narrow on purpose — wide-net aliasing like X1 ⇄ X1C would silently group truly distinct printers.

  **Archive / stats / metadata**

- Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by @volodymyr-doba). Reporter printed a single 190 g lid from a 5-box+5-lid file and got debited for the whole file's 273 g. Two completion-time recorders (internal _track_from_3mf and store_print_data for Spoolman mode) called the extractor with no plate_id and summed every plate. Fix threads plate_id end-to-end via a new PrintSession.plate_id field captured from on_print_start + a parallel _print_plate_ids dict for the direct-Print path (which bypasses the queue). 9 new tests across test_usage_tracker.py, test_spoolman_tracking.py, and test_print_start_expected_promotion.py.

- VP archive/queue names with & no longer render as &amp;amp; (#1658 follow-up, reported by @IndividualGhost1905). ThreeMFParser._parse_3dmodel was parsing <metadata> payloads without html.unescape(); raw &amp; landed in the DB and React re-escaped on render. Fix applies the same loop-until-stable unescape pattern the sibling ProjectPageParser already had. Same drop: tooltip rewritten in all 11 locales to spell out the BambuStudio 2.7.x reality that BS unconditionally overwrites the user-typed Send-dialog name with the slugified 3MF Title field — there's no MQTT field carrying the original.

- Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK). Bambu's end-gcode lowers the build plate at print completion; capturing at gcode_state=FINISH showed the print well below the camera's natural framing. Fix sources the photo from a brief Bambu timelapse Bambuddy now force-records on every dispatched print — firmware stops recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly. BackgroundDispatchService overrides timelapse=True on the MQTT command + marks PrintArchive.bambuddy_forced_timelapse; new extract_video_last_frame ffmpeg helper extracts the last frame; _cleanup_forced_timelapse deletes both the locally-attached file and the on-printer copy when the user didn't ask for one. Verified on N=2 H2C prints; bed-slinger field verification welcome. Round-2 fix in the same drop: extractor switched from -sseof -1.0 to -update 1 (frame-per-layer recording produces sub-second videos on small prints, where the 1-second seek-from-end went before the start of the file); resolver wired into print_scheduler.py so queue-dispatched prints get the override too (not just background_dispatch.py).

  **Virtual printer**

- VP MQTT no longer disconnects idle OrcaSlicer at keep_alive * 1.5 (#1548 round 2, reported by @hollajandro). Round 1 shipped the MQTT §4.4-compliant idle disconnect; the reporter's follow-up pcap proved the spec compliance was itself the regression — OrcaSlicer sends zero MQTT packets after the initial burst (no PINGREQ), so any spec-compliant server disconnects. Real Bambu firmware doesn't enforce §4.4 (verified against the reporter's identical Orca install holding idle sessions against real hardware on the same network). Fix drops the application-level read timeout after CONNECT/auth and sets SO_KEEPALIVE on the underlying socket so the OS TCP stack detects truly dead connections.

- VP MQTT bridge net.info[].ip rewrite never armed when the printer was added by hostname/FQDN (#1429, root-caused by @Mape6, also hit @TrickShotMLG02). _ip_to_uint32_le and the host-interface picker both assume dotted-quad IPv4 and bailed on the FQDN string. New _resolve_target_to_ipv4 resolves via socket.getaddrinfo(target, None, family=socket.AF_INET) (IPv4-only filter — the net.info[*].ip field is uint32 LE). Returns None on transient DNS hiccups so the encoding doesn't break permanently. The configured FQDN is preserved into the armed log line as configured→resolved so a bad-DNS regression stays legible.

- FTP passive-port pool now sliced per-VP (10 ports each) so bridge-mode Docker drops from ~3.5 GB to ~210 MB host RAM (#1646, reported by @TheFou). Reporter measured 2002 docker-proxy host processes from the 1001-port range. Class-level PASSIVE_PORT_MIN/MAX constants are gone; new module-level compute_passive_port_slice(vp_id) returns a 10-port window allocated by VP id. Result for the reporter (single VP): ~70 MB instead of ~3.5 GB. Wrap-around behaviour at 100 VPs falls back to the per-session retry the pre-#1646 code already had. Docs corrected in the same drop (userland-proxy: false framing tightened).

- VP Queue / Archive / Review: Bambu Studio 2.7.x stayed stuck at "Downloading" after Send (#1658, reported by @IndividualGhost1905). BambuStudio 2.7.x flipped the Send sequence to verify_job → .3mf → project_file, so on_file_received's set_gcode_state("FINISH") fired first and the synthetic _send_print_response ack overwrote it back to PREPARE. The slicer waited for a FINISH transition it'd never see. Fix re-fires set_gcode_state(FINISH) from  on_print_command 1.5 s after the synthetic ack, for every non-proxy mode. Proxy mode is exempt — there the real printer drives bridge state.

- VP settings card now shows the target printer's serial in proxy mode. Runtime services (SSDP, MQTT bind identity, certificate subject) all use the target printer's actual serial; _vp_to_dict always returned the self-generated suffix-based serial, breaking the visual "one identity per VP" mental model. Fix queries the target's serial when vp.mode == proxy and target_printer_id; falls back to the self-generated serial when the target row is missing (printer deleted, race) so the card still renders.

  **Print queue / dispatch**

- Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by @IndividualGhost1905, corroborated by @Arn0uDz on watchtower-driven restarts). On startup, _on_connect broadcast on_state_change(self.state) immediately after the broker accepted the connection — BEFORE _request_push_all round-tripped real status. The reconcile saw state.state="unknown" + state.subtask_name="" and synthesised an aborted PRINT COMPLETE for every in-flight archive. When the real PRINT COMPLETE arrived, _active_prints didn't have the entry, so a brand-new archive row was created. Fix: two-layer guard — on_printer_status_change now gates reconcile spawn on state.state being a real value (parsed firmware enum); _is_active_archive_stale returns (False, "") when state.state is empty / "unknown" / None.

- Print queue no longer wedges in "Currently Printing" when a printer accepts project_file but never starts (#1678, reported by @kleinwareio). The _watchdog_print_start returned success as soon as subtask_id advanced — added for H2D's #1078 FINISH→PREPARE delay — but subtask_id advance is "command landed" not "actually printing". When the printer accepted the file but wedged (cloud+LAN re-auth dance after a power cycle, old firmware), the queue row stayed status='printing', in-memory _expected_prints kept the entry, and every subsequent queue item was blocked. Fix splits the watchdog into Phase A (existing 90 s) + Phase B (new 180 s, ~3.5× the worst observed H2D FINISH→PREPARE delay): if Phase A exited via subtask_id-alone, Phase B keeps watching for the active-state transition before reverting. Phase B explicitly does NOT force-reconnect (subtask_id advance proves the publish landed; force-reconnect mid-parse triggers 0500_4003 per #1150).

- Print Log "User" column now shows the user for prints started from the Queue (#1670, reported by @JmanB52D). Two-link gap on the Queue→manual-start path: POST /queue/{id}/start discarded the user dep, and PrintScheduler._start_print never called set_current_print_user. Fix writes item.created_by_id on the route (first-claim-wins so UI-added attribution isn't overwritten), and forwards the resolved username into printer_manager.set_current_print_user from the scheduler.

- Print queue require_previous_success no longer cascades indefinitely after a user-cancelled print (#1667, fully root-caused by @599w6c26tv-droid). Reporter saw a single user-cancelled print block 18 downstream queue items over 3 days, with reproducible logs + DB dumps proving two distinct bugs: the lookback query excluded cancelled (so user-cancellations were never found as the recent predecessor — the query walked past) AND included skipped (so one bug-cascaded skip became the next item's "failed predecessor"). Fix swaps the lookback list to ["completed", "failed", "cancelled", "aborted"] and broadens the success check to prev_item.status in ("completed", "cancelled"). Conservative one-shot recovery migration in run_migrations resets only the skipped items whose immediate real predecessor was cancelled — narrow enough not to disturb skipped items whose true predecessor was a real failure.

- Print modal now exposes a "Nozzle Offset Calibration" toggle for dual-nozzle printers (#1682, reported by @louiskleiman). The nozzle_offset_cali field was hardcoded to 2 (skip) in the MQTT project_file payload. New end-to-end plumbing with a hard MQTT-layer gate on dual-nozzle (is_dual_nozzle_model() + runtime _is_dual_nozzle flag) — even a stale queue item from when the printer was misidentified gets downgraded to 2 at dispatch. New default_nozzle_offset_cali setting (default True, matches BambuStudio behaviour); Settings row + QueuePage bulk-edit toggle only render when at least one registered printer is dual-nozzle.

  **Cloud / external**

- Firmware-update check no longer 403s against Bambu Lab's Cloudflare-gated download page (#1666, reported by @arekm). CF upped bot protection on bambulab.com to a JA3 / TLS-fingerprint challenge; plain Python TLS handshakes (httpx, requests, urllib) don't match Chrome's ClientHello bytes. Fix uses curl_cffi for the two bambulab.com fetches only. The HTTP-layer User-Agent stays Bambuddy/1.0 honest per the 2026-05-12 compliance commitment — only TLS handshake bytes match Chrome. Pinned by a test (test_bambulab_curl_cffi_session_keeps_honest_user_agent) that fails the build if the UA override is ever dropped. Soft dependency: if the wheel doesn't install on the host platform, the service logs a startup warning and falls back to httpx (wiki-based version detection continues to work; only the in-app firmware download URL stops resolving). wiki.bambulab.com and the CDN path stay on httpx — neither sits behind the same JA3 gate.

- MakerWorld URL imports into a writable external folder wrote bytes to internal storage, not the NAS (#1645, reported and root-caused by @needo37). save_3mf_bytes_to_library accepted folder_id but never loaded the folder or inspected is_external / external_path — hardcoded the destination to internal storage and left the row with is_external=False. Same class of bug as #1112 (fixed for multipart-upload + move paths, never applied to byte-import). Fix mirrors the multipart-upload path: load the target LibraryFolder, feed it through _resolve_upload_destination, persist via _stored_file_path(dest, is_external).

  **Auth / session**

- Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by @TCL987, fix mirrored from reporter's fork). When a 401 with a token-invalidating message landed in client.ts, the handler called setAuthToken(null) to drop the token, but AuthContext.user is a React state value that doesn't react to module-level setters. ProtectedRoute only redirects when user === null, so the protected tree kept rendering, every request went out with no Authorization header, and the UI silently looked empty. Fix dispatches a window.dispatchEvent(new CustomEvent('auth:expired')) after setAuthToken(null); AuthContext listens and calls setUser(null). Generic 401 Authentication required (no token-invalidating message) still doesn't fire the event — preserves the existing transient-401 tolerance.

  **System / install**

- System page now reports the container's uptime / boot time, not the host's (#1690, reported by @IndividualGhost1905). psutil.boot_time() reads /proc/stat:btime, which on shared-kernel containers is the host's boot time. Fix reads psutil.Process(1).create_time() — PID 1 in a container is the entrypoint, on bare metal / VMs it's effectively the host init. Defensive fallback to boot_time() if /proc/1/stat is unreadable.

- Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by @Geoff-S). bambuddy.service shipped with ProtectHome=true, which makes /home/* invisible to the service namespace; ReadWritePaths=$INSTALL_PATH doesn't reliably re-expose subpaths for executable resolution. Fix detects INSTALL_PATH == /home/* and emits ProtectHome=read-only for that case; the default /opt/bambuddy/ install keeps ProtectHome=true.

- X2D archives lose 3MF metadata because FTPS handshake fails on firmware 01.01.00.00 (#1638, reported by @vasmarfas). Same shape as the P2S 01.02.00.00 FTPS bug from #1401 — Python 3.13's TLS-1.3 default rejected by the X2D's implicit-FTPS server. Fix adds an X2D entry to the per-model ftp_profiles.py registry with cap_tls_v1_2=True and an N6 → X2D SSDP alias. Honest caveat: hypothesis-driven trial. The TLS-1.2 cap is the most likely cure given symptom family resemblance to #1401; if it doesn't clear the error, the registry slot stays useful as a tuning anchor for the next round of diagnostics.

  **Settings**

- Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by @Bgabor997). The filament_type select shipped with 11 base materials; expanded to 25 BambuStudio-aligned options grouped by family (PLA + CF/GF/AERO, PETG + CF, ABS + GF, ASA + CF/GF, PC, PCTG, PA family + CF/PAHT-CF/PA6-CF/PA6-GF, PET-CF, TPU, PPS family + CF/GF for X1E, PVA, HIPS).

- Scheduled local backup time is now interpreted as local time, not UTC (#1602 follow-up). Pre-fix the picker stored HH:MM and _calculate_next_run replaced into datetime.now(timezone.utc) — a UTC+3 user who wanted 21:00 local had to enter 18:00. Post-fix uses the container's local timezone resolved from TZ via zoneinfo.ZoneInfo. UTC fallback when TZ is unset or unrecognised. UI now renders the resolved zone name next to the time field. One-time behaviour change: users who   entered a UTC-offset workaround will see the first scheduled cycle after upgrade run at their local-TZ offset earlier than expected.

  **Internal / observability**

- Background asyncio tasks no longer get garbage-collected mid-flight (#1648 follow-up). Support-bundle review surfaced 94 Task was destroyed but it is pending! warnings in 8 days of v0.2.4.5 — asyncio holds only a weak reference to the result of create_task, so "fire and forget" call sites that don't store the returned task got GC'd before completion. The warning gives no traceback, so the originating exception (if any) vanished silently. New spawn_background_task(coro, *, name=None) helper stores a strong reference, attaches a done-callback that auto-removes on completion AND logs uncaught exceptions with the originating traceback. 16 truly-orphan create_task call sites migrated; other sites already keeping strong refs are unchanged.

---
**Credits**

  External contributors and reporters who drove this release: @samedyuksel (PR #1659 CSV import / export), @Spionkiller01 (root-caused + patched #1688 / #1689 / #1689 follow-up with H2C testing), @TCL987 (#1698 JWT zombie tabs — fix mirrored from their fork), @needo37 (#1645 MakerWorld external folder, plus the original #1593 multi-plate diagnosis), @volodymyr-doba (#1697 multi-plate filament tracking), @kleinwareio (#1680 / #1694 / #1678), @IndividualGhost1905 (#1679 / #1658 / #1690 / #1687 / #1670), @hasmar04 (#1329), @CMW-ISS (#1281), @hollajandro (#1548 round 2), @klevin92 (#1642), @kcw96 (#1621), @TheFou (#1646 + Docker docs corrections), @louiskleiman (#1682), @ferch-G (#1648), @technopaw (#1649), @arekm (#1666 with the working bypass demonstrated), @JmanB52D (#1670), @599w6c26tv-droid (#1667 fully root-caused), @Bgabor997 (#1686), @Geoff-S (#1685), @vasmarfas (#1638), @MartinNYHC (#1564), @d3nn3s08 (#1657), @rtadams89, @Jeff-GebhartCA, @MA2ZAK (#1397), @vmhomelab's Windows installer carries forward unchanged. 
  
Thank you!
MartinNYHC 2 ay önce
ebeveyn
işleme
c193db0acf
100 değiştirilmiş dosya ile 9217 ekleme ve 440 silme
  1. 5 0
      CHANGELOG.md
  2. 2 1
      README.md
  3. 0 0
      backend/=0.9.0
  4. 46 1
      backend/app/api/routes/archives.py
  5. 5 3
      backend/app/api/routes/discovery.py
  6. 128 10
      backend/app/api/routes/inventory.py
  7. 38 10
      backend/app/api/routes/library.py
  8. 6 0
      backend/app/api/routes/local_backup.py
  9. 666 0
      backend/app/api/routes/orca_cloud.py
  10. 160 2
      backend/app/api/routes/print_log.py
  11. 30 4
      backend/app/api/routes/print_queue.py
  12. 20 3
      backend/app/api/routes/printers.py
  13. 6 0
      backend/app/api/routes/settings.py
  14. 140 22
      backend/app/api/routes/slicer_presets.py
  15. 5 2
      backend/app/api/routes/smart_plugs.py
  16. 17 7
      backend/app/api/routes/spoolman_inventory.py
  17. 9 1
      backend/app/api/routes/system.py
  18. 49 9
      backend/app/api/routes/virtual_printers.py
  19. 6 0
      backend/app/core/auth.py
  20. 1 1
      backend/app/core/config.py
  21. 155 0
      backend/app/core/database.py
  22. 2 0
      backend/app/core/permissions.py
  23. 85 0
      backend/app/core/tasks.py
  24. 18 4
      backend/app/data/filament_fields.json
  25. 386 69
      backend/app/main.py
  26. 6 0
      backend/app/models/archive.py
  27. 10 0
      backend/app/models/print_queue.py
  28. 14 0
      backend/app/models/user.py
  29. 1 0
      backend/app/schemas/archive.py
  30. 1 0
      backend/app/schemas/library.py
  31. 94 0
      backend/app/schemas/orca_cloud.py
  32. 13 0
      backend/app/schemas/print_log.py
  33. 19 0
      backend/app/schemas/print_queue.py
  34. 26 3
      backend/app/schemas/settings.py
  35. 12 5
      backend/app/schemas/slicer.py
  36. 12 8
      backend/app/schemas/slicer_presets.py
  37. 15 2
      backend/app/services/archive.py
  38. 76 8
      backend/app/services/background_dispatch.py
  39. 21 1
      backend/app/services/bambu_mqtt.py
  40. 78 0
      backend/app/services/camera.py
  41. 140 71
      backend/app/services/firmware_check.py
  42. 4 2
      backend/app/services/firmware_update.py
  43. 13 0
      backend/app/services/ftp_profiles.py
  44. 38 10
      backend/app/services/local_backup.py
  45. 504 0
      backend/app/services/orca_cloud.py
  46. 62 0
      backend/app/services/preset_resolver.py
  47. 154 49
      backend/app/services/print_scheduler.py
  48. 84 1
      backend/app/services/printer_diagnostic.py
  49. 6 1
      backend/app/services/printer_manager.py
  50. 4 3
      backend/app/services/smart_plug_manager.py
  51. 549 0
      backend/app/services/spool_csv.py
  52. 27 14
      backend/app/services/spoolman_tracking.py
  53. 29 2
      backend/app/services/usage_tracker.py
  54. 44 12
      backend/app/services/virtual_printer/ftp_server.py
  55. 68 3
      backend/app/services/virtual_printer/manager.py
  56. 74 3
      backend/app/services/virtual_printer/mqtt_bridge.py
  57. 32 11
      backend/app/services/virtual_printer/mqtt_server.py
  58. 3 0
      backend/app/utils/printer_models.py
  59. 50 0
      backend/app/utils/threemf_tools.py
  60. 363 0
      backend/tests/integration/test_archives_api.py
  61. 134 0
      backend/tests/integration/test_inventory_assign.py
  62. 520 0
      backend/tests/integration/test_inventory_csv.py
  63. 46 0
      backend/tests/integration/test_library_api.py
  64. 81 0
      backend/tests/integration/test_print_queue_api.py
  65. 247 0
      backend/tests/integration/test_queue_start_user_attribution.py
  66. 59 0
      backend/tests/integration/test_settings_api.py
  67. 15 10
      backend/tests/integration/test_spool_reset_usage.py
  68. 7 7
      backend/tests/integration/test_spoolman_inventory_api.py
  69. 65 0
      backend/tests/integration/test_system_api.py
  70. 227 0
      backend/tests/integration/test_virtual_printer_api.py
  71. 104 0
      backend/tests/unit/core/test_tasks.py
  72. 63 0
      backend/tests/unit/services/test_archive_service.py
  73. 6 1
      backend/tests/unit/services/test_background_dispatch.py
  74. 16 0
      backend/tests/unit/services/test_background_dispatch_watchdog.py
  75. 55 0
      backend/tests/unit/services/test_bambu_mqtt.py
  76. 156 0
      backend/tests/unit/services/test_dispatch_force_timelapse.py
  77. 16 0
      backend/tests/unit/services/test_ftp_profiles.py
  78. 433 0
      backend/tests/unit/services/test_orca_cloud.py
  79. 77 0
      backend/tests/unit/services/test_preset_resolver.py
  80. 122 3
      backend/tests/unit/services/test_printer_diagnostic.py
  81. 8 1
      backend/tests/unit/services/test_printer_manager.py
  82. 9 2
      backend/tests/unit/services/test_spoolman_tracking.py
  83. 98 0
      backend/tests/unit/services/test_virtual_printer.py
  84. 133 0
      backend/tests/unit/services/test_vp_ftp_port_slicing.py
  85. 82 0
      backend/tests/unit/test_ams_alarm_gating.py
  86. 282 0
      backend/tests/unit/test_cancellation_cascade_recovery_migration.py
  87. 172 0
      backend/tests/unit/test_check_previous_success.py
  88. 205 0
      backend/tests/unit/test_cleanup_forced_timelapse.py
  89. 171 0
      backend/tests/unit/test_extract_video_last_frame.py
  90. 53 0
      backend/tests/unit/test_filament_fields_options.py
  91. 174 0
      backend/tests/unit/test_finish_photo_from_timelapse.py
  92. 77 29
      backend/tests/unit/test_firmware_versions.py
  93. 89 16
      backend/tests/unit/test_local_backup.py
  94. 145 0
      backend/tests/unit/test_makerworld_routes.py
  95. 98 0
      backend/tests/unit/test_print_start_expected_promotion.py
  96. 18 0
      backend/tests/unit/test_reconcile_stale_active_prints.py
  97. 38 0
      backend/tests/unit/test_scheduler_filament_deficit.py
  98. 71 0
      backend/tests/unit/test_scheduler_force_timelapse_wiring.py
  99. 82 8
      backend/tests/unit/test_scheduler_watchdog.py
  100. 132 5
      backend/tests/unit/test_slicer_presets.py

Dosya farkı çok büyük olduğundan ihmal edildi
+ 5 - 0
CHANGELOG.md


+ 2 - 1
README.md

@@ -260,6 +260,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management
 - Bambu Cloud profile management
+- **Orca Cloud profile sync** — read your OrcaSlicer 2.4.0+ cloud-synced profiles directly in Bambuddy, usable for slicing alongside Bambu Cloud / local / standard presets. Four sign-in providers (Google / Apple / GitHub / email+password)
 - **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
@@ -267,7 +268,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - External sidebar links
 - External sidebar links
 - Webhooks & API keys
 - Webhooks & API keys
   - Per-user ownership — each key acts on behalf of its creator
   - Per-user ownership — each key acts on behalf of its creator
-  - Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud presets / filament catalogue / device list (off by default)
+  - Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud + Orca Cloud presets / filament catalogue / device list (off by default)
 - Interactive API browser with live testing
 - Interactive API browser with live testing
 
 
 ### 🖨️ Virtual Printer & Remote Printing
 ### 🖨️ Virtual Printer & Remote Printing

+ 0 - 0
backend/=0.9.0


+ 46 - 1
backend/app/api/routes/archives.py

@@ -427,6 +427,39 @@ async def list_archives(
     return result
     return result
 
 
 
 
+@router.get("/no-3mf-warning")
+async def no_3mf_warning(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Whether to nudge the user about install step 4 ("Store sent files on
+    external storage"). True iff any archive in the last 30 days was created
+    via the no-3MF fallback path — that's the deterministic symptom of the
+    slicer-side variant of the setting being off.
+
+    Complements the connection-diagnostic ``external_storage`` check, which
+    only catches the printer-side variant of the setting. On older slicers
+    where the toggle lives only in BambuStudio, the printer never reports it
+    and the diagnostic passes — this endpoint surfaces the symptom instead.
+
+    Dismissal is handled client-side via localStorage (one-shot): once the
+    user has been told, no further nudge until they clear browser storage.
+    The backend stays stateless.
+    """
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    result = await db.execute(
+        select(PrintArchive.extra_data).where(
+            PrintArchive.created_at >= cutoff,
+            PrintArchive.deleted_at.is_(None),
+            PrintArchive.extra_data.isnot(None),
+        )
+    )
+    for (extra_data,) in result.all():
+        if extra_data and extra_data.get("no_3mf_available"):
+            return {"has_fallback": True}
+    return {"has_fallback": False}
+
+
 @router.get("/slim", response_model=list[ArchiveSlim])
 @router.get("/slim", response_model=list[ArchiveSlim])
 async def list_archives_slim(
 async def list_archives_slim(
     date_from: date | None = Query(None),
     date_from: date | None = Query(None),
@@ -3268,7 +3301,14 @@ async def get_archive_plates(
                 root = ET.fromstring(content)
                 root = ET.fromstring(content)
 
 
                 for plate_elem in root.findall(".//plate"):
                 for plate_elem in root.findall(".//plate"):
-                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
+                    plate_info = {
+                        "filaments": [],
+                        "prediction": None,
+                        "weight": None,
+                        "name": None,
+                        "objects": [],
+                        "bed_type": None,
+                    }
 
 
                     # Get plate index from metadata
                     # Get plate index from metadata
                     plate_index = None
                     plate_index = None
@@ -3290,6 +3330,10 @@ async def get_archive_plates(
                                 plate_info["weight"] = float(value)
                                 plate_info["weight"] = float(value)
                             except ValueError:
                             except ValueError:
                                 pass  # Skip non-numeric filament weight
                                 pass  # Skip non-numeric filament weight
+                        elif key == "curr_bed_type" and value:
+                            # Per-plate bed type so the PrintModal can show the
+                            # right plate alongside each option (#1281).
+                            plate_info["bed_type"] = value.strip()
 
 
                     # Get filaments used in this plate
                     # Get filaments used in this plate
                     for filament_elem in plate_elem.findall("filament"):
                     for filament_elem in plate_elem.findall("filament"):
@@ -3391,6 +3435,7 @@ async def get_archive_plates(
                         "print_time_seconds": meta.get("prediction"),
                         "print_time_seconds": meta.get("prediction"),
                         "filament_used_grams": meta.get("weight"),
                         "filament_used_grams": meta.get("weight"),
                         "filaments": meta.get("filaments", []),
                         "filaments": meta.get("filaments", []),
+                        "bed_type": meta.get("bed_type"),
                     }
                     }
                 )
                 )
 
 

+ 5 - 3
backend/app/api/routes/discovery.py

@@ -12,6 +12,7 @@ from pydantic import BaseModel
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.discovery import (
 from backend.app.services.discovery import (
     discovery_service,
     discovery_service,
@@ -154,9 +155,10 @@ async def start_subnet_scan(
         request: Subnet to scan in CIDR notation (e.g., "192.168.1.0/24")
         request: Subnet to scan in CIDR notation (e.g., "192.168.1.0/24")
     """
     """
     # Start scan in background
     # Start scan in background
-    import asyncio
-
-    asyncio.create_task(subnet_scanner.scan_subnet(request.subnet, request.timeout))
+    spawn_background_task(
+        subnet_scanner.scan_subnet(request.subnet, request.timeout),
+        name=f"subnet-scan-{request.subnet}",
+    )
 
 
     # Return immediate status
     # Return immediate status
     scanned, total = subnet_scanner.progress
     scanned, total = subnet_scanner.progress

+ 128 - 10
backend/app/api/routes/inventory.py

@@ -2,8 +2,8 @@ import json
 import logging
 import logging
 
 
 import httpx
 import httpx
-from fastapi import APIRouter, Depends, HTTPException
-from fastapi.responses import StreamingResponse
+from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
+from fastapi.responses import Response, StreamingResponse
 from pydantic import BaseModel, Field, field_validator
 from pydantic import BaseModel, Field, field_validator
 from sqlalchemy import delete, func, select
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -38,6 +38,13 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
     normalize_extra_colors,
 )
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.services.spool_csv import (
+    MAX_CSV_IMPORT_BYTES,
+    ImportPreview,
+    ImportResult,
+    parse_and_validate,
+    serialize,
+)
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
     MATERIAL_TEMPS,
     MATERIAL_TEMPS,
@@ -52,6 +59,10 @@ _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
 
 
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 
 
+# Bounded read size for the CSV import body so a chunked upload with no
+# Content-Length can't stream past the cap into memory before we notice.
+_CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
+
 # FilamentColors.xyz API
 # FilamentColors.xyz API
 FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
 FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
 
 
@@ -126,7 +137,15 @@ async def apply_spool_to_slot_via_mqtt(
 
 
     if sf:
     if sf:
         base_sf = sf.split("_")[0] if "_" in sf else sf
         base_sf = sf.split("_")[0] if "_" in sf else sf
-        if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+        # Cloud-side preset IDs in three known shapes:
+        #   GFS…   — Bambu official cloud preset
+        #   PFUS…  — cloud user-created preset
+        #   PFCN…  — cloud shared / partner preset (e.g. Polymaker's
+        #            "(Custom)" Bambu Lab H2D variant, #1648)
+        # All three need a cloud-detail lookup to extract the underlying
+        # filament_id; without it the raw cloud id ends up in tray_info_idx
+        # and the printer's calibration table can't resolve it.
+        if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
             setting_id = base_sf
             setting_id = base_sf
             try:
             try:
                 from backend.app.api.routes.cloud import build_authenticated_cloud
                 from backend.app.api.routes.cloud import build_authenticated_cloud
@@ -204,7 +223,7 @@ async def apply_spool_to_slot_via_mqtt(
                     setting_id = filament_id_to_setting_id(fid)
                     setting_id = filament_id_to_setting_id(fid)
                     break
                     break
 
 
-    # Defend against tray_info_idx values the slicer cannot resolve. Two
+    # Defend against tray_info_idx values the slicer cannot resolve. Three
     # shapes leak through and must be discarded so the generic-material
     # shapes leak through and must be discarded so the generic-material
     # fallback below can rescue the slot:
     # fallback below can rescue the slot:
     #   1. Literal material names ("PLA", "PETG-CF") that pass through
     #   1. Literal material names ("PLA", "PETG-CF") that pass through
@@ -217,10 +236,16 @@ async def apply_spool_to_slot_via_mqtt(
     #      replay path in main.py.on_ams_change passes current_user=None,
     #      replay path in main.py.on_ams_change passes current_user=None,
     #      which skips cloud auth and leaves the raw PFUS in tray_info_idx —
     #      which skips cloud auth and leaves the raw PFUS in tray_info_idx —
     #      overwriting the correctly-configured slot from the original assign.
     #      overwriting the correctly-configured slot from the original assign.
+    #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
+    #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
-    # "P" followed by hex (user/local presets, NOT "PFUS").
+    # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     _known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
     _known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
-    if tray_info_idx and (tray_info_idx.upper() in _known_materials or tray_info_idx.startswith("PFUS")):
+    if tray_info_idx and (
+        tray_info_idx.upper() in _known_materials
+        or tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+    ):
         tray_info_idx = ""
         tray_info_idx = ""
         setting_id = ""
         setting_id = ""
 
 
@@ -229,6 +254,7 @@ async def apply_spool_to_slot_via_mqtt(
             current_tray_info_idx
             current_tray_info_idx
             and current_tray_info_idx not in _generic_id_values
             and current_tray_info_idx not in _generic_id_values
             and not current_tray_info_idx.startswith("PFUS")
             and not current_tray_info_idx.startswith("PFUS")
+            and not current_tray_info_idx.startswith("PFCN")
             and current_tray_info_idx.upper() not in _known_materials
             and current_tray_info_idx.upper() not in _known_materials
             and current_tray_type
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
             and current_tray_type.upper() == tray_type.upper()
@@ -937,6 +963,92 @@ async def list_spools(
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
+# ── CSV import / export (#1576) ──────────────────────────────────────────────
+# Declared before the dynamic `/spools/{spool_id}` route below so the literal
+# `export` / `import` segments match here instead of being parsed as an int id.
+
+
+@router.get("/spools/export")
+async def export_spools_csv(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Export the active inventory as CSV (same schema the importer accepts)."""
+    from datetime import datetime, timezone
+
+    query = select(Spool).where(Spool.archived_at.is_(None)).order_by(Spool.material, Spool.brand, Spool.color_name)
+    result = await db.execute(query)
+    spools = list(result.scalars().all())
+    content = serialize(spools)
+    # Date-stamp the filename so repeat exports don't overwrite each other in
+    # the browser's default download folder.
+    filename = f"bambuddy_inventory_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
+    return Response(
+        content=content,
+        media_type="text/csv",
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@router.post("/spools/import", response_model=ImportPreview | ImportResult)
+async def import_spools_csv(
+    file: UploadFile = File(...),
+    dry_run: bool = Query(False),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Import spools from a CSV file.
+
+    With ``dry_run=true`` returns an ImportPreview (per-row valid/error/skipped,
+    colours resolved) and writes nothing — the UI shows this before the user
+    confirms. With ``dry_run=false`` it validates the same way and then persists
+    only the valid rows in a single transaction (invalid rows are skipped, the
+    user fixes the CSV and re-uploads), returning an ImportResult summary.
+    """
+
+    def _too_large() -> HTTPException:
+        return HTTPException(
+            status_code=413,
+            detail={
+                "code": "csv_import_too_large",
+                "message": f"CSV file exceeds the {MAX_CSV_IMPORT_BYTES // (1024 * 1024)} MB limit.",
+            },
+        )
+
+    # Reject by declared size first (fast path when Content-Length is set), then
+    # read in bounded chunks and bail the moment the accumulated body crosses the
+    # cap — file.size is None for chunked uploads, so the loop is what actually
+    # keeps an oversized stream from filling memory.
+    if file.size is not None and file.size > MAX_CSV_IMPORT_BYTES:
+        raise _too_large()
+    raw = bytearray()
+    while chunk := await file.read(_CSV_UPLOAD_CHUNK_BYTES):
+        raw.extend(chunk)
+        if len(raw) > MAX_CSV_IMPORT_BYTES:
+            raise _too_large()
+    preview = await parse_and_validate(bytes(raw), db)
+
+    if dry_run:
+        return preview
+
+    created = 0
+    for row in preview.rows:
+        if row.status == "valid" and row.spool is not None:
+            db.add(Spool(**row.spool))
+            created += 1
+
+    if created:
+        await db.commit()
+        await ws_manager.broadcast({"type": "inventory_changed"})
+
+    return ImportResult(
+        created=created,
+        skipped=preview.skipped_count,
+        errors=preview.error_count,
+        error_rows=[r for r in preview.rows if r.status == "error"],
+    )
+
+
 @router.get("/spools/{spool_id}", response_model=SpoolResponse)
 @router.get("/spools/{spool_id}", response_model=SpoolResponse)
 async def get_spool(
 async def get_spool(
     spool_id: int,
     spool_id: int,
@@ -1071,8 +1183,8 @@ async def restore_spool(
     return result.scalar_one()
     return result.scalar_one()
 
 
 
 
-@router.post("/spools/{spool_id}/reset-usage", response_model=SpoolResponse)
-async def reset_spool_usage(
+@router.post("/spools/{spool_id}/reset-consumed-counter", response_model=SpoolResponse)
+async def reset_spool_consumed_counter(
     spool_id: int,
     spool_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
@@ -1084,6 +1196,12 @@ async def reset_spool_usage(
     weight_used` (remaining) is unchanged. weight_locked is also left
     weight_used` (remaining) is unchanged. weight_locked is also left
     alone — the spool keeps receiving AMS auto-sync updates. Matches
     alone — the spool keeps receiving AMS auto-sync updates. Matches
     Spoolman's split between used_weight and remaining_weight (#1390).
     Spoolman's split between used_weight and remaining_weight (#1390).
+
+    The earlier name `/reset-usage` was misleading: callers reasonably
+    expected `weight_used` itself to drop to 0 and were surprised when
+    the response showed it unchanged. The current name describes what
+    the endpoint actually does — reset the "Total Consumed" counter
+    widget, not the lifetime weight_used field.
     """
     """
     result = await db.execute(select(Spool).where(Spool.id == spool_id))
     result = await db.execute(select(Spool).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
     spool = result.scalar_one_or_none()
@@ -1097,8 +1215,8 @@ async def reset_spool_usage(
     return result.scalar_one()
     return result.scalar_one()
 
 
 
 
-@router.post("/spools/reset-usage-bulk")
-async def bulk_reset_spool_usage(
+@router.post("/spools/reset-consumed-counter-bulk")
+async def bulk_reset_spool_consumed_counter(
     payload: dict,
     payload: dict,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),

+ 38 - 10
backend/app/api/routes/library.py

@@ -1,6 +1,5 @@
 """API routes for File Manager (Library) functionality."""
 """API routes for File Manager (Library) functionality."""
 
 
-import asyncio
 import base64
 import base64
 import binascii
 import binascii
 import contextlib
 import contextlib
@@ -30,6 +29,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
@@ -449,13 +449,21 @@ async def save_3mf_bytes_to_library(
         if existing_row is not None:
         if existing_row is not None:
             return existing_row, True
             return existing_row, True
 
 
-    # Persist bytes to disk under a UUID-scoped filename; keep the original
-    # extension so downstream logic (ThreeMFParser, thumbnail viewer) works.
-    ext = os.path.splitext(filename)[1].lower() or ".3mf"
-    unique_filename = f"{uuid.uuid4().hex}{ext}"
-    file_path = (
-        get_library_files_dir() / unique_filename
-    )  # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext, generated on the previous line
+    # Resolve target folder so writable-external destinations land on the
+    # mount with the real filename, instead of being silently misrouted to
+    # the internal library dir with a UUID name (#1645). Mirrors what the
+    # multipart-upload path has done since #1112. ``_resolve_upload_destination``
+    # also enforces the 403 read-only / 400 unwritable / 409 collision
+    # rejections — the makerworld route layer already pre-checks read-only,
+    # but the helper's checks remain as defence-in-depth for any future
+    # caller that skips that route gate.
+    target_folder: LibraryFolder | None = None
+    if folder_id is not None:
+        folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
+        target_folder = folder_q.scalar_one_or_none()
+
+    file_path, is_external = _resolve_upload_destination(target_folder, filename)
+    ext = file_path.suffix.lower() or ".3mf"
     with open(file_path, "wb") as fh:
     with open(file_path, "wb") as fh:
         fh.write(file_bytes)
         fh.write(file_bytes)
 
 
@@ -486,8 +494,9 @@ async def save_3mf_bytes_to_library(
 
 
     library_file = LibraryFile(
     library_file = LibraryFile(
         folder_id=folder_id,
         folder_id=folder_id,
+        is_external=is_external,
         filename=filename,
         filename=filename,
-        file_path=to_relative_path(file_path),
+        file_path=_stored_file_path(file_path, is_external),
         file_type=classify_file_type(filename),
         file_type=classify_file_type(filename),
         file_size=len(file_bytes),
         file_size=len(file_bytes),
         file_hash=file_hash,
         file_hash=file_hash,
@@ -1602,7 +1611,7 @@ async def scan_external_folder(
     # folder_cache.values() covers the root + every pre-existing subfolder
     # folder_cache.values() covers the root + every pre-existing subfolder
     # + every subfolder created during this scan. all_folder_ids on its own
     # + every subfolder created during this scan. all_folder_ids on its own
     # would miss the newly-created ones (it's snapshotted before the walk).
     # would miss the newly-created ones (it's snapshotted before the walk).
-    asyncio.create_task(
+    spawn_background_task(
         _backfill_external_stl_thumbnails(list(set(folder_cache.values()))),
         _backfill_external_stl_thumbnails(list(set(folder_cache.values()))),
         name=f"stl-backfill-folder-{folder_id}",
         name=f"stl-backfill-folder-{folder_id}",
     )
     )
@@ -1620,6 +1629,8 @@ async def list_files(
     folder_id: int | None = None,
     folder_id: int | None = None,
     project_id: int | None = None,
     project_id: int | None = None,
     include_root: bool = True,
     include_root: bool = True,
+    internal_only: bool = False,
+    external_only: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
 ):
@@ -1630,7 +1641,19 @@ async def list_files(
         project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).
         project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).
         include_root: If True and folder_id is None, returns files at root level.
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
                      If False and folder_id is None, returns all files.
+        internal_only: Restrict the result to files in managed storage (`is_external=False`).
+                       Used by the File Manager's "All Files" sidebar entry so a linked NAS
+                       with hundreds of files doesn't drown the user's own uploads (#1621).
+        external_only: Restrict the result to files under external folders
+                       (`is_external=True`) — the symmetric combined view for users with
+                       multiple linked external sources (#1621).
     """
     """
+    if internal_only and external_only:
+        raise HTTPException(
+            status_code=400,
+            detail="internal_only and external_only are mutually exclusive",
+        )
+
     query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
     query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
 
 
     if folder_id is not None:
     if folder_id is not None:
@@ -1642,6 +1665,11 @@ async def list_files(
     elif include_root:
     elif include_root:
         query = query.where(LibraryFile.folder_id.is_(None))
         query = query.where(LibraryFile.folder_id.is_(None))
 
 
+    if internal_only:
+        query = query.where(LibraryFile.is_external.is_(False))
+    elif external_only:
+        query = query.where(LibraryFile.is_external.is_(True))
+
     query = query.order_by(LibraryFile.filename)
     query = query.order_by(LibraryFile.filename)
     result = await db.execute(query)
     result = await db.execute(query)
     files = result.scalars().all()
     files = result.scalars().all()

+ 6 - 0
backend/app/api/routes/local_backup.py

@@ -20,6 +20,8 @@ async def get_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
 ):
     """Get local backup scheduler status and configuration."""
     """Get local backup scheduler status and configuration."""
+    from backend.app.services.local_backup import _local_zone
+
     settings = await local_backup_service._load_settings()
     settings = await local_backup_service._load_settings()
     status = local_backup_service.get_status()
     status = local_backup_service.get_status()
     return {
     return {
@@ -30,6 +32,10 @@ async def get_status(
         "retention": settings["retention"],
         "retention": settings["retention"],
         "path": settings["path"],
         "path": settings["path"],
         "default_path": str(local_backup_service._resolve_backup_dir("")),
         "default_path": str(local_backup_service._resolve_backup_dir("")),
+        # IANA zone name the HH:MM picker is interpreted in (TZ env, UTC fallback).
+        # Frontend renders this next to the time field so users see the same
+        # zone the backend will use. #1602 follow-up.
+        "timezone": str(_local_zone()),
     }
     }
 
 
 
 

+ 666 - 0
backend/app/api/routes/orca_cloud.py

@@ -0,0 +1,666 @@
+"""
+Orca Cloud API Routes
+
+PKCE-based connect/disconnect + profile sync endpoints for the
+Orca Cloud (Supabase) profile-sync surface.
+
+Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
+
+    POST /orca-cloud/auth/start
+        Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
+    POST /orca-cloud/auth/finish
+        Parse the pasted callback URL, validate state for CSRF, exchange the
+        code for tokens, persist them atomically.
+    GET  /orca-cloud/status
+        Connected/disconnected + email + user_id.
+    POST /orca-cloud/logout
+        Clear stored tokens (no Supabase-side revocation — token still
+        survives until its 1h expiry, but Bambuddy has no way to use it).
+    GET  /orca-cloud/profiles
+        Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
+        access token if it's within the 5-min leeway of expiry.
+    GET  /orca-cloud/profiles/{id}
+        Single profile's full content.
+
+Storage shape mirrors the Bambu Cloud surface: per-user columns on
+``users`` when auth is enabled, fallback to global ``settings`` keys when
+auth is disabled. The transient PKCE state (verifier, state, pending_at)
+is stored alongside the tokens — same dual-mode pattern.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.orca_cloud import (
+    OrcaAuthFinishRequest,
+    OrcaAuthPasswordRequest,
+    OrcaAuthStartRequest,
+    OrcaAuthStartResponse,
+    OrcaAuthStatusResponse,
+    OrcaProfileDetail,
+    OrcaProfileListResponse,
+    OrcaProfileMeta,
+)
+from backend.app.services.orca_cloud import (
+    PENDING_PKCE_TTL,
+    OrcaCloudAuthError,
+    OrcaCloudError,
+    OrcaCloudService,
+    build_authorize_url,
+    generate_pkce,
+    parse_callback_url,
+)
+
+logger = logging.getLogger(__name__)
+
+# Router-level dependency: enforce the same API-key cloud-access fence as the
+# Bambu Cloud router (rejects ownerless legacy keys, requires the
+# ``can_access_cloud`` scope, stashes the owner on ``request.state`` so
+# per-route deps can resolve it as the effective ``current_user``).
+# Without this gate the kiosk's API-keyed requests sail past with
+# ``current_user=None`` → ``_build_authenticated_service`` falls back to
+# the global Settings table → no Orca token → 401, no presets surfaced.
+# Bambu Cloud works in the same kiosk because its router has this gate.
+router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)])
+
+# Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary.
+# Empirically (confirmed against a live account on 2026-06-04): Orca uses
+# ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio
+# ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere
+# in the OrcaSlicer source. The aliases keep us forward-compatible if Orca
+# ever flips back to the older naming.
+_ORCA_TYPE_TO_BAMBU = {
+    "filament": "filament",
+    "printer": "printer",
+    "machine": "printer",  # alias for the BambuStudio-style naming
+    "print": "process",
+    "process": "process",  # alias for the BambuStudio-style naming
+}
+
+
+def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
+    """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
+    into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
+    isn't a dict or the type isn't one we render."""
+    content = orca_profile.get("content") or {}
+    if not isinstance(content, dict):
+        return None
+    bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
+    if bambu_type is None:
+        return None
+    pid = orca_profile.get("id")
+    if pid is None:
+        return None
+    updated = orca_profile.get("updated_time")
+    return OrcaProfileMeta(
+        setting_id=str(pid),
+        name=str(orca_profile.get("name") or pid),
+        type=bambu_type,
+        version=_str_or_none(content.get("version")),
+        # ``from`` distinguishes ``system`` (bundled) from ``User`` (custom),
+        # same field the Bambu source-of-truth uses for that distinction.
+        user_id=_str_or_none(content.get("user_id") or content.get("from")),
+        updated_time=str(updated) if updated is not None else None,
+        # Every profile that lives in the user's Orca Cloud account is by
+        # definition user-authored; bundled defaults aren't synced.
+        is_custom=True,
+    )
+
+
+def _str_or_none(value: object) -> str | None:
+    """Cast non-empty scalars to ``str``; pass ``None`` and empty values
+    through unchanged. Used to keep the response shape consistent when
+    Orca's source data has heterogenous typing for the same field."""
+    if value is None:
+        return None
+    s = str(value)
+    return s if s else None
+
+
+# Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
+# pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
+# settings table see a consistent prefix.
+_SETTINGS_KEYS = {
+    "token": "orca_cloud_token",
+    "refresh_token": "orca_cloud_refresh_token",
+    "expires_at": "orca_cloud_expires_at",  # ISO 8601 UTC string
+    "email": "orca_cloud_email",
+    "user_id": "orca_cloud_user_id",
+    "pending_verifier": "orca_cloud_pending_verifier",
+    "pending_state": "orca_cloud_pending_state",
+    "pending_at": "orca_cloud_pending_at",  # ISO 8601 UTC string
+}
+
+
+# ---------------------------------------------------------------------------
+# Storage helpers — bridge User-row vs Settings-table fallback transparently
+# ---------------------------------------------------------------------------
+
+
+def _iso(dt: datetime | None) -> str | None:
+    """Serialize a datetime to ISO 8601 UTC. ``None`` passes through."""
+    if dt is None:
+        return None
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt.astimezone(timezone.utc).isoformat()
+
+
+def _as_utc(dt: datetime | None) -> datetime | None:
+    """Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as
+    UTC. ``None`` passes through. Already-aware datetimes are converted to
+    UTC to normalize."""
+    if dt is None:
+        return None
+    if dt.tzinfo is None:
+        return dt.replace(tzinfo=timezone.utc)
+    return dt.astimezone(timezone.utc)
+
+
+def _parse_iso(value: str | None) -> datetime | None:
+    """Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through."""
+    if not value:
+        return None
+    try:
+        dt = datetime.fromisoformat(value)
+    except (TypeError, ValueError):
+        return None
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt
+
+
+class _OrcaCredentials:
+    """Lightweight bag for stored Orca Cloud credentials. We use a class
+    rather than a dataclass so the helpers can mutate it as needed during
+    JIT-refresh without rebuilding the whole object."""
+
+    __slots__ = (
+        "token",
+        "refresh_token",
+        "expires_at",
+        "email",
+        "user_id",
+        "pending_verifier",
+        "pending_state",
+        "pending_at",
+    )
+
+    def __init__(self) -> None:
+        self.token: str | None = None
+        self.refresh_token: str | None = None
+        self.expires_at: datetime | None = None
+        self.email: str | None = None
+        self.user_id: str | None = None
+        self.pending_verifier: str | None = None
+        self.pending_state: str | None = None
+        self.pending_at: datetime | None = None
+
+
+async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials:
+    """Load stored Orca Cloud credentials for the caller (user-row when auth
+    is enabled, Settings fallback when auth is disabled).
+
+    Datetimes coming back from the User row are NAIVE on the Postgres side
+    (asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but
+    represent UTC moments because that's what we stored. We attach
+    ``tzinfo=UTC`` here so downstream comparisons against
+    ``datetime.now(timezone.utc)`` don't get shifted by the host's local
+    offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on
+    a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and
+    fires the 10-minute TTL guard immediately."""
+    creds = _OrcaCredentials()
+    if user is not None:
+        creds.token = user.orca_cloud_token
+        creds.refresh_token = user.orca_cloud_refresh_token
+        creds.expires_at = _as_utc(user.orca_cloud_expires_at)
+        creds.email = user.orca_cloud_email
+        creds.user_id = user.orca_cloud_user_id
+        creds.pending_verifier = user.orca_cloud_pending_verifier
+        creds.pending_state = user.orca_cloud_pending_state
+        creds.pending_at = _as_utc(user.orca_cloud_pending_at)
+        return creds
+
+    result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
+    raw = {s.key: s.value for s in result.scalars().all()}
+    creds.token = raw.get(_SETTINGS_KEYS["token"])
+    creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"])
+    creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
+    creds.email = raw.get(_SETTINGS_KEYS["email"])
+    creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
+    creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
+    creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
+    creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
+    return creds
+
+
+async def _persist_pending_pkce(
+    db: AsyncSession,
+    user: User | None,
+    verifier: str,
+    state: str,
+    when: datetime,
+) -> None:
+    """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_pending_verifier=verifier,
+                orca_cloud_pending_state=state,
+                orca_cloud_pending_at=when,
+            )
+        )
+        await db.commit()
+        return
+    await _upsert_settings(
+        db,
+        {
+            _SETTINGS_KEYS["pending_verifier"]: verifier,
+            _SETTINGS_KEYS["pending_state"]: state,
+            _SETTINGS_KEYS["pending_at"]: _iso(when),
+        },
+    )
+
+
+async def _persist_tokens(
+    db: AsyncSession,
+    user: User | None,
+    access_token: str,
+    refresh_token: str | None,
+    expires_at: datetime | None,
+    email: str | None,
+    user_id: str | None,
+) -> None:
+    """Atomically write the new access/refresh pair to whichever backing store
+    the deployment uses. Also clears the pending PKCE state on the same write,
+    since by this point the handshake is complete."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_token=access_token,
+                orca_cloud_refresh_token=refresh_token,
+                orca_cloud_expires_at=expires_at,
+                orca_cloud_email=email,
+                orca_cloud_user_id=user_id,
+                orca_cloud_pending_verifier=None,
+                orca_cloud_pending_state=None,
+                orca_cloud_pending_at=None,
+            )
+        )
+        await db.commit()
+        return
+    await _upsert_settings(
+        db,
+        {
+            _SETTINGS_KEYS["token"]: access_token,
+            _SETTINGS_KEYS["refresh_token"]: refresh_token,
+            _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
+            _SETTINGS_KEYS["email"]: email,
+            _SETTINGS_KEYS["user_id"]: user_id,
+            _SETTINGS_KEYS["pending_verifier"]: None,
+            _SETTINGS_KEYS["pending_state"]: None,
+            _SETTINGS_KEYS["pending_at"]: None,
+        },
+    )
+
+
+async def _persist_rotated_tokens(
+    db: AsyncSession,
+    user: User | None,
+    access_token: str,
+    refresh_token: str | None,
+    expires_at: datetime | None,
+) -> None:
+    """Persist tokens after a refresh — does NOT touch email/user_id and does
+    NOT touch the pending PKCE state (refresh happens long after the handshake)."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_token=access_token,
+                orca_cloud_refresh_token=refresh_token,
+                orca_cloud_expires_at=expires_at,
+            )
+        )
+        await db.commit()
+        return
+    await _upsert_settings(
+        db,
+        {
+            _SETTINGS_KEYS["token"]: access_token,
+            _SETTINGS_KEYS["refresh_token"]: refresh_token,
+            _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
+        },
+    )
+
+
+async def _clear_credentials(db: AsyncSession, user: User | None) -> None:
+    """Wipe everything Orca-related (tokens, identity, pending state)."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_token=None,
+                orca_cloud_refresh_token=None,
+                orca_cloud_expires_at=None,
+                orca_cloud_email=None,
+                orca_cloud_user_id=None,
+                orca_cloud_pending_verifier=None,
+                orca_cloud_pending_state=None,
+                orca_cloud_pending_at=None,
+            )
+        )
+        await db.commit()
+        return
+    result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
+    for setting in result.scalars().all():
+        await db.delete(setting)
+    await db.commit()
+
+
+async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None:
+    """Idempotent upsert into the Settings table. ``None`` values delete the row."""
+    keys = [k for k, _ in values.items()]
+    result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
+    existing = {s.key: s for s in result.scalars().all()}
+    for key, value in values.items():
+        row = existing.get(key)
+        if value is None:
+            if row is not None:
+                await db.delete(row)
+            continue
+        if row is not None:
+            row.value = value
+        else:
+            db.add(Settings(key=key, value=value))
+    await db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Authenticated service builder with JIT refresh
+# ---------------------------------------------------------------------------
+
+
+async def _build_authenticated_service(
+    db: AsyncSession,
+    user: User | None,
+) -> OrcaCloudService:
+    """Construct an :class:`OrcaCloudService` pre-populated with stored
+    credentials. If the access token is within the refresh-leeway of expiry,
+    proactively refresh and persist the new pair BEFORE returning, so the
+    next API call doesn't time out mid-flight on an expired token."""
+    creds = await _load_credentials(db, user)
+    if not creds.token:
+        raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
+
+    svc = OrcaCloudService()
+    svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
+    if not svc.is_authenticated:
+        if not svc.refresh_token:
+            raise HTTPException(
+                status_code=401,
+                detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
+            )
+        try:
+            await svc.refresh()
+        except OrcaCloudAuthError as e:
+            # Refresh token was revoked or rotated out from under us. Clear
+            # the stale credentials so the UI flips to disconnected.
+            await _clear_credentials(db, user)
+            raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
+        except OrcaCloudError as e:
+            raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
+        # Persist new pair BEFORE returning. A crash between here and the
+        # downstream API call would still leave the user with valid stored
+        # tokens for the next request.
+        await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
+    return svc
+
+
+# ---------------------------------------------------------------------------
+# Route handlers
+# ---------------------------------------------------------------------------
+
+
+@router.post("/auth/start", response_model=OrcaAuthStartResponse)
+async def auth_start(
+    payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Generate PKCE state and return the Supabase authorize URL for the
+    requested OAuth provider (google / apple / github). The frontend opens
+    the URL in a new tab; after sign-in the user pastes the callback URL
+    back into ``/auth/finish``.
+
+    ``state`` is generated but NOT sent to Supabase (it would clash with
+    GoTrue's internal redirect_to-tracking state). We still persist it so
+    a future flow change can re-introduce state-based CSRF if needed; CSRF
+    protection today comes from the PKCE verifier itself, which is
+    single-use, server-side, and bound to the caller's user row."""
+    verifier, challenge, state = generate_pkce()
+    await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
+    return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
+
+
+@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
+async def auth_password(
+    payload: OrcaAuthPasswordRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Direct email+password sign-in. No browser redirect, no paste flow —
+    Bambuddy POSTs the credentials to Supabase and stores the returned
+    tokens. Whether this succeeds depends on Orca's Supabase project
+    accepting the password grant; if it rejects (the SDK refuses passwords
+    by design, the backend may follow suit), the caller falls back to an
+    OAuth provider via ``/auth/start``."""
+    svc = OrcaCloudService()
+    try:
+        await svc.password_login(payload.email, payload.password)
+    except OrcaCloudAuthError as e:
+        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+    except OrcaCloudError as e:
+        raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
+
+    email: str | None = None
+    user_id: str | None = None
+    try:
+        user_info = await svc.get_user_info()
+        if isinstance(user_info, dict):
+            email = user_info.get("email")
+            user_id = user_info.get("id")
+    except OrcaCloudError as e:
+        logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
+
+    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
+    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+
+
+@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
+async def auth_finish(
+    payload: OrcaAuthFinishRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Complete the PKCE handshake — parse the pasted callback URL, validate
+    state (CSRF), exchange the code for tokens, persist."""
+    creds = await _load_credentials(db, current_user)
+    if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
+        raise HTTPException(
+            status_code=400,
+            detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
+        )
+
+    # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
+    # normalization. Subtracting two aware UTC datetimes gives a real wall-clock
+    # delta with no local-offset shift.
+    age = datetime.now(timezone.utc) - creds.pending_at
+    if age > PENDING_PKCE_TTL:
+        # Don't leave the stale state in the DB — clear it so the user has to
+        # restart fresh, which forces a new verifier/state pair.
+        await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
+                "Click Connect again to start over."
+            ),
+        )
+
+    code, _callback_state = parse_callback_url(payload.callback_url)
+    if not code:
+        raise HTTPException(
+            status_code=400,
+            detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
+        )
+    # We do NOT validate ``state`` here: Supabase doesn't echo back a state we
+    # don't send (see :func:`build_authorize_url` for why we can't send one).
+    # CSRF is protected by PKCE: the verifier is server-side and single-use,
+    # so an attacker can't complete the exchange with a code they obtained
+    # separately. ``pending_state`` is still stored for forward compatibility
+    # if Supabase ever supports a client-passed state alongside redirect_to.
+
+    svc = OrcaCloudService()
+    try:
+        await svc.exchange_code(code, creds.pending_verifier)
+    except OrcaCloudAuthError as e:
+        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+    except OrcaCloudError as e:
+        raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
+
+    # Fetch user info so we can show the connected email in the UI.
+    email: str | None = None
+    user_id: str | None = None
+    try:
+        user_info = await svc.get_user_info()
+        if isinstance(user_info, dict):
+            email = user_info.get("email")
+            user_id = user_info.get("id")
+    except OrcaCloudError as e:
+        # Don't fail the whole connect flow just because the user-info side
+        # call hiccuped — we have valid tokens, that's the load-bearing part.
+        logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
+
+    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
+    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+
+
+@router.get("/status", response_model=OrcaAuthStatusResponse)
+async def get_status(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Return whether the caller has an Orca Cloud session stored, plus
+    identifier details for display. Does NOT make a live API call."""
+    creds = await _load_credentials(db, current_user)
+    return OrcaAuthStatusResponse(
+        connected=bool(creds.token),
+        email=creds.email,
+        user_id=creds.user_id,
+    )
+
+
+@router.post("/logout")
+async def logout(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Clear stored Orca Cloud credentials. Does not call Supabase's
+    ``/logout`` endpoint (the token would still survive its 1h expiry there
+    either way, and Bambuddy will no longer have it to use)."""
+    await _clear_credentials(db, current_user)
+    return {"success": True}
+
+
+@router.get("/profiles", response_model=OrcaProfileListResponse)
+async def list_profiles(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Return profile metadata grouped by type (``filament`` / ``printer``
+    / ``process``), matching the ``SlicerSettingsResponse`` shape the
+    Bambu Cloud tab consumes. This lets the frontend render Orca profiles
+    with the same visual components — same cards, same filter bar, same
+    grouping — without separate UI code paths."""
+    svc = await _build_authenticated_service(db, current_user)
+    try:
+        raw_profiles = await svc.list_profiles()
+    except OrcaCloudAuthError as e:
+        raise HTTPException(status_code=401, detail=str(e)) from e
+    except OrcaCloudError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []}
+    # Log any unknown content.type values we silently drop, so a future
+    # change in Orca's type vocabulary surfaces in the logs rather than
+    # quietly losing profiles.
+    unknown_types: dict[str, int] = {}
+    for entry in raw_profiles:
+        setting = _orca_to_setting(entry)
+        if setting is None:
+            content = entry.get("content") if isinstance(entry, dict) else None
+            raw_type = (content.get("type") if isinstance(content, dict) else None) or "<missing>"
+            unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1
+            continue
+        grouped[setting.type].append(setting)
+    if unknown_types:
+        logger.warning(
+            "Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s",
+            sum(unknown_types.values()),
+            unknown_types,
+        )
+    return OrcaProfileListResponse(**grouped)
+
+
+@router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
+async def get_profile(
+    profile_id: str,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
+):
+    """Fetch a single profile's full content, shaped like
+    ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
+    unchanged. The inner ``setting`` field is the raw slicer-format JSON
+    Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
+    BambuStudio fork."""
+    svc = await _build_authenticated_service(db, current_user)
+    try:
+        profile = await svc.get_profile(profile_id)
+    except OrcaCloudAuthError as e:
+        raise HTTPException(status_code=401, detail=str(e)) from e
+    except OrcaCloudError as e:
+        if "not found" in str(e).lower():
+            raise HTTPException(status_code=404, detail=str(e)) from e
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    content = profile.get("content") if isinstance(profile, dict) else None
+    if not isinstance(content, dict):
+        content = {}
+    orca_type = str(content.get("type", ""))
+    bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type)
+    update_time = profile.get("updated_time") if isinstance(profile, dict) else None
+    return OrcaProfileDetail(
+        setting_id=str(profile_id),
+        name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id),
+        type=bambu_type,
+        version=_str_or_none(content.get("version")),
+        base_id=_str_or_none(content.get("inherits") or content.get("base_id")),
+        update_time=str(update_time) if update_time is not None else None,
+        setting=content,
+    )

+ 160 - 2
backend/app/api/routes/print_log.py

@@ -6,13 +6,17 @@ from fastapi.responses import FileResponse
 from sqlalchemy import delete, func, select
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
+    RequirePermissionIfAuthEnabled,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
+from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -68,6 +72,7 @@ async def get_print_log(
         items=[
         items=[
             PrintLogEntrySchema(
             PrintLogEntrySchema(
                 id=e.id,
                 id=e.id,
+                archive_id=e.archive_id,
                 print_name=e.print_name,
                 print_name=e.print_name,
                 printer_name=e.printer_name,
                 printer_name=e.printer_name,
                 printer_id=e.printer_id,
                 printer_id=e.printer_id,
@@ -78,7 +83,12 @@ async def get_print_log(
                 filament_type=e.filament_type,
                 filament_type=e.filament_type,
                 filament_color=e.filament_color,
                 filament_color=e.filament_color,
                 filament_used_grams=e.filament_used_grams,
                 filament_used_grams=e.filament_used_grams,
+                # failure_reason was silently dropped by the GET serialiser
+                # before #1687 part 4 — without it the Print Log table couldn't
+                # surface what the Failure Analysis widget already groups by.
+                failure_reason=e.failure_reason,
                 thumbnail_path=e.thumbnail_path,
                 thumbnail_path=e.thumbnail_path,
+                created_by_id=e.created_by_id,
                 created_by_username=e.created_by_username,
                 created_by_username=e.created_by_username,
                 created_at=e.created_at,
                 created_at=e.created_at,
             )
             )
@@ -137,3 +147,151 @@ async def clear_print_log(
 
 
     logger.info("Print log cleared: %d entries deleted", deleted)
     logger.info("Print log cleared: %d entries deleted", deleted)
     return {"deleted": deleted}
     return {"deleted": deleted}
+
+
+@router.delete("/{entry_id}")
+async def delete_print_log_entry(
+    entry_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
+):
+    """Delete a single print-log entry (#1687).
+
+    Removes the row entirely. Because /archives/stats aggregates over
+    PrintLogEntry, the deleted row's filament / cost / duration / count
+    contributions drop out of the totals in the same response cycle.
+    The linked archive (if any) is untouched — the FK on the archive row
+    is from PrintLogEntry, not the other way around.
+    """
+    user, can_modify_all = auth_result
+
+    entry = await db.get(PrintLogEntry, entry_id)
+    if not entry:
+        raise HTTPException(404, "Print log entry not found")
+
+    if not can_modify_all:
+        if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
+            raise HTTPException(403, "You can only delete your own print log entries")
+
+    await db.delete(entry)
+    await db.commit()
+
+    logger.info("Print log entry %d deleted", entry_id)
+    return {"status": "deleted", "id": entry_id}
+
+
+# Canonical failure-reason vocabulary. Mirrors the frontend dropdown in
+# EditArchiveModal.tsx; the empty string is the "clear classification" value.
+# The catch-all "other" is the escape hatch for failures that don't fit the
+# enumerated list. Keep these two lists in sync if the EditArchiveModal options
+# ever change.
+_FAILURE_REASON_KEYS = frozenset(
+    {
+        "",
+        "adhesionFailure",
+        "spaghettiDetached",
+        "layerShift",
+        "cloggedNozzle",
+        "filamentRunout",
+        "warping",
+        "stringing",
+        "underExtrusion",
+        "powerFailure",
+        "userCancelled",
+        "other",
+    }
+)
+
+# Same status vocabulary the print-log column already filters by.
+_STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skipped"})
+
+
+@router.patch("/{entry_id}", response_model=PrintLogEntrySchema)
+async def update_print_log_entry(
+    entry_id: int,
+    update: PrintLogEntryUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
+):
+    """Edit a single Print Log row's classification (#1687 part 4, reporter
+    IndividualGhost1905).
+
+    Lets the user set ``failure_reason`` (and optionally re-classify ``status``)
+    directly on a Print Log row — including orphan entries that have no
+    archive to edit through. The Failure Analysis widget already groups by
+    ``PrintLogEntry.failure_reason`` (see ``archives.py:1421`` for the
+    archive-side mirror); this endpoint is the missing edit affordance for the
+    log-side, mirror-less case.
+
+    Ownership semantics mirror the per-row delete: archives:update_all sees
+    everything; archives:update_own sees only rows it owns.
+    """
+    user, can_modify_all = auth_result
+
+    entry = await db.get(PrintLogEntry, entry_id)
+    if not entry:
+        raise HTTPException(404, "Print log entry not found")
+
+    if not can_modify_all:
+        if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
+            raise HTTPException(403, "You can only update your own print log entries")
+
+    payload = update.model_dump(exclude_unset=True)
+
+    # Validate against the canonical vocabularies. Reject unknown values rather
+    # than silently storing them — the Failure Analysis widget renders the
+    # values back as i18n keys, and an unrecognised value would surface as a
+    # raw string in the UI.
+    if "failure_reason" in payload:
+        new_reason = payload["failure_reason"] or ""
+        if new_reason not in _FAILURE_REASON_KEYS:
+            raise HTTPException(400, f"Unknown failure_reason: {new_reason!r}")
+        # Store empty string back as NULL so the column's nullable=True intent
+        # is preserved end-to-end.
+        entry.failure_reason = new_reason or None
+
+    if "status" in payload and payload["status"] is not None:
+        new_status = payload["status"]
+        if new_status not in _STATUS_KEYS:
+            raise HTTPException(400, f"Unknown status: {new_status!r}")
+        entry.status = new_status
+
+    await db.commit()
+    await db.refresh(entry)
+
+    logger.info(
+        "Print log entry %d updated (failure_reason=%r, status=%r)",
+        entry_id,
+        entry.failure_reason,
+        entry.status,
+    )
+
+    return PrintLogEntrySchema(
+        id=entry.id,
+        archive_id=entry.archive_id,
+        print_name=entry.print_name,
+        printer_name=entry.printer_name,
+        printer_id=entry.printer_id,
+        status=entry.status,
+        started_at=entry.started_at,
+        completed_at=entry.completed_at,
+        duration_seconds=entry.duration_seconds,
+        filament_type=entry.filament_type,
+        filament_color=entry.filament_color,
+        filament_used_grams=entry.filament_used_grams,
+        failure_reason=entry.failure_reason,
+        thumbnail_path=entry.thumbnail_path,
+        created_by_id=entry.created_by_id,
+        created_by_username=entry.created_by_username,
+        created_at=entry.created_at,
+    )

+ 30 - 4
backend/app/api/routes/print_queue.py

@@ -16,6 +16,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_owners
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_batch import PrintBatch
@@ -35,7 +36,7 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
-from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
+from backend.app.utils.threemf_tools import extract_bed_type_from_3mf, extract_filament_usage_from_3mf
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -198,6 +199,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "auto_off_after": item.auto_off_after,
         "auto_off_after": item.auto_off_after,
         "manual_start": item.manual_start,
         "manual_start": item.manual_start,
         "filament_short": bool(item.filament_short),
         "filament_short": bool(item.filament_short),
+        "skip_filament_check": bool(item.skip_filament_check),
         "ams_mapping": ams_mapping_parsed,
         "ams_mapping": ams_mapping_parsed,
         "plate_id": item.plate_id,
         "plate_id": item.plate_id,
         "bed_levelling": item.bed_levelling,
         "bed_levelling": item.bed_levelling,
@@ -206,6 +208,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "layer_inspect": item.layer_inspect,
         "layer_inspect": item.layer_inspect,
         "timelapse": item.timelapse,
         "timelapse": item.timelapse,
         "use_ams": item.use_ams,
         "use_ams": item.use_ams,
+        "nozzle_offset_cali": item.nozzle_offset_cali,
         "status": item.status,
         "status": item.status,
         "started_at": item.started_at,
         "started_at": item.started_at,
         "completed_at": item.completed_at,
         "completed_at": item.completed_at,
@@ -243,6 +246,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.layer_height = item.archive.layer_height
             response.layer_height = item.archive.layer_height
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.sliced_for_model = item.archive.sliced_for_model
             response.sliced_for_model = item.archive.sliced_for_model
+            response.bed_type = item.archive.bed_type
             if item.plate_id:
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
                 if archive_path.exists():
@@ -250,10 +254,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
                     plate_weight = sum(
                     plate_weight = sum(
                         f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
                         f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
                     )
                     )
+                    plate_bed = extract_bed_type_from_3mf(archive_path, item.plate_id)
                     if plate_time is not None:
                     if plate_time is not None:
                         response.print_time_seconds = plate_time
                         response.print_time_seconds = plate_time
                     if plate_weight > 0:
                     if plate_weight > 0:
                         response.filament_used_grams = plate_weight
                         response.filament_used_grams = plate_weight
+                    if plate_bed:
+                        response.bed_type = plate_bed
     if item.library_file:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -270,6 +277,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.layer_height = item.library_file.file_metadata.get("layer_height")
             response.layer_height = item.library_file.file_metadata.get("layer_height")
             response.nozzle_diameter = item.library_file.file_metadata.get("nozzle_diameter")
             response.nozzle_diameter = item.library_file.file_metadata.get("nozzle_diameter")
             response.sliced_for_model = item.library_file.file_metadata.get("sliced_for_model")
             response.sliced_for_model = item.library_file.file_metadata.get("sliced_for_model")
+            response.bed_type = item.library_file.file_metadata.get("bed_type")
         if item.plate_id:
         if item.plate_id:
             lib_path = Path(item.library_file.file_path)
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
@@ -278,10 +286,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
                 plate_weight = sum(
                 plate_weight = sum(
                     f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
                     f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
                 )
                 )
+                plate_bed = extract_bed_type_from_3mf(library_file_path, item.plate_id)
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
                 if plate_weight > 0:
                 if plate_weight > 0:
                     response.filament_used_grams = plate_weight
                     response.filament_used_grams = plate_weight
+                if plate_bed:
+                    response.bed_type = plate_bed
     if item.printer:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response
@@ -529,6 +540,7 @@ async def add_to_queue(
             require_previous_success=data.require_previous_success,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
             auto_off_after=data.auto_off_after,
             manual_start=data.manual_start,
             manual_start=data.manual_start,
+            skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             ams_mapping=ams_mapping_json,
             plate_id=data.plate_id,
             plate_id=data.plate_id,
             bed_levelling=data.bed_levelling,
             bed_levelling=data.bed_levelling,
@@ -537,6 +549,7 @@ async def add_to_queue(
             layer_inspect=data.layer_inspect,
             layer_inspect=data.layer_inspect,
             timelapse=data.timelapse,
             timelapse=data.timelapse,
             use_ams=data.use_ams,
             use_ams=data.use_ams,
+            nozzle_offset_cali=data.nozzle_offset_cali,
             gcode_injection=data.gcode_injection,
             gcode_injection=data.gcode_injection,
             project_id=data.project_id,
             project_id=data.project_id,
             position=max_pos + 1 + i,
             position=max_pos + 1 + i,
@@ -961,7 +974,6 @@ async def stop_queue_item(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
 ):
     """Stop an actively printing queue item."""
     """Stop an actively printing queue item."""
-    import asyncio
 
 
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
@@ -1031,7 +1043,7 @@ async def stop_queue_item(
                     logger.info("Auto-off: Powering off printer %s", printer_id)
                     logger.info("Auto-off: Powering off printer %s", printer_id)
                     await tasmota_service.turn_off(plug)
                     await tasmota_service.turn_off(plug)
 
 
-        asyncio.create_task(cooldown_and_poweroff())
+        spawn_background_task(cooldown_and_poweroff(), name=f"queue-cooldown-poweroff-{printer_id}")
 
 
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
 
 
@@ -1041,7 +1053,7 @@ async def start_queue_item(
     item_id: int,
     item_id: int,
     skip_filament_check: bool = Query(default=False),
     skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
 ):
     """Manually start a staged (manual_start) queue item.
     """Manually start a staged (manual_start) queue item.
 
 
@@ -1086,6 +1098,20 @@ async def start_queue_item(
     # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     item.manual_start = False
     item.manual_start = False
     item.filament_short = False
     item.filament_short = False
+    # Persist the user's "Print Anyway" decision so the scheduler does not
+    # immediately re-flag this item on the next tick (#1698-followup). The
+    # pre-fix behaviour bounced between "user said anyway" and
+    # "scheduler re-blocked on same deficit" forever.
+    if skip_filament_check:
+        item.skip_filament_check = True
+    # Credit the clicker as the item's owner when no prior owner is set —
+    # VP-uploaded queue items arrive over FTP unattributed, so without this
+    # the print log's User column stays blank even when auth is on
+    # (#1670). An item that already has a creator (UI-added queue items)
+    # keeps that attribution; the dispatcher is not promoted over the
+    # original uploader.
+    if user is not None and item.created_by_id is None:
+        item.created_by_id = user.id
     await db.commit()
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
     await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
 

+ 20 - 3
backend/app/api/routes/printers.py

@@ -12,6 +12,7 @@ from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, Require
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.models.slot_preset import SlotPresetMapping
@@ -803,12 +804,25 @@ async def diagnose_printer(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Run connection diagnostics for an existing saved printer."""
+    """Run connection diagnostics for an existing saved printer.
+
+    On-demand run from the UI: wait up to PUBLISH_WAIT_DEFAULT seconds for the
+    printer to publish a status report so a fresh reconnect (counter reset to
+    0) isn't reported as `printer_publishing: fail` prematurely. The support
+    package code path calls run_connection_diagnostic without the wait so
+    bundling stays fast.
+    """
+    from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT
+
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
         raise HTTPException(404, "Printer not found")
         raise HTTPException(404, "Printer not found")
-    return await run_connection_diagnostic(printer.ip_address, printer=printer)
+    return await run_connection_diagnostic(
+        printer.ip_address,
+        printer=printer,
+        wait_for_publish_seconds=PUBLISH_WAIT_DEFAULT,
+    )
 
 
 
 
 # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
 # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
@@ -3107,7 +3121,10 @@ async def refresh_ams_slot(
         raise HTTPException(400, message)
         raise HTTPException(400, message)
 
 
     # Apply PA profile after delay (RFID re-read takes a few seconds)
     # Apply PA profile after delay (RFID re-read takes a few seconds)
-    asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))
+    spawn_background_task(
+        _apply_pa_after_refresh(printer_id, ams_id, slot_id),
+        name=f"apply-pa-after-refresh-{printer_id}-{ams_id}-{slot_id}",
+    )
 
 
     return {"success": True, "message": message}
     return {"success": True, "message": message}
 
 

+ 6 - 0
backend/app/api/routes/settings.py

@@ -134,6 +134,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_vibration_cali",
             "default_vibration_cali",
             "default_layer_inspect",
             "default_layer_inspect",
             "default_timelapse",
             "default_timelapse",
+            "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_enabled",
             "ldap_auto_provision",
             "ldap_auto_provision",
         ]:
         ]:
@@ -162,6 +163,11 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             settings_dict[setting.key] = int(setting.value)
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":
         elif setting.key == "default_printer_id":
             settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
             settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
+        elif setting.key == "open_in_slicer":
+            # None means "inherit from preferred_slicer" (#1329). The PUT path
+            # serializes None as the literal string "None"; strip it back so
+            # the frontend sees a true null and falls back as intended.
+            settings_dict[setting.key] = setting.value if setting.value and setting.value != "None" else None
         else:
         else:
             settings_dict[setting.key] = setting.value
             settings_dict[setting.key] = setting.value
 
 

+ 140 - 22
backend/app/api/routes/slicer_presets.py

@@ -21,6 +21,11 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
 from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
+from backend.app.api.routes.orca_cloud import (
+    _ORCA_TYPE_TO_BAMBU,
+    _build_authenticated_service as _build_orca_service,
+    _load_credentials as _load_orca_credentials,
+)
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
@@ -37,6 +42,10 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
 )
 )
+from backend.app.services.orca_cloud import (
+    OrcaCloudAuthError,
+    OrcaCloudError,
+)
 from backend.app.services.slicer_api import (
 from backend.app.services.slicer_api import (
     BundleNotFoundError,
     BundleNotFoundError,
     BundleSummary,
     BundleSummary,
@@ -69,6 +78,9 @@ _bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
 _CLOUD_TTL_S = 300.0
 _CLOUD_TTL_S = 300.0
 _cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
 _cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
 
 
+# Same shape for Orca Cloud — keyed on (user_id, access_token-fingerprint).
+_orca_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
+
 
 
 def _token_fingerprint(token: str) -> str:
 def _token_fingerprint(token: str) -> str:
     """Short stable hash of the cloud token for use as a cache-key component.
     """Short stable hash of the cloud token for use as a cache-key component.
@@ -172,6 +184,101 @@ async def _fetch_cloud_presets(
         await cloud.close()
         await cloud.close()
 
 
 
 
+async def _fetch_orca_cloud_presets(
+    db: AsyncSession, user: User | None, *, refresh: bool = False
+) -> tuple[dict[str, list[UnifiedPreset]], str]:
+    """Mirror of :func:`_fetch_cloud_presets` but for Orca Cloud. Same status
+    vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``),
+    same caching shape, same defence-in-depth permission gate
+    (``orca_cloud:auth`` rather than ``cloud:auth``).
+
+    Filament metadata (``filament_type`` / ``filament_colour``) is extracted
+    from the profile's inline ``content`` dict — unlike Bambu Cloud where
+    we'd have to fetch each setting separately and hit a rate limit, Orca's
+    ``/sync/pull`` already returns full content per profile, so the metadata
+    enrichment is free here.
+    """
+    if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
+        return _empty_slots(), "not_authenticated"
+
+    creds = await _load_orca_credentials(db, user)
+    if not creds.token:
+        return _empty_slots(), "not_authenticated"
+
+    user_key = user.id if user is not None else 0
+    cache_key = (user_key, _token_fingerprint(creds.token))
+    now = time.monotonic()
+    if not refresh:
+        cached = _orca_cloud_cache.get(cache_key)
+        if cached and now - cached[0] < _CLOUD_TTL_S:
+            return cached[1], "ok"
+
+    try:
+        svc = await _build_orca_service(db, user)
+    except HTTPException as e:
+        # ``_build_orca_service`` raises 401 when the token is missing,
+        # the refresh-token rotation failed, or the JIT refresh hit Orca's
+        # backend and got rejected; 502 when Orca is unreachable. Translate
+        # to the status vocabulary the SliceModal expects.
+        if e.status_code == 401:
+            return _empty_slots(), "expired"
+        return _empty_slots(), "unreachable"
+
+    try:
+        try:
+            raw_profiles = await svc.list_profiles()
+        except OrcaCloudAuthError:
+            return _empty_slots(), "expired"
+        except OrcaCloudError as e:
+            logger.warning("Orca Cloud preset fetch failed for user %s: %s", user_key, e)
+            return _empty_slots(), "unreachable"
+        except Exception as e:  # noqa: BLE001 — defensive: never crash the modal
+            logger.warning("Orca Cloud preset fetch unexpected error for user %s: %s", user_key, e)
+            return _empty_slots(), "unreachable"
+
+        slots = _empty_slots()
+        for entry in raw_profiles:
+            content = entry.get("content") if isinstance(entry, dict) else None
+            if not isinstance(content, dict):
+                continue
+            slot = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
+            if slot is None:
+                continue
+            preset_id = entry.get("id")
+            name = entry.get("name") or preset_id
+            if not preset_id or not name:
+                continue
+            filament_type: str | None = None
+            filament_colour: str | None = None
+            if slot == "filament":
+                # Bambu/Orca filament profiles store these as single-element
+                # arrays (the historical multi-extruder shape). Extract the
+                # first non-empty element for both.
+                ft = content.get("filament_type")
+                if isinstance(ft, list) and ft and isinstance(ft[0], str):
+                    filament_type = ft[0]
+                elif isinstance(ft, str):
+                    filament_type = ft
+                fc = content.get("default_filament_colour")
+                if isinstance(fc, list) and fc and isinstance(fc[0], str):
+                    filament_colour = fc[0]
+                elif isinstance(fc, str):
+                    filament_colour = fc
+            slots[slot].append(
+                UnifiedPreset(
+                    id=str(preset_id),
+                    name=str(name),
+                    source="orca_cloud",
+                    filament_type=filament_type,
+                    filament_colour=filament_colour,
+                )
+            )
+        _orca_cloud_cache[cache_key] = (now, slots)
+        return slots, "ok"
+    finally:
+        await svc.close()
+
+
 async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
 async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
     """Local imports — no caching needed, single indexed DB read."""
     """Local imports — no caching needed, single indexed DB read."""
     result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
     result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
@@ -314,6 +421,7 @@ async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
 
 
 
 
 def _dedupe_by_name(
 def _dedupe_by_name(
+    orca_cloud: dict[str, list[UnifiedPreset]],
     cloud: dict[str, list[UnifiedPreset]],
     cloud: dict[str, list[UnifiedPreset]],
     local: dict[str, list[UnifiedPreset]],
     local: dict[str, list[UnifiedPreset]],
     standard: dict[str, list[UnifiedPreset]],
     standard: dict[str, list[UnifiedPreset]],
@@ -321,34 +429,35 @@ def _dedupe_by_name(
     dict[str, list[UnifiedPreset]],
     dict[str, list[UnifiedPreset]],
     dict[str, list[UnifiedPreset]],
     dict[str, list[UnifiedPreset]],
     dict[str, list[UnifiedPreset]],
     dict[str, list[UnifiedPreset]],
+    dict[str, list[UnifiedPreset]],
 ]:
 ]:
-    """Filter so each preset name appears in exactly one tier (cloud > local > standard).
-
-    Order within each tier is preserved as-is — only "lower-priority duplicates"
-    are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
-    public AND standard bundled) only renders once, in the cloud tier.
-
-    Filament metadata is **merged across tiers** during dedup: when a cloud
-    entry wins over a same-named local entry, the cloud entry inherits the
-    local entry's ``filament_type`` and ``filament_colour`` (cloud entries
-    carry no metadata themselves because we deliberately don't fetch each
-    setting's content — see _fetch_cloud_presets). Without this merge, the
-    SliceModal's metadata-aware pre-pick would silently lose match data for
-    every preset the user has both cloud-synced and locally imported, and
-    fall back to plain priority selection.
+    """Filter so each preset name appears in exactly one tier.
+
+    Precedence: ``orca_cloud > cloud > local > standard``. Orca Cloud is
+    highest because a user who set up Orca sync is explicitly curating
+    those profiles for use here; Bambu Cloud follows for the same reason
+    one tier down. Order within each tier is preserved.
+
+    Filament metadata merges across tiers: a Bambu Cloud entry without its
+    own ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
+    these in the list response for rate-limiting reasons — see
+    :func:`_fetch_cloud_presets`) inherits values from the same-named local
+    or standard entry. Orca Cloud already carries metadata inline, so no
+    backfill is needed for it.
     """
     """
-    # Build a lookup: filament name → metadata from the highest-quality tier
-    # that has it. Local + standard both expose parsed metadata; cloud
-    # doesn't. Take whichever non-empty entry shows up first.
+    # Build a name → metadata lookup from the tiers that carry it (orca_cloud,
+    # local, standard). Bambu cloud is intentionally skipped — it doesn't
+    # populate filament_type/colour in the list response. Take whichever
+    # non-empty entry shows up first.
     metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
     metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
-    for tier in (local, standard):
+    for tier in (orca_cloud, local, standard):
         for p in tier["filament"]:
         for p in tier["filament"]:
             if p.name in metadata_by_name:
             if p.name in metadata_by_name:
                 continue
                 continue
             if p.filament_type or p.filament_colour:
             if p.filament_type or p.filament_colour:
                 metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
                 metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
 
 
-    # Backfill cloud entries that don't have their own metadata.
+    # Backfill Bambu Cloud entries that don't have their own metadata.
     for p in cloud["filament"]:
     for p in cloud["filament"]:
         if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
         if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
             t, c = metadata_by_name[p.name]
             t, c = metadata_by_name[p.name]
@@ -357,10 +466,16 @@ def _dedupe_by_name(
             if p.filament_colour is None and c is not None:
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
                 p.filament_colour = c
 
 
+    deduped_cloud = _empty_slots()
     deduped_local = _empty_slots()
     deduped_local = _empty_slots()
     deduped_standard = _empty_slots()
     deduped_standard = _empty_slots()
     for slot in ("printer", "process", "filament"):
     for slot in ("printer", "process", "filament"):
-        seen = {p.name for p in cloud[slot]}
+        seen = {p.name for p in orca_cloud[slot]}
+        for p in cloud[slot]:
+            if p.name in seen:
+                continue
+            deduped_cloud[slot].append(p)
+            seen.add(p.name)
         for p in local[slot]:
         for p in local[slot]:
             if p.name in seen:
             if p.name in seen:
                 continue
                 continue
@@ -371,7 +486,7 @@ def _dedupe_by_name(
                 continue
                 continue
             deduped_standard[slot].append(p)
             deduped_standard[slot].append(p)
             seen.add(p.name)
             seen.add(p.name)
-    return cloud, deduped_local, deduped_standard
+    return orca_cloud, deduped_cloud, deduped_local, deduped_standard
 
 
 
 
 @router.get("/printer-models")
 @router.get("/printer-models")
@@ -421,17 +536,20 @@ async def list_unified_presets(
     too — matching the slice route (#1182 follow-up).
     too — matching the slice route (#1182 follow-up).
     """
     """
     cloud_token_user = current_user or api_key_cloud_owner
     cloud_token_user = current_user or api_key_cloud_owner
+    orca_cloud, orca_cloud_status = await _fetch_orca_cloud_presets(db, cloud_token_user, refresh=refresh)
     cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
     cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
     local = await _fetch_local_presets(db)
     local = await _fetch_local_presets(db)
     standard = await _fetch_bundled_presets(db, refresh=refresh)
     standard = await _fetch_bundled_presets(db, refresh=refresh)
 
 
-    cloud, local, standard = _dedupe_by_name(cloud, local, standard)
+    orca_cloud, cloud, local, standard = _dedupe_by_name(orca_cloud, cloud, local, standard)
 
 
     return UnifiedPresetsResponse(
     return UnifiedPresetsResponse(
+        orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
         cloud=UnifiedPresetsBySlot(**cloud),
         cloud=UnifiedPresetsBySlot(**cloud),
         local=UnifiedPresetsBySlot(**local),
         local=UnifiedPresetsBySlot(**local),
         standard=UnifiedPresetsBySlot(**standard),
         standard=UnifiedPresetsBySlot(**standard),
         cloud_status=cloud_status,
         cloud_status=cloud_status,
+        orca_cloud_status=orca_cloud_status,
     )
     )
 
 
 
 

+ 5 - 2
backend/app/api/routes/smart_plugs.py

@@ -12,6 +12,7 @@ from backend.app.api.routes.settings import get_setting
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -249,14 +250,16 @@ async def start_tasmota_scan(
 
 
     Auto-detects local network if no IP range provided.
     Auto-detects local network if no IP range provided.
     """
     """
-    import asyncio
 
 
     # Auto-detect network
     # Auto-detect network
     from_ip, to_ip = get_local_network_range()
     from_ip, to_ip = get_local_network_range()
     timeout = request.timeout if request else 1.0
     timeout = request.timeout if request else 1.0
 
 
     # Start scan in background
     # Start scan in background
-    asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
+    spawn_background_task(
+        tasmota_scanner.scan_range(from_ip, to_ip, timeout),
+        name="tasmota-scan",
+    )
 
 
     # Return immediate status
     # Return immediate status
     scanned, total = tasmota_scanner.progress
     scanned, total = tasmota_scanner.progress

+ 17 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -865,13 +865,23 @@ async def restore_spool(
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
 
 
 
 
-@router.post("/spools/{spool_id}/reset-usage")
-async def reset_spool_usage(
+@router.post("/spools/{spool_id}/reset-consumed-counter")
+async def reset_spool_consumed_counter(
     spool_id: int = Path(..., gt=0),
     spool_id: int = Path(..., gt=0),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
 ) -> dict:
-    """Zero the spool's used_weight in Spoolman without touching anything else."""
+    """Zero the displayed "Total Consumed" counter for a Spoolman spool.
+
+    Spoolman doesn't have a native "baseline" field, so the implementation
+    reaches for the closest equivalent: PATCH `used_weight=0` upstream.
+    The read mapping in ``_map_spoolman_spool`` then derives Bambuddy's
+    `weight_used = label - remaining_weight` and `baseline = weight_used -
+    real_used_weight`, so the Inventory page's `weight_used - baseline`
+    display lands at 0 while remaining (= label - weight_used) is preserved
+    — parity with the internal-mode endpoint (#1390, see also
+    ``backend/app/api/routes/inventory.py::reset_spool_consumed_counter``).
+    """
     client = await _get_client(db)
     client = await _get_client(db)
     async with _translate_spoolman_errors():
     async with _translate_spoolman_errors():
         spool = await client.reset_spool_usage(spool_id)
         spool = await client.reset_spool_usage(spool_id)
@@ -882,13 +892,13 @@ async def reset_spool_usage(
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
 
 
 
 
-@router.post("/spools/reset-usage-bulk")
-async def bulk_reset_spool_usage(
+@router.post("/spools/reset-consumed-counter-bulk")
+async def bulk_reset_spool_consumed_counter(
     payload: dict = Body(...),
     payload: dict = Body(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
 ) -> dict:
-    """Bulk-reset used_weight to 0 across the given Spoolman spool IDs.
+    """Bulk reset the "Total Consumed" counter across the given Spoolman spool IDs.
 
 
     Caller passes an explicit list of IDs — no "reset all" shortcut, since
     Caller passes an explicit list of IDs — no "reset all" shortcut, since
     a typo on a wildcard would wipe the entire inventory's tracking.
     a typo on a wildcard would wipe the entire inventory's tracking.
@@ -909,7 +919,7 @@ async def bulk_reset_spool_usage(
                 await client.reset_spool_usage(spool_id)
                 await client.reset_spool_usage(spool_id)
             reset_count += 1
             reset_count += 1
         except HTTPException as exc:
         except HTTPException as exc:
-            logger.warning("Spoolman reset-usage failed for spool %s: %s", spool_id, exc.detail)
+            logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
     return {"reset": reset_count}
     return {"reset": reset_count}
 
 
 
 

+ 9 - 1
backend/app/api/routes/system.py

@@ -498,7 +498,15 @@ async def get_system_info(
 
 
     # System info
     # System info
     memory = psutil.virtual_memory()
     memory = psutil.virtual_memory()
-    boot_time = datetime.fromtimestamp(psutil.boot_time())
+    # PID 1's create_time is the right uptime anchor in containerised installs
+    # (Docker, LXC) — psutil.boot_time() reads /proc/stat:btime which on a
+    # shared-kernel container is the host's boot time, not the container's
+    # (#1690). On bare metal / VMs PID 1 is the host init, which starts at
+    # boot, so the value matches psutil.boot_time() within a sub-second.
+    try:
+        boot_time = datetime.fromtimestamp(psutil.Process(1).create_time())
+    except (psutil.Error, OSError):
+        boot_time = datetime.fromtimestamp(psutil.boot_time())
     uptime_seconds = (datetime.now() - boot_time).total_seconds()
     uptime_seconds = (datetime.now() - boot_time).total_seconds()
 
 
     # Python and system info
     # Python and system info

+ 49 - 9
backend/app/api/routes/virtual_printers.py

@@ -74,13 +74,26 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
 
 
 
 
-def _vp_to_dict(vp, status: dict | None = None) -> dict:
-    """Convert VirtualPrinter model to response dict."""
+async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
+    """Convert VirtualPrinter model to response dict.
+
+    In proxy mode the surfaced serial is the target printer's actual serial
+    (what the bridge advertises over SSDP / what slicers see), not the
+    self-generated suffix. Archive / queue / review keep the self-generated
+    serial since those modes never speak the target's identity.
+    """
+    from backend.app.models.printer import Printer
+    from backend.app.models.virtual_printer import VP_MODE_PROXY
     from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
     from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
     from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
     from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
 
 
     model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
     model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
     serial = _get_serial_for_model(model_code, vp.serial_suffix)
     serial = _get_serial_for_model(model_code, vp.serial_suffix)
+    if vp.mode == VP_MODE_PROXY and vp.target_printer_id:
+        result = await db.execute(select(Printer.serial_number).where(Printer.id == vp.target_printer_id))
+        target_serial = result.scalar_one_or_none()
+        if target_serial:
+            serial = target_serial
 
 
     return {
     return {
         "id": vp.id,
         "id": vp.id,
@@ -118,7 +131,7 @@ async def list_virtual_printers(
     for vp in vps:
     for vp in vps:
         instance = virtual_printer_manager.get_instance(vp.id)
         instance = virtual_printer_manager.get_instance(vp.id)
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
-        printers.append(_vp_to_dict(vp, status))
+        printers.append(await _vp_to_dict(vp, db, status))
 
 
     return {
     return {
         "printers": printers,
         "printers": printers,
@@ -154,7 +167,10 @@ async def create_virtual_printer(
     if body.access_code and len(body.access_code) != 8:
     if body.access_code and len(body.access_code) != 8:
         return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
         return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
 
 
-    # Validation when enabling
+    # Validation when enabling. Non-proxy VPs with a target printer derive
+    # their access code from the target (the bridge forwards the slicer's
+    # auth bytes through to the real printer, so the codes MUST match),
+    # so a separately-supplied access_code isn't required in that case.
     if body.enabled:
     if body.enabled:
         if not body.bind_ip:
         if not body.bind_ip:
             return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
             return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
@@ -162,7 +178,7 @@ async def create_virtual_printer(
             if not body.target_printer_id:
             if not body.target_printer_id:
                 return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
                 return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
         else:
         else:
-            if not body.access_code:
+            if not body.access_code and not body.target_printer_id:
                 return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
                 return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
 
 
     # Validate proxy target printer exists
     # Validate proxy target printer exists
@@ -188,6 +204,16 @@ async def create_virtual_printer(
         if result.scalar_one_or_none():
         if result.scalar_one_or_none():
             return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
             return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
 
 
+    # Force-inherit the access code from the target printer for non-proxy VPs.
+    # The non-proxy bridge (Immediate / Review / Queue with a target set) forwards
+    # the slicer's MQTT / RTSPS auth bytes through to the real printer, so any
+    # value the user supplied here would silently break the bridge if it didn't
+    # match the printer's code. The UI now renders the field read-only when a
+    # target is set; this is the belt-and-braces backstop for any non-UI client.
+    effective_access_code = body.access_code
+    if body.mode != "proxy" and target_printer is not None:
+        effective_access_code = target_printer.access_code
+
     # Generate next serial suffix
     # Generate next serial suffix
     result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
     result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
     last_suffix = result.scalar()
     last_suffix = result.scalar()
@@ -212,7 +238,7 @@ async def create_virtual_printer(
         model=body.model
         model=body.model
         or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
         or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
         or DEFAULT_VIRTUAL_PRINTER_MODEL,
         or DEFAULT_VIRTUAL_PRINTER_MODEL,
-        access_code=body.access_code,
+        access_code=effective_access_code,
         target_printer_id=body.target_printer_id,
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
         queue_force_color_match=body.queue_force_color_match,
@@ -234,7 +260,7 @@ async def create_virtual_printer(
         except Exception as e:
         except Exception as e:
             logger.error("Failed to start virtual printer after create: %s", e)
             logger.error("Failed to start virtual printer after create: %s", e)
 
 
-    return _vp_to_dict(vp)
+    return await _vp_to_dict(vp, db)
 
 
 
 
 @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
 @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
@@ -318,7 +344,7 @@ async def get_virtual_printer(
     instance = virtual_printer_manager.get_instance(vp.id)
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
 
 
-    return _vp_to_dict(vp, status)
+    return await _vp_to_dict(vp, db, status)
 
 
 
 
 @router.put("/{vp_id}")
 @router.put("/{vp_id}")
@@ -409,6 +435,20 @@ async def update_virtual_printer(
         if existing_target and existing_target.model:
         if existing_target and existing_target.model:
             vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
             vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
 
 
+    # Force-inherit the access code from the target printer for non-proxy VPs.
+    # See create_virtual_printer for the rationale: the bridge forwards slicer
+    # auth bytes through, so the VP's code MUST equal the target's. This block
+    # runs after every patch (whether or not access_code or target were in the
+    # body), so changing the target also resyncs the code, and an explicit
+    # access_code submitted alongside a target is silently overridden.
+    if vp.mode != "proxy" and vp.target_printer_id is not None:
+        from backend.app.models.printer import Printer as PrinterModelAC
+
+        result = await db.execute(select(PrinterModelAC).where(PrinterModelAC.id == vp.target_printer_id))
+        target_for_ac = result.scalar_one_or_none()
+        if target_for_ac is not None and vp.access_code != target_for_ac.access_code:
+            vp.access_code = target_for_ac.access_code
+
     # Determine final enabled state
     # Determine final enabled state
     explicitly_enabling = body.enabled is True
     explicitly_enabling = body.enabled is True
     new_enabled = body.enabled if body.enabled is not None else vp.enabled
     new_enabled = body.enabled if body.enabled is not None else vp.enabled
@@ -479,7 +519,7 @@ async def update_virtual_printer(
     instance = virtual_printer_manager.get_instance(vp.id)
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
 
 
-    return _vp_to_dict(vp, status)
+    return await _vp_to_dict(vp, db, status)
 
 
 
 
 @router.delete("/{vp_id}")
 @router.delete("/{vp_id}")

+ 6 - 0
backend/app/core/auth.py

@@ -124,6 +124,12 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
     # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
     # when the flag is off (defence-in-depth).
     # when the flag is off (defence-in-depth).
     Permission.CLOUD_AUTH: "can_access_cloud",
     Permission.CLOUD_AUTH: "can_access_cloud",
+    # ORCA_CLOUD_AUTH folds into the same ``can_access_cloud`` scope: same
+    # trust dimension (third-party cloud access for profile sync), so an
+    # operator who already accepted "this key can talk to clouds for the
+    # owner" doesn't need a second toggle for Orca. Splitting later requires
+    # a new column + migration — easy to add if the trust dimensions diverge.
+    Permission.ORCA_CLOUD_AUTH: "can_access_cloud",
 }
 }
 
 
 # Retained for documentation, drift-detection, and the prior "administrative
 # Retained for documentation, drift-detection, and the prior "administrative

+ 1 - 1
backend/app/core/config.py

@@ -6,7 +6,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.2.4.5"
+APP_VERSION = "0.2.4.6"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 

+ 155 - 0
backend/app/core/database.py

@@ -935,6 +935,17 @@ async def run_migrations(conn):
     else:
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
 
 
+    # Migration: skip_filament_check flag on print_queue (#1698-followup).
+    # Persists the user's "Print Anyway" acknowledgement so the scheduler
+    # doesn't re-flag the item every tick after they've confirmed dispatch
+    # despite the deficit warning. Set from the start route's skip_filament_check
+    # query param and from PrintModal at queue-creation time. Postgres / SQLite
+    # boolean default branch matches filament_short above.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler
     # from the 3MF onto the queue item's filament_overrides so the scheduler
@@ -1109,6 +1120,12 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")
+    # Migration: Add nozzle offset calibration option (dual-nozzle printers, #1682).
+    # Postgres rejects `DEFAULT 1` on a BOOLEAN column — use TRUE / 1 per dialect.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT 1")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
 
 
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
     # This allows queue items to reference library files directly (archive created at print start)
     # This allows queue items to reference library files directly (archive created at print start)
@@ -1833,6 +1850,110 @@ async def run_migrations(conn):
             {"old": old_val, "new": new_val},
             {"old": old_val, "new": new_val},
         )
         )
 
 
+    # Migration: Auto-sync VP access codes from their target printer.
+    # Non-proxy VPs with a target printer (the live-mirror bridge) forward the
+    # slicer's MQTT/RTSPS auth bytes through to the real printer, so the VP's
+    # access code MUST equal the target's — earlier UIs let them diverge,
+    # producing a VP that the slicer could bind but whose bridge silently
+    # failed to authenticate against the real printer. The route layer now
+    # auto-inherits on every create/update; this backfill corrects any rows
+    # that pre-date that change. Idempotent (re-running on synced rows is a
+    # no-op because the WHERE clause excludes them). SQLite and Postgres both
+    # accept correlated subqueries in UPDATE — no driver-specific syntax.
+    mismatch_result = await conn.execute(
+        text(
+            "SELECT vp.id AS vp_id, vp.name AS vp_name, p.name AS target_name "
+            "FROM virtual_printers vp "
+            "JOIN printers p ON vp.target_printer_id = p.id "
+            "WHERE vp.mode != 'proxy' "
+            "  AND (vp.access_code IS NULL OR vp.access_code != p.access_code)"
+        )
+    )
+    for row in mismatch_result.fetchall():
+        logger.info(
+            "VP %r (id=%d) access code synced from target printer %r",
+            row.vp_name,
+            row.vp_id,
+            row.target_name,
+        )
+    await conn.execute(
+        text(
+            "UPDATE virtual_printers "
+            "SET access_code = ("
+            "    SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
+            ") "
+            "WHERE virtual_printers.target_printer_id IS NOT NULL "
+            "  AND virtual_printers.mode != 'proxy' "
+            "  AND (virtual_printers.access_code IS NULL OR virtual_printers.access_code != ("
+            "      SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
+            "  ))"
+        )
+    )
+
+    # Migration: Recover queue items that got stuck in `skipped` because of
+    # the cancellation-cascade bug (#1667). Pre-fix, the scheduler's
+    # `_check_previous_success` lookback excluded `cancelled` but included
+    # `skipped`, so a single user-cancelled print poisoned every downstream
+    # item with `require_previous_success=True` indefinitely. The reporter saw
+    # 18 items blocked over 3 days from one cancellation.
+    #
+    # Conservative reversal: ONLY reset rows whose immediate predecessor on
+    # the same printer (by completed_at desc, excluding the skipped-bug
+    # cascade) was `cancelled`. Skipped items whose true predecessor was a
+    # real `failed` or `aborted` print stay skipped — those were legitimate.
+    # Genuine failure-skips share the same status + error_message + completed_at
+    # fingerprint as bug-skips, so the predecessor check is what distinguishes
+    # them. Idempotent (post-reset rows no longer match the WHERE clause).
+    #
+    # Correlated subquery is portable across SQLite and Postgres. The
+    # `error_message` literal matches the exact string the buggy scheduler
+    # wrote — narrowing further on intent.
+    stuck_skipped_result = await conn.execute(
+        text(
+            "SELECT pq.id, pq.printer_id "
+            "FROM print_queue pq "
+            "WHERE pq.status = 'skipped' "
+            "  AND pq.error_message = 'Previous print failed or was aborted' "
+            "  AND pq.completed_at IS NOT NULL "
+            "  AND ("
+            "    SELECT prev.status FROM print_queue prev "
+            "    WHERE prev.printer_id = pq.printer_id "
+            "      AND prev.id != pq.id "
+            "      AND prev.status IN ('completed', 'failed', 'cancelled', 'aborted') "
+            "      AND prev.completed_at IS NOT NULL "
+            "      AND prev.completed_at < pq.completed_at "
+            "    ORDER BY prev.completed_at DESC LIMIT 1"
+            "  ) = 'cancelled'"
+        )
+    )
+    stuck_ids = [row.id for row in stuck_skipped_result.fetchall()]
+    if stuck_ids:
+        logger.info(
+            "Queue cancellation-cascade migration (#1667): resetting %d skipped item(s) to pending",
+            len(stuck_ids),
+        )
+        await conn.execute(
+            text(
+                "UPDATE print_queue "
+                "SET status = 'pending', error_message = NULL, completed_at = NULL "
+                "WHERE id IN ("
+                "  SELECT pq.id FROM print_queue pq "
+                "  WHERE pq.status = 'skipped' "
+                "    AND pq.error_message = 'Previous print failed or was aborted' "
+                "    AND pq.completed_at IS NOT NULL "
+                "    AND ("
+                "      SELECT prev.status FROM print_queue prev "
+                "      WHERE prev.printer_id = pq.printer_id "
+                "        AND prev.id != pq.id "
+                "        AND prev.status IN ('completed', 'failed', 'cancelled', 'aborted') "
+                "        AND prev.completed_at IS NOT NULL "
+                "        AND prev.completed_at < pq.completed_at "
+                "      ORDER BY prev.completed_at DESC LIMIT 1"
+                "    ) = 'cancelled'"
+                ")"
+            )
+        )
+
     # Migration: Unify `LibraryFile.file_type` across ingest paths (#1600).
     # Migration: Unify `LibraryFile.file_type` across ingest paths (#1600).
     # Pre-#1600, only the external-folder scan path stored `gcode.3mf` for
     # Pre-#1600, only the external-folder scan path stored `gcode.3mf` for
     # sliced outputs — the upload, ZIP-extract, and in-process paths all
     # sliced outputs — the upload, ZIP-extract, and in-process paths all
@@ -2040,6 +2161,19 @@ async def run_migrations(conn):
         "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
         "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
     )
     )
 
 
+    # Migration: Add bambuddy_forced_timelapse to print_archives (#1397)
+    # Tracks prints where Bambuddy forced the firmware to record a timelapse
+    # so the finish-photo extractor could pull the post-park-pre-drop frame.
+    # The cleanup path uses this to delete the timelapse both locally and on
+    # the printer's SD after extraction — the user didn't opt in to a
+    # timelapse recording. Postgres rejects `DEFAULT 0` for BOOLEAN; SQLite
+    # accepts both 0/FALSE — branch the literal.
+    _bool_false_literal = "0" if is_sqlite() else "FALSE"
+    await _safe_execute(
+        conn,
+        f"ALTER TABLE print_archives ADD COLUMN bambuddy_forced_timelapse BOOLEAN DEFAULT {_bool_false_literal}",
+    )
+
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # "total consumption" energy mode can compute (last - first) deltas.
     # "total consumption" energy mode can compute (last - first) deltas.
@@ -2755,6 +2889,27 @@ async def run_migrations(conn):
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
         )
         )
 
 
+    # Migration: Add per-user Orca Cloud credential columns. Mirrors the Bambu
+    # Cloud columns but adds refresh_token + expires_at (Supabase PKCE issues
+    # short-lived access tokens with rotating refresh tokens), plus three
+    # transient PKCE state columns held during the auth handshake. DATETIME
+    # is SQLite-only — Postgres uses TIMESTAMP, so the datetime columns are
+    # dialect-branched per project convention.
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_token VARCHAR(2000)")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_refresh_token VARCHAR(128)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_expires_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_expires_at TIMESTAMP")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_email VARCHAR(255)")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_user_id VARCHAR(64)")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_verifier VARCHAR(64)")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_state VARCHAR(32)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
+
     # Data migration: drop the embedded 3MF Title (`print_name`) from library
     # Data migration: drop the embedded 3MF Title (`print_name`) from library
     # file metadata so the FileManager displays the filename, not the title (#1489).
     # file metadata so the FileManager displays the filename, not the title (#1489).
     await _migrate_drop_library_print_name(conn)
     await _migrate_drop_library_print_name(conn)

+ 2 - 0
backend/app/core/permissions.py

@@ -144,6 +144,7 @@ class Permission(StrEnum):
 
 
     # Cloud Auth (admin-level)
     # Cloud Auth (admin-level)
     CLOUD_AUTH = "cloud:auth"
     CLOUD_AUTH = "cloud:auth"
+    ORCA_CLOUD_AUTH = "orca_cloud:auth"
 
 
     # MakerWorld Integration
     # MakerWorld Integration
     MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
     MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
@@ -297,6 +298,7 @@ PERMISSION_CATEGORIES = {
     ],
     ],
     "Cloud": [
     "Cloud": [
         Permission.CLOUD_AUTH,
         Permission.CLOUD_AUTH,
+        Permission.ORCA_CLOUD_AUTH,
     ],
     ],
     "MakerWorld": [
     "MakerWorld": [
         Permission.MAKERWORLD_VIEW,
         Permission.MAKERWORLD_VIEW,

+ 85 - 0
backend/app/core/tasks.py

@@ -0,0 +1,85 @@
+"""Background-task helper that keeps a strong reference to fire-and-forget tasks.
+
+asyncio holds only a weak reference to tasks returned by ``create_task`` --
+when the caller discards the return value (the "fire and forget" pattern),
+the task can be garbage-collected mid-execution and the event loop logs
+``Task was destroyed but it is pending!`` with no traceback. A support
+bundle review under #1648 surfaced 94 such warnings in 8 days of v0.2.4.5.
+
+``spawn_background_task`` is the one place in the codebase that calls
+``asyncio.create_task``. It stores the task in a module-level set, removes
+it when the task completes, and surfaces any uncaught exception through
+the logger so a silently-swallowed error becomes a visible WARNING with
+the originating traceback instead of an opaque GC warning.
+
+Use this for any work that should run in the background without being
+awaited inline. For tasks that the service owns and needs to cancel on
+shutdown, store the returned ``asyncio.Task`` on the service instance
+instead (the helper still adds the strong reference, so storing it twice
+is redundant but harmless).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Coroutine
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# Strong-reference holder. Tasks live here from creation through completion.
+# Module-level so the set survives across spawn calls; the done-callback
+# removes each task as it finishes so the set doesn't grow without bound
+# (the event loop's GC can't reap an entry the callback still holds, but
+# the discard breaks the cycle immediately).
+_background_tasks: set[asyncio.Task[Any]] = set()
+
+
+def spawn_background_task(
+    coro: Coroutine[Any, Any, Any],
+    *,
+    name: str | None = None,
+) -> asyncio.Task[Any]:
+    """Schedule ``coro`` on the running loop without losing the task reference.
+
+    Args:
+        coro: The coroutine to run. Must not already be a Task.
+        name: Optional task name surfaced in /tracebacks and the
+            done-callback log line so a leaked task is traceable to its
+            spawn site.
+
+    Returns:
+        The created ``asyncio.Task``. Most callers ignore it -- the helper
+        keeps its own strong reference. Callers that need to ``await`` or
+        cancel later can store it on a service instance.
+    """
+    task = asyncio.create_task(coro, name=name)
+    _background_tasks.add(task)
+    task.add_done_callback(_on_task_done)
+    return task
+
+
+def _on_task_done(task: asyncio.Task[Any]) -> None:
+    """Discard the strong reference and surface any uncaught exception.
+
+    Without this, an exception raised inside a fire-and-forget task is
+    silently retrieved by ``Task.__del__`` and never reaches the logger.
+    Surface it here as a WARNING with the task name so support bundles
+    capture the originating error instead of an opaque GC notice.
+    """
+    _background_tasks.discard(task)
+    if task.cancelled():
+        return
+    exc = task.exception()
+    if exc is not None:
+        logger.warning(
+            "Background task %r raised an uncaught exception",
+            task.get_name(),
+            exc_info=exc,
+        )
+
+
+def active_task_count() -> int:
+    """Number of background tasks currently in flight. Used by tests."""
+    return len(_background_tasks)

+ 18 - 4
backend/app/data/filament_fields.json

@@ -17,14 +17,28 @@
       "description": "Material type",
       "description": "Material type",
       "options": [
       "options": [
         {"value": "PLA", "label": "PLA"},
         {"value": "PLA", "label": "PLA"},
-        {"value": "ABS", "label": "ABS"},
+        {"value": "PLA-CF", "label": "PLA-CF"},
+        {"value": "PLA-GF", "label": "PLA-GF"},
+        {"value": "PLA-AERO", "label": "PLA Aero"},
         {"value": "PETG", "label": "PETG"},
         {"value": "PETG", "label": "PETG"},
-        {"value": "TPU", "label": "TPU"},
+        {"value": "PETG-CF", "label": "PETG-CF"},
+        {"value": "ABS", "label": "ABS"},
+        {"value": "ABS-GF", "label": "ABS-GF"},
+        {"value": "ASA", "label": "ASA"},
+        {"value": "ASA-CF", "label": "ASA-CF"},
+        {"value": "ASA-GF", "label": "ASA-GF"},
+        {"value": "PC", "label": "PC"},
+        {"value": "PCTG", "label": "PCTG"},
         {"value": "PA", "label": "PA (Nylon)"},
         {"value": "PA", "label": "PA (Nylon)"},
         {"value": "PA-CF", "label": "PA-CF"},
         {"value": "PA-CF", "label": "PA-CF"},
+        {"value": "PAHT-CF", "label": "PAHT-CF"},
+        {"value": "PA6-CF", "label": "PA6-CF"},
+        {"value": "PA6-GF", "label": "PA6-GF"},
         {"value": "PET-CF", "label": "PET-CF"},
         {"value": "PET-CF", "label": "PET-CF"},
-        {"value": "PC", "label": "PC"},
-        {"value": "ASA", "label": "ASA"},
+        {"value": "TPU", "label": "TPU"},
+        {"value": "PPS", "label": "PPS"},
+        {"value": "PPS-CF", "label": "PPS-CF"},
+        {"value": "PPS-GF", "label": "PPS-GF"},
         {"value": "PVA", "label": "PVA"},
         {"value": "PVA", "label": "PVA"},
         {"value": "HIPS", "label": "HIPS"}
         {"value": "HIPS", "label": "HIPS"}
       ]
       ]

+ 386 - 69
backend/app/main.py

@@ -8,6 +8,7 @@ import time
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 from logging.handlers import RotatingFileHandler
+from pathlib import Path
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
 from fastapi import FastAPI
 from fastapi import FastAPI
@@ -45,6 +46,7 @@ from backend.app.api.routes import (
     notification_templates,
     notification_templates,
     notifications,
     notifications,
     obico,
     obico,
+    orca_cloud,
     pending_uploads,
     pending_uploads,
     print_log,
     print_log,
     print_queue,
     print_queue,
@@ -70,6 +72,7 @@ from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
 from backend.app.api.routes.support import init_debug_logging
 from backend.app.api.routes.support import init_debug_logging
 from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.database import async_session, engine, init_db
 from backend.app.core.database import async_session, engine, init_db
+from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
@@ -346,6 +349,13 @@ _expected_prints: dict[tuple[int, str], int] = {}
 # Used by usage tracker to map 3MF slots to physical AMS trays
 # Used by usage tracker to map 3MF slots to physical AMS trays
 _print_ams_mappings: dict[int, list[int]] = {}
 _print_ams_mappings: dict[int, list[int]] = {}
 
 
+# Track plate_id for prints from multi-plate 3MFs: {archive_id: plate_id}
+# Used by usage tracker to scope 3MF parsing to the dispatched plate (#1697).
+# Populated by direct-Print and queue dispatch paths; queue prints also have a
+# redundant queue-item lookup in on_print_start so this dict isn't load-bearing
+# for the queue path. Cleared on print completion or TTL eviction.
+_print_plate_ids: dict[int, int] = {}
+
 # Track progress milestones for notifications: {printer_id: last_milestone_notified}
 # Track progress milestones for notifications: {printer_id: last_milestone_notified}
 # Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
 # Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
 _last_progress_milestone: dict[int, int] = {}
 _last_progress_milestone: dict[int, int] = {}
@@ -564,6 +574,7 @@ def register_expected_print(
     archive_id: int,
     archive_id: int,
     ams_mapping: list[int] | None = None,
     ams_mapping: list[int] | None = None,
     created_by_id: int | None = None,
     created_by_id: int | None = None,
+    plate_id: int | None = None,
 ):
 ):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     # Store with multiple filename variations to catch different naming patterns
     # Store with multiple filename variations to catch different naming patterns
@@ -576,6 +587,11 @@ def register_expected_print(
     # Store AMS mapping for usage tracking at print completion
     # Store AMS mapping for usage tracking at print completion
     if ams_mapping is not None:
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
         _print_ams_mappings[archive_id] = ams_mapping
+    # Store plate_id for usage tracking when this is a single-plate dispatch from
+    # a multi-plate 3MF — without this, the direct-Print path attributes the whole
+    # file's filament total to the spool instead of just the printed plate (#1697).
+    if plate_id is not None:
+        _print_plate_ids[archive_id] = plate_id
     # Store created_by_id so the user start email can be sent even when the archive
     # Store created_by_id so the user start email can be sent even when the archive
     # itself has no created_by_id (e.g. library-file-based queue prints)
     # itself has no created_by_id (e.g. library-file-based queue prints)
     if created_by_id is not None:
     if created_by_id is not None:
@@ -592,7 +608,7 @@ def register_expected_print(
         _expected_print_registered_at[(printer_id, base)] = _registered_at
         _expected_print_registered_at[(printer_id, base)] = _registered_at
         _expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
         _expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
     logging.getLogger(__name__).info(
     logging.getLogger(__name__).info(
-        f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
+        f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}, plate_id={plate_id}"
     )
     )
 
 
 
 
@@ -636,6 +652,19 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
     return stored_ams_mapping
 
 
 
 
+def _get_start_plate_id(archive_id: int | None) -> int | None:
+    """Resolve plate_id for print start without consuming stored direct-Print state.
+
+    Direct-Print of a single plate from a multi-plate 3MF registers plate_id in
+    ``_print_plate_ids`` at dispatch time; this lets the spoolman / usage tracker
+    read it back at print-start without popping (the entry is popped on print
+    completion or TTL eviction, mirroring ``_print_ams_mappings``).
+    """
+    if archive_id is None:
+        return None
+    return _print_plate_ids.get(archive_id)
+
+
 def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
 def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
     """Best-effort filament metadata from the MQTT print-start snapshot.
     """Best-effort filament metadata from the MQTT print-start snapshot.
 
 
@@ -819,9 +848,27 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     # WebSocket dedup / broadcast logic below, and the connected edge is
     # WebSocket dedup / broadcast logic below, and the connected edge is
     # marked True BEFORE the await so concurrent status updates inside
     # marked True BEFORE the await so concurrent status updates inside
     # the same connection don't re-trigger reconciliation.
     # the same connection don't re-trigger reconciliation.
-    if state.connected and not _printer_reconciled_since_connect.get(printer_id, False):
+    #
+    # Wait for a real push_status before reconciling (#1679): MQTT
+    # `_on_connect` broadcasts `state` IMMEDIATELY after the broker accepts
+    # the connection, BEFORE `_request_push_all` round-trips. At that
+    # instant the `PrinterState` is still on construction defaults — most
+    # importantly `state.state == "unknown"` and `state.subtask_name == ""`.
+    # If reconcile spawns here, every in-flight archive falls through to
+    # the empty-subtask_name trigger and gets synthesised `aborted`, which
+    # creates a duplicate archive on the real PRINT COMPLETE and
+    # double-counts filament. Gating on `state.state ∉ ("", "unknown")`
+    # keeps the #1542 mechanism intact: once the first real push_status
+    # updates `state.state` (RUNNING / IDLE / FINISH / …), this handler
+    # fires again with the flag still False — reconcile then runs against
+    # actual evidence.
+    state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")
+    if state.connected and state_known and not _printer_reconciled_since_connect.get(printer_id, False):
         _printer_reconciled_since_connect[printer_id] = True
         _printer_reconciled_since_connect[printer_id] = True
-        asyncio.create_task(reconcile_stale_active_prints(printer_id))
+        spawn_background_task(
+            reconcile_stale_active_prints(printer_id),
+            name=f"reconcile-stale-prints-{printer_id}",
+        )
     elif not state.connected and _printer_reconciled_since_connect.get(printer_id, False):
     elif not state.connected and _printer_reconciled_since_connect.get(printer_id, False):
         # Re-arm so the next reconnect triggers reconciliation again.
         # Re-arm so the next reconnect triggers reconciliation again.
         _printer_reconciled_since_connect[printer_id] = False
         _printer_reconciled_since_connect[printer_id] = False
@@ -2194,14 +2241,21 @@ async def on_print_start(printer_id: int, data: dict):
                 # before expected-print promotion, so it may have ams_mapping=None when
                 # before expected-print promotion, so it may have ams_mapping=None when
                 # the MQTT request topic subscription failed (common on P1S/A1).
                 # the MQTT request topic subscription failed (common on P1S/A1).
                 _stored_map = _print_ams_mappings.get(expected_archive_id)
                 _stored_map = _print_ams_mappings.get(expected_archive_id)
-                if _stored_map:
+                _stored_plate_id = _print_plate_ids.get(expected_archive_id)
+                if _stored_map or _stored_plate_id is not None:
                     try:
                     try:
                         from backend.app.services.usage_tracker import _active_sessions
                         from backend.app.services.usage_tracker import _active_sessions
 
 
                         _ut_session = _active_sessions.get(printer_id)
                         _ut_session = _active_sessions.get(printer_id)
-                        if _ut_session and not _ut_session.ams_mapping:
+                        if _ut_session and _stored_map and not _ut_session.ams_mapping:
                             _ut_session.ams_mapping = _stored_map
                             _ut_session.ams_mapping = _stored_map
                             logger.info("[CALLBACK] Injected ams_mapping into usage tracker session: %s", _stored_map)
                             logger.info("[CALLBACK] Injected ams_mapping into usage tracker session: %s", _stored_map)
+                        # plate_id injection covers direct-Print of plate N of a multi-plate
+                        # 3MF — queue prints already capture it via the on_print_start queue
+                        # lookup, but direct-Print never goes through the queue (#1697).
+                        if _ut_session and _stored_plate_id is not None and _ut_session.plate_id is None:
+                            _ut_session.plate_id = _stored_plate_id
+                            logger.info("[CALLBACK] Injected plate_id into usage tracker session: %s", _stored_plate_id)
                     except Exception:
                     except Exception:
                         pass
                         pass
 
 
@@ -2244,6 +2298,7 @@ async def on_print_start(printer_id: int, data: dict):
                         db,
                         db,
                         printer_manager,
                         printer_manager,
                         ams_mapping=_get_start_ams_mapping(data, archive.id),
                         ams_mapping=_get_start_ams_mapping(data, archive.id),
+                        plate_id=_get_start_plate_id(archive.id),
                     )
                     )
                 except Exception as e:
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
@@ -2777,6 +2832,7 @@ async def on_print_start(printer_id: int, data: dict):
                         db,
                         db,
                         printer_manager,
                         printer_manager,
                         ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),
                         ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),
+                        plate_id=_get_start_plate_id(fallback_archive.id),
                     )
                     )
                 except Exception as e:
                 except Exception as e:
                     logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
                     logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
@@ -2878,6 +2934,7 @@ async def on_print_start(printer_id: int, data: dict):
                         db,
                         db,
                         printer_manager,
                         printer_manager,
                         ams_mapping=_get_start_ams_mapping(data, archive.id),
                         ams_mapping=_get_start_ams_mapping(data, archive.id),
+                        plate_id=_get_start_plate_id(archive.id),
                     )
                     )
                 except Exception as e:
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
@@ -3145,6 +3202,158 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
     logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
     logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
 
 
 
 
+# Defaults for the finish-photo-from-timelapse polling loop (#1397). These are
+# module-level so tests can monkeypatch them down to ~0 without timing out.
+_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS: float = 3.0
+_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS: float = 60.0
+
+
+async def _capture_finish_photo_from_timelapse(
+    archive_id: int,
+    archive_dir: Path,
+) -> str | None:
+    """Wait for the per-print timelapse to land on the archive and extract its
+    last frame as the finish photo (#1397).
+
+    Bambu firmware stops timelapse recording after the toolhead parks but
+    before the bed-drop end-gcode runs, so the last frame frames the finished
+    print correctly. A live camera grab at gcode_state=FINISH captures the
+    bed already lowered.
+
+    ``_scan_for_timelapse_with_retries`` runs in parallel and writes
+    ``archive.timelapse_path`` when the file lands. This function polls for
+    that field. Returns the saved photo filename on success, or None if the
+    timelapse never arrives within the timeout / extraction fails / no
+    timelapse path was set — in which case the caller falls back to the
+    existing live-camera capture chain.
+    """
+    import uuid
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.camera import extract_video_last_frame
+
+    logger = logging.getLogger(__name__)
+
+    deadline = asyncio.get_event_loop().time() + _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
+    poll_interval = _FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS
+
+    while True:
+        async with async_session() as db:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+            archive = result.scalar_one_or_none()
+            timelapse_relpath = archive.timelapse_path if archive else None
+
+        if timelapse_relpath:
+            video_path = app_settings.base_dir / timelapse_relpath
+            if video_path.exists() and video_path.stat().st_size > 0:
+                photos_dir = archive_dir / "photos"
+                photos_dir.mkdir(parents=True, exist_ok=True)
+                timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                output_path = photos_dir / filename
+                if await extract_video_last_frame(video_path, output_path):
+                    logger.info(
+                        "[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s",
+                        video_path.name,
+                        archive_id,
+                    )
+                    return filename
+                logger.warning(
+                    "[PHOTO-BG] Timelapse %s landed but last-frame extraction failed for archive %s; falling back",
+                    video_path.name,
+                    archive_id,
+                )
+                return None
+
+        if asyncio.get_event_loop().time() >= deadline:
+            logger.info(
+                "[PHOTO-BG] Timelapse for archive %s didn't land within %.0fs; falling back to live camera",
+                archive_id,
+                _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
+            )
+            return None
+
+        await asyncio.sleep(poll_interval)
+
+
+async def _cleanup_forced_timelapse(archive_id: int, printer_id: int) -> None:
+    """Delete the timelapse Bambuddy forced on for #1397's finish-photo path.
+
+    Called from the finish-photo background task after the extractor has had
+    its turn (regardless of whether extraction succeeded — the user never
+    asked for a video and we shouldn't leave one behind even if ffmpeg
+    failed). Cleanup is best-effort and never raises: a printer that's
+    offline at cleanup time means a single orphaned file on the SD card,
+    not a broken Bambuddy flow.
+
+    Cleans both:
+      - the locally-attached file (clears archive.timelapse_path)
+      - the printer-side file via FTP DELE
+    """
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import delete_file_async
+
+    logger = logging.getLogger(__name__)
+
+    local_relpath: str | None = None
+    printer = None
+
+    async with async_session() as db:
+        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        archive = archive_result.scalar_one_or_none()
+        if not archive or not archive.bambuddy_forced_timelapse:
+            return
+
+        local_relpath = archive.timelapse_path
+        if local_relpath:
+            local_abspath = app_settings.base_dir / local_relpath
+            try:
+                if local_abspath.exists():
+                    local_abspath.unlink()
+                    logger.info(
+                        "[FORCED-TIMELAPSE] Deleted local timelapse %s for archive %s",
+                        local_relpath,
+                        archive_id,
+                    )
+            except OSError as e:
+                logger.warning("[FORCED-TIMELAPSE] Could not delete local timelapse %s: %s", local_relpath, e)
+            archive.timelapse_path = None
+            await db.commit()
+
+        printer_result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = printer_result.scalar_one_or_none()
+
+    if printer is None or not local_relpath:
+        return
+
+    # _scan_for_timelapse_with_retries used the original filename when it
+    # attached, so the basename of timelapse_path matches the printer-side
+    # filename. Try the directories the scanner walks (#1397).
+    filename = Path(local_relpath).name
+    for remote_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
+        remote_path = f"{remote_dir}/{filename}"
+        try:
+            ok = await delete_file_async(
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                printer_model=printer.model,
+            )
+        except Exception as e:
+            logger.debug("[FORCED-TIMELAPSE] FTP delete attempt failed for %s: %s", remote_path, e)
+            continue
+        if ok:
+            logger.info("[FORCED-TIMELAPSE] Deleted printer-side timelapse %s", remote_path)
+            return
+
+    logger.warning(
+        "[FORCED-TIMELAPSE] Could not delete printer-side timelapse %s for archive %s (file may already be gone)",
+        filename,
+        archive_id,
+    )
+
+
 async def on_print_running_observed(printer_id: int, data: dict):
 async def on_print_running_observed(printer_id: int, data: dict):
     """Restart-recovery: capture a fresh timelapse baseline for a print that
     """Restart-recovery: capture a fresh timelapse baseline for a print that
     started before Bambuddy came up.
     started before Bambuddy came up.
@@ -3213,11 +3422,28 @@ def _is_active_archive_stale(archive, state) -> tuple[bool, str]:
 
 
     Conservative on purpose: PAUSE / PREPARE / SLICING and any RUNNING state
     Conservative on purpose: PAUSE / PREPARE / SLICING and any RUNNING state
     with matching subtask_id+subtask_name is left alone. The cost of a false
     with matching subtask_id+subtask_name is left alone. The cost of a false
-    positive is a single misreported "aborted" status that the next real
-    PRINT COMPLETE would have overwritten with the correct status anyway.
-    The cost of a false negative is the ghost-print loop in #1542.
+    positive is a duplicate archive on the next real PRINT COMPLETE — the
+    reactive handler uses ``_active_prints`` for lookup, which the reconcile
+    clears on synthesis, so the real completion creates a fresh row instead
+    of overwriting the synthesised one (#1679). The cost of a false negative
+    is the ghost-print loop in #1542.
+
+    Pre-push guard (#1679): when ``state.state`` is empty or ``"unknown"``,
+    MQTT has connected but the first ``push_status`` response hasn't been
+    applied yet — ``PrinterState`` is sitting on its construction defaults.
+    The reconcile caller in ``on_printer_status_change`` is already gated
+    on a real ``state.state``, so in normal operation this branch is
+    unreachable; it's kept as belt-and-braces for future callers and for
+    the narrow window where a partial state update could arrive
+    (``state.state`` set but ``subtask_name`` not yet populated). Returning
+    ``not stale`` on degenerate input is strictly conservative: a real
+    stale archive will still be caught by the next push_status arriving
+    with terminal state.
     """
     """
     current_state = (state.state or "").upper()
     current_state = (state.state or "").upper()
+    if current_state in ("", "UNKNOWN"):
+        # No real push_status yet — PrinterState defaults are not evidence.
+        return False, ""
     if current_state in ("IDLE", "FINISH", "FAILED"):
     if current_state in ("IDLE", "FINISH", "FAILED"):
         return True, f"printer state {current_state}"
         return True, f"printer state {current_state}"
     # Below here the printer is in a running / pre-running state (RUNNING /
     # Below here the printer is in a running / pre-running state (RUNNING /
@@ -3685,7 +3911,10 @@ async def on_print_complete(printer_id: int, data: dict):
                                 except Exception as e:
                                 except Exception as e:
                                     logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e)
                                     logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e)
 
 
-                    asyncio.create_task(cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]))
+                    spawn_background_task(
+                        cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]),
+                        name=f"cooldown-poweroff-{printer_id}",
+                    )
     except Exception as e:
     except Exception as e:
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 
 
@@ -3730,6 +3959,12 @@ async def on_print_complete(printer_id: int, data: dict):
     if not stored_ams_mapping and archive_id:
     if not stored_ams_mapping and archive_id:
         stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
         stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
 
 
+    # Always drain the plate_id register on completion — the session already
+    # consumed it at print-start injection; leaving it would leak into the next
+    # print on the same archive_id (rare but possible with reprints) (#1697).
+    if archive_id:
+        _print_plate_ids.pop(archive_id, None)
+
     # Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
     # Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
     try:
     try:
         async with async_session() as db:
         async with async_session() as db:
@@ -3870,8 +4105,7 @@ async def on_print_complete(printer_id: int, data: dict):
             except Exception as e:
             except Exception as e:
                 logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
                 logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
 
 
-        task = asyncio.create_task(_notify_no_archive())
-        task.add_done_callback(lambda _t: None)
+        spawn_background_task(_notify_no_archive(), name="notify-no-archive")
         return
         return
 
 
     log_timing("Archive lookup")
     log_timing("Archive lookup")
@@ -4118,54 +4352,72 @@ async def on_print_complete(printer_id: int, data: dict):
                                 archive_dir = app_settings.archive_dir / str(archive.id)
                                 archive_dir = app_settings.archive_dir / str(archive.id)
                             photo_filename = None
                             photo_filename = None
 
 
-                            # Check for external camera first
-                            if printer.external_camera_enabled and printer.external_camera_url:
-                                logger.info("[PHOTO-BG] Using external camera")
-                                from backend.app.services.external_camera import capture_frame
+                            # Prefer the timelapse last-frame source when a timelapse was
+                            # recording — it captures the moment after the toolhead parks
+                            # but before the bed drops, which the live-camera grab below
+                            # would miss (#1397). Skipped for external cameras (those have
+                            # their own framing and don't see a Bambu timelapse).
+                            prefer_timelapse_source = bool(data.get("timelapse_was_active")) and not (
+                                printer.external_camera_enabled and printer.external_camera_url
+                            )
 
 
-                                frame_data = await capture_frame(
-                                    printer.external_camera_url,
-                                    printer.external_camera_type or "mjpeg",
-                                    snapshot_url=printer.external_camera_snapshot_url,
+                            if prefer_timelapse_source:
+                                photo_filename = await _capture_finish_photo_from_timelapse(
+                                    archive_id=archive_id,
+                                    archive_dir=archive_dir,
                                 )
                                 )
-                                if frame_data:
-                                    photos_dir = archive_dir / "photos"
-                                    photos_dir.mkdir(parents=True, exist_ok=True)
-                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                                    photo_path = photos_dir / photo_filename
-                                    await asyncio.to_thread(photo_path.write_bytes, frame_data)
-                                    logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
-                            else:
-                                # Check if camera stream is active - use buffered frame to avoid freeze
-                                # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
-                                active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
-                                active_chamber_for_printer = [
-                                    k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
-                                ]
-                                buffered_frame = get_buffered_frame(printer_id)
-
-                                if (active_for_printer or active_chamber_for_printer) and buffered_frame:
-                                    # Use frame from active stream
-                                    logger.info("[PHOTO-BG] Using buffered frame from active stream")
-                                    photos_dir = archive_dir / "photos"
-                                    photos_dir.mkdir(parents=True, exist_ok=True)
-                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                                    photo_path = photos_dir / photo_filename
-                                    await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
-                                    logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
-                                else:
-                                    # No active stream - capture new frame
-                                    from backend.app.services.camera import capture_finish_photo
-
-                                    photo_filename = await capture_finish_photo(
-                                        printer_id=printer_id,
-                                        ip_address=printer.ip_address,
-                                        access_code=printer.access_code,
-                                        model=printer.model,
-                                        archive_dir=archive_dir,
+
+                            # Fallback chain: external camera → buffered live frame →
+                            # fresh RTSP capture. Only runs if the timelapse path above
+                            # didn't already produce a photo.
+                            if not photo_filename:
+                                if printer.external_camera_enabled and printer.external_camera_url:
+                                    logger.info("[PHOTO-BG] Using external camera")
+                                    from backend.app.services.external_camera import capture_frame
+
+                                    frame_data = await capture_frame(
+                                        printer.external_camera_url,
+                                        printer.external_camera_type or "mjpeg",
+                                        snapshot_url=printer.external_camera_snapshot_url,
                                     )
                                     )
+                                    if frame_data:
+                                        photos_dir = archive_dir / "photos"
+                                        photos_dir.mkdir(parents=True, exist_ok=True)
+                                        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                        photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                        photo_path = photos_dir / photo_filename
+                                        await asyncio.to_thread(photo_path.write_bytes, frame_data)
+                                        logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
+                                else:
+                                    # Check if camera stream is active - use buffered frame to avoid freeze
+                                    # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
+                                    active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+                                    active_chamber_for_printer = [
+                                        k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
+                                    ]
+                                    buffered_frame = get_buffered_frame(printer_id)
+
+                                    if (active_for_printer or active_chamber_for_printer) and buffered_frame:
+                                        # Use frame from active stream
+                                        logger.info("[PHOTO-BG] Using buffered frame from active stream")
+                                        photos_dir = archive_dir / "photos"
+                                        photos_dir.mkdir(parents=True, exist_ok=True)
+                                        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                        photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                        photo_path = photos_dir / photo_filename
+                                        await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
+                                        logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
+                                    else:
+                                        # No active stream - capture new frame
+                                        from backend.app.services.camera import capture_finish_photo
+
+                                        photo_filename = await capture_finish_photo(
+                                            printer_id=printer_id,
+                                            ip_address=printer.ip_address,
+                                            access_code=printer.access_code,
+                                            model=printer.model,
+                                            archive_dir=archive_dir,
+                                        )
 
 
                             if photo_filename:
                             if photo_filename:
                                 photos = archive.photos or []
                                 photos = archive.photos or []
@@ -4173,15 +4425,27 @@ async def on_print_complete(printer_id: int, data: dict):
                                 archive.photos = photos
                                 archive.photos = photos
                                 await db.commit()
                                 await db.commit()
                                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
                                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
+
+                            # When Bambuddy forced timelapse on for this print, delete
+                            # the timelapse afterward (#1397). The user didn't ask for
+                            # a video to keep — only the finish photo. Runs even when
+                            # photo extraction failed, so we don't leave debris.
+                            if archive.bambuddy_forced_timelapse:
+                                await _cleanup_forced_timelapse(
+                                    archive_id=archive_id,
+                                    printer_id=printer_id,
+                                )
+
+                            if photo_filename:
                                 return photo_filename
                                 return photo_filename
             return None
             return None
         except Exception as e:
         except Exception as e:
             logger.warning("[PHOTO-BG] Failed: %s", e)
             logger.warning("[PHOTO-BG] Failed: %s", e)
             return None
             return None
 
 
-    asyncio.create_task(_background_energy_calculation())
+    spawn_background_task(_background_energy_calculation(), name="background-energy-calc")
     # Photo capture task - result will be used by notifications
     # Photo capture task - result will be used by notifications
-    photo_task = asyncio.create_task(_background_finish_photo())
+    photo_task = spawn_background_task(_background_finish_photo(), name="background-finish-photo")
     log_timing("Background tasks scheduled (energy, photo)")
     log_timing("Background tasks scheduled (energy, photo)")
 
 
     # Also run smart plug, notifications, and maintenance as background tasks
     # Also run smart plug, notifications, and maintenance as background tasks
@@ -4360,18 +4624,27 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
         except Exception as e:
             logger.warning("[MAINT-BG] Failed: %s", e)
             logger.warning("[MAINT-BG] Failed: %s", e)
 
 
-    asyncio.create_task(_background_smart_plug())
-    asyncio.create_task(_background_maintenance_check())
+    spawn_background_task(_background_smart_plug(), name="background-smart-plug")
+    spawn_background_task(_background_maintenance_check(), name="background-maintenance-check")
+
+    # Notification task waits for photo capture to complete first (with timeout).
+    # When a timelapse was recording, photo sourcing polls the per-print
+    # timelapse for up to 60s (#1397) — extend the budget so the notification
+    # carries the correct bed-up photo instead of falling through to the
+    # live-cam grab. Adds ~30s of notification latency at worst on slow links.
+    photo_wait_timeout = 75 if data.get("timelapse_was_active") else 45
 
 
-    # Notification task waits for photo capture to complete first (with timeout)
     async def _photo_then_notify():
     async def _photo_then_notify():
         """Wait for photo capture, then send notification with photo URL."""
         """Wait for photo capture, then send notification with photo URL."""
         finish_photo = None
         finish_photo = None
         try:
         try:
-            finish_photo = await asyncio.wait_for(photo_task, timeout=45)
+            finish_photo = await asyncio.wait_for(photo_task, timeout=photo_wait_timeout)
             logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo)
             logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo)
         except TimeoutError:
         except TimeoutError:
-            logger.warning("[PHOTO-NOTIFY] Photo capture timed out after 45s, sending notification without photo")
+            logger.warning(
+                "[PHOTO-NOTIFY] Photo capture timed out after %ss, sending notification without photo",
+                photo_wait_timeout,
+            )
         except Exception as e:
         except Exception as e:
             logger.warning("[PHOTO-NOTIFY] Photo task failed: %s", e)
             logger.warning("[PHOTO-NOTIFY] Photo task failed: %s", e)
         try:
         try:
@@ -4379,7 +4652,7 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
         except Exception as e:
             logger.error("[PHOTO-NOTIFY] Notification sending failed: %s", e, exc_info=True)
             logger.error("[PHOTO-NOTIFY] Notification sending failed: %s", e, exc_info=True)
 
 
-    asyncio.create_task(_photo_then_notify())
+    spawn_background_task(_photo_then_notify(), name="photo-then-notify")
 
 
     # Stitch external camera layer timelapse if session was active
     # Stitch external camera layer timelapse if session was active
     print_status = data.get("status", "completed")
     print_status = data.get("status", "completed")
@@ -4418,7 +4691,7 @@ async def on_print_complete(printer_id: int, data: dict):
             except Exception:
             except Exception:
                 pass  # Best-effort timelapse session cancellation on error
                 pass  # Best-effort timelapse session cancellation on error
 
 
-    asyncio.create_task(_background_layer_timelapse())
+    spawn_background_task(_background_layer_timelapse(), name="background-layer-timelapse")
 
 
     log_timing("All background tasks scheduled")
     log_timing("All background tasks scheduled")
 
 
@@ -4428,7 +4701,10 @@ async def on_print_complete(printer_id: int, data: dict):
         # Schedule timelapse scan as background task with retries
         # Schedule timelapse scan as background task with retries
         # The printer needs time to encode the video after print completion
         # The printer needs time to encode the video after print completion
         baseline = _timelapse_baselines.pop(printer_id, None)
         baseline = _timelapse_baselines.pop(printer_id, None)
-        asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
+        spawn_background_task(
+            _scan_for_timelapse_with_retries(archive_id, baseline),
+            name=f"scan-timelapse-{archive_id}",
+        )
         log_timing("Timelapse scan scheduled")
         log_timing("Timelapse scan scheduled")
 
 
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
@@ -4444,6 +4720,34 @@ _ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
 
 
 
+def _ams_has_filament(ams_data: dict) -> bool:
+    """True if this AMS unit has at least one tray slot holding filament.
+
+    Bambu firmware reports loaded slots via `tray_exist_bits`, a per-AMS hex
+    bitmap (one bit per tray slot — bit set = spool present). Empty AMS units
+    still report sensor readings, but those readings are ambient and not
+    actionable: no filament to dry, no humidity to push down. #1619 — gate
+    humidity/temperature alarms on this check so empty units don't generate
+    hourly noise. Sensor history still records regardless so the UI charts
+    stay continuous.
+
+    Fallback path inspects the `tray` array's `tray_type` fields for setups
+    where `tray_exist_bits` is missing (some early-connection pushall shapes).
+    """
+    bits = ams_data.get("tray_exist_bits")
+    if isinstance(bits, str) and bits.strip():
+        try:
+            return int(bits, 16) > 0
+        except ValueError:
+            pass
+    trays = ams_data.get("tray")
+    if isinstance(trays, list):
+        return any(
+            isinstance(t, dict) and isinstance(t.get("tray_type"), str) and t["tray_type"].strip() for t in trays
+        )
+    return False
+
+
 async def record_ams_history():
 async def record_ams_history():
     """Background task to record AMS humidity and temperature data."""
     """Background task to record AMS humidity and temperature data."""
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
@@ -4541,6 +4845,16 @@ async def record_ams_history():
                         else:
                         else:
                             ams_label = f"AMS-{chr(65 + ams_id)}"
                             ams_label = f"AMS-{chr(65 + ams_id)}"
 
 
+                        # Skip alarm dispatch for empty AMS units — humidity /
+                        # temperature readings are ambient with no filament to
+                        # protect, and the hourly notification just becomes
+                        # noise. Sensor history was already recorded above so
+                        # the UI charts stay continuous (#1619). Per-AMS check
+                        # so a multi-AMS setup with one loaded + one empty
+                        # still alarms on the loaded unit.
+                        if not _ams_has_filament(ams_data):
+                            continue
+
                         # Check humidity alarm (only if above threshold)
                         # Check humidity alarm (only if above threshold)
                         if humidity is not None and humidity > humidity_threshold:
                         if humidity is not None and humidity > humidity_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
@@ -4854,12 +5168,14 @@ def _evict_stale_expected_prints() -> None:
         _expected_print_creators.pop(key, None)
         _expected_print_creators.pop(key, None)
         _expected_print_registered_at.pop(key, None)
         _expected_print_registered_at.pop(key, None)
 
 
-    # Also clean up _print_ams_mappings for archive_ids that have no remaining
-    # live keys in _expected_prints (i.e. all variants were just evicted).
+    # Also clean up _print_ams_mappings and _print_plate_ids for archive_ids
+    # that have no remaining live keys in _expected_prints (all variants
+    # were just evicted).
     live_archive_ids = set(_expected_prints.values())
     live_archive_ids = set(_expected_prints.values())
     for archive_id in evicted_archive_ids:
     for archive_id in evicted_archive_ids:
         if archive_id not in live_archive_ids:
         if archive_id not in live_archive_ids:
             _print_ams_mappings.pop(archive_id, None)
             _print_ams_mappings.pop(archive_id, None)
+            _print_plate_ids.pop(archive_id, None)
 
 
     logging.getLogger(__name__).info(
     logging.getLogger(__name__).info(
         "Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
         "Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
@@ -5211,7 +5527,7 @@ async def lifespan(app: FastAPI):
                 logging.warning("Failed to auto-connect to Spoolman: %s", e)
                 logging.warning("Failed to auto-connect to Spoolman: %s", e)
 
 
     # Start the print scheduler
     # Start the print scheduler
-    asyncio.create_task(print_scheduler.run())
+    spawn_background_task(print_scheduler.run(), name="print-scheduler")
 
 
     # Start background dispatch worker for send/start operations
     # Start background dispatch worker for send/start operations
     await background_dispatch.start()
     await background_dispatch.start()
@@ -5739,6 +6055,7 @@ app.include_router(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(labels.router, prefix=app_settings.api_prefix)
 app.include_router(labels.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
+app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)

+ 6 - 0
backend/app/models/archive.py

@@ -20,6 +20,12 @@ class PrintArchive(Base):
     content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
+    # True when Bambuddy forced timelapse recording on for this print so the
+    # finish-photo extractor (#1397) could pull the post-park-pre-drop frame.
+    # The cleanup path uses this to know the timelapse should be deleted
+    # both locally and on the printer's SD after extraction — the user
+    # didn't opt in to a timelapse recording.
+    bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
     source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
     source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
     f3d_path: Mapped[str | None] = mapped_column(String(500))  # Fusion 360 design file
     f3d_path: Mapped[str | None] = mapped_column(String(500))  # Fusion 360 design file
 
 

+ 10 - 0
backend/app/models/print_queue.py

@@ -72,6 +72,8 @@ class PrintQueueItem(Base):
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
+    # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
+    nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
 
 
     # Status: pending, printing, completed, failed, skipped, cancelled
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
     status: Mapped[str] = mapped_column(String(20), default="pending")
@@ -83,6 +85,14 @@ class PrintQueueItem(Base):
     # block automatically.
     # block automatically.
     filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
     filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
 
 
+    # User has acknowledged the filament-shortage warning for this item
+    # ("Print Anyway"). Set by the start route when the user passes
+    # skip_filament_check=true, or at queue-creation time if PrintModal's
+    # frontend deficit warning was acknowledged. Survives scheduler ticks so
+    # the dispatch no longer bounces between "user said anyway" and
+    # "scheduler re-flagged" (#1698-followup).
+    skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Tracking
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 14 - 0
backend/app/models/user.py

@@ -45,6 +45,20 @@ class User(Base):
     # "global" or "china"; NULL treated as "global" for legacy rows.
     # "global" or "china"; NULL treated as "global" for legacy rows.
     cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
     cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
 
 
+    # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
+    # with short-lived access tokens (1h) and rotating single-use refresh tokens,
+    # so we store the refresh token + expiry alongside the access token.
+    orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None)
+    orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
+    orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
+    orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
+    orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+    # Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish.
+    # Cleared on successful finish; expires after 10 minutes if the user abandons the flow.
+    orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+    orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
+    orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
+
     # Relationship to groups through association table
     # Relationship to groups through association table
     groups: Mapped[list[Group]] = relationship(
     groups: Mapped[list[Group]] = relationship(
         "Group",
         "Group",

+ 1 - 0
backend/app/schemas/archive.py

@@ -240,3 +240,4 @@ class ReprintRequest(BaseModel):
     layer_inspect: bool = False
     layer_inspect: bool = False
     timelapse: bool = False
     timelapse: bool = False
     use_ams: bool = True  # Not exposed in UI, but needed for API
     use_ams: bool = True  # Not exposed in UI, but needed for API
+    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)

+ 1 - 0
backend/app/schemas/library.py

@@ -208,6 +208,7 @@ class FilePrintRequest(BaseModel):
     layer_inspect: bool = False
     layer_inspect: bool = False
     timelapse: bool = False
     timelapse: bool = False
     use_ams: bool = True
     use_ams: bool = True
+    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)
     # Project to associate the resulting archive with
     # Project to associate the resulting archive with
     project_id: int | None = None
     project_id: int | None = None
     # When true, delete the LibraryFile row + disk file after the archive has
     # When true, delete the LibraryFile row + disk file after the archive has

+ 94 - 0
backend/app/schemas/orca_cloud.py

@@ -0,0 +1,94 @@
+"""Schemas for Orca Cloud auth + profile sync endpoints."""
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+# The three OAuth providers Orca's sign-in surface offers. Supabase
+# accepts the bare lowercase provider name in the authorize query string.
+OrcaOAuthProvider = Literal["google", "apple", "github"]
+
+
+class OrcaAuthStartRequest(BaseModel):
+    """Body for ``POST /orca-cloud/auth/start``. Provider defaults to
+    ``google`` so existing clients that send an empty body keep working."""
+
+    provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in")
+
+
+class OrcaAuthStartResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/auth/start``. The frontend opens
+    ``auth_url`` in a new tab. After the user signs in to Orca, they copy the
+    redirected URL from their address bar and POST it to
+    ``/orca-cloud/auth/finish`` to complete the handshake."""
+
+    auth_url: str = Field(..., description="URL to open for Orca Cloud sign-in")
+
+
+class OrcaAuthFinishRequest(BaseModel):
+    """Submitted by the frontend after the user pastes the callback URL from
+    their browser. The URL contains a Supabase ``code`` (and our ``state``)
+    that we exchange for tokens."""
+
+    callback_url: str = Field(..., description="The full URL the browser was redirected to after sign-in")
+
+
+class OrcaAuthPasswordRequest(BaseModel):
+    """Body for ``POST /orca-cloud/auth/password``. Whether this succeeds
+    depends on Orca's Supabase project — their desktop client refuses
+    password payloads, but the web sign-in offers email+password as one
+    option. We forward the credentials and surface the server's response.
+    ``email`` is plain ``str`` rather than Pydantic's ``EmailStr`` to avoid
+    pulling in the optional ``email-validator`` dependency — Supabase will
+    reject malformed addresses with a clear error itself, and the existing
+    Bambu Cloud login schema uses the same approach."""
+
+    email: str = Field(..., min_length=1)
+    password: str = Field(..., min_length=1)
+
+
+class OrcaAuthStatusResponse(BaseModel):
+    """Connection status for the Orca Cloud tab."""
+
+    connected: bool
+    email: str | None = None
+    user_id: str | None = None
+
+
+class OrcaProfileMeta(BaseModel):
+    """A single profile, shaped to match the Bambu Cloud ``SlicerSetting``
+    schema so the frontend can render Orca profiles with the existing
+    Bambu Cloud visual components (cards, filter bar, grouping). Per-source
+    differences (Orca's IDs are UUIDs not Bambu's ``PFU...`` prefix; Orca
+    types are ``machine`` / ``process`` / ``filament`` whereas Bambu uses
+    ``printer`` / ``process`` / ``filament``) are normalized at the route
+    layer before the response leaves the backend."""
+
+    setting_id: str
+    name: str
+    type: str
+    version: str | None = None
+    user_id: str | None = None
+    updated_time: str | None = None
+    is_custom: bool = True
+
+
+class OrcaProfileListResponse(BaseModel):
+    """Groups Orca profiles by type, matching ``SlicerSettingsResponse``."""
+
+    filament: list[OrcaProfileMeta] = []
+    printer: list[OrcaProfileMeta] = []
+    process: list[OrcaProfileMeta] = []
+
+
+class OrcaProfileDetail(BaseModel):
+    """Single profile's full content, shaped to match ``SlicerSettingDetail``
+    so the frontend's detail modal can render it without translation."""
+
+    setting_id: str
+    name: str
+    type: str
+    version: str | None = None
+    base_id: str | None = None
+    update_time: str | None = None
+    setting: dict

+ 13 - 0
backend/app/schemas/print_log.py

@@ -29,3 +29,16 @@ class PrintLogEntrySchema(BaseModel):
 class PrintLogResponse(BaseModel):
 class PrintLogResponse(BaseModel):
     items: list[PrintLogEntrySchema]
     items: list[PrintLogEntrySchema]
     total: int
     total: int
+
+
+class PrintLogEntryUpdate(BaseModel):
+    """Per-row classification edits (#1687 part 4 — IndividualGhost1905).
+
+    Lets the user set ``failure_reason`` (and re-classify ``status``) directly
+    on a Print Log row, including on orphan entries that have no archive to
+    edit through. The Failure Analysis widget already groups by
+    ``PrintLogEntry.failure_reason``, so this just plugs the editor gap.
+    """
+
+    failure_reason: str | None = None
+    status: str | None = None

+ 19 - 0
backend/app/schemas/print_queue.py

@@ -28,6 +28,10 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
+    # PrintModal already showed the deficit warning and the user confirmed,
+    # so the scheduler does not re-flag this item on the next tick.
+    skip_filament_check: bool = False
     # AMS mapping: list of global tray IDs for each filament slot
     # AMS mapping: list of global tray IDs for each filament slot
     # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
     # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
     ams_mapping: list[int] | None = None
     ams_mapping: list[int] | None = None
@@ -40,6 +44,10 @@ class PrintQueueItemCreate(BaseModel):
     layer_inspect: bool = False
     layer_inspect: bool = False
     timelapse: bool = False
     timelapse: bool = False
     use_ams: bool = True
     use_ams: bool = True
+    # Nozzle offset calibration — dual-nozzle printers only (#1682). Default True
+    # matches BambuStudio's default; the MQTT layer ignores the flag on
+    # single-nozzle printers so the wire value stays "skip" there.
+    nozzle_offset_cali: bool = True
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool = False
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
     # Batch: create multiple copies (creates a batch if > 1)
@@ -67,6 +75,7 @@ class PrintQueueItemUpdate(BaseModel):
     layer_inspect: bool | None = None
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
     use_ams: bool | None = None
+    nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
 
 
@@ -91,6 +100,9 @@ class PrintQueueItemResponse(BaseModel):
     # (#1496). Display-only — the ▶ click recomputes deficit against live
     # (#1496). Display-only — the ▶ click recomputes deficit against live
     # spool state.
     # spool state.
     filament_short: bool = False
     filament_short: bool = False
+    # User has acknowledged "Print Anyway" — scheduler skips the deficit check
+    # for this item (#1698-followup).
+    skip_filament_check: bool = False
     ams_mapping: list[int] | None = None
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options
     # Print options
@@ -100,6 +112,7 @@ class PrintQueueItemResponse(BaseModel):
     layer_inspect: bool = False
     layer_inspect: bool = False
     timelapse: bool = False
     timelapse: bool = False
     use_ams: bool = True
     use_ams: bool = True
+    nozzle_offset_cali: bool = True
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     started_at: UTCDatetime
     completed_at: UTCDatetime
     completed_at: UTCDatetime
@@ -126,6 +139,11 @@ class PrintQueueItemResponse(BaseModel):
     layer_height: float | None = None  # e.g. 0.2 (from archive/library file)
     layer_height: float | None = None  # e.g. 0.2 (from archive/library file)
     nozzle_diameter: float | None = None  # e.g. 0.4 (from archive/library file)
     nozzle_diameter: float | None = None  # e.g. 0.4 (from archive/library file)
     sliced_for_model: str | None = None  # e.g. "P1S" (from archive/library file)
     sliced_for_model: str | None = None  # e.g. "P1S" (from archive/library file)
+    # Build plate type (e.g. "Textured PEI Plate") so the user knows which
+    # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
+    # 3MFs: when `plate_id` is set, the value is the matching plate's
+    # `curr_bed_type` rather than the archive-level first-plate default.
+    bed_type: str | None = None
 
 
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: int | None = None
     created_by_id: int | None = None
@@ -171,6 +189,7 @@ class PrintQueueBulkUpdate(BaseModel):
     layer_inspect: bool | None = None
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
     use_ams: bool | None = None
+    nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
 
 

+ 26 - 3
backend/app/schemas/settings.py

@@ -9,7 +9,13 @@ class AppSettings(BaseModel):
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
     capture_finish_photo: bool = Field(
     capture_finish_photo: bool = Field(
-        default=True, description="Capture photo from printer camera when print completes"
+        default=True,
+        description=(
+            "Capture photo from printer camera when print completes. Bambuddy records a "
+            "brief timelapse during the print so the photo can be sourced from the moment "
+            "before the bed drops; the timelapse file is kept if you enabled timelapse for "
+            "this print, otherwise it is deleted automatically after the photo is captured."
+        ),
     )
     )
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")
     currency: str = Field(default="USD", description="Currency for cost tracking")
@@ -181,10 +187,21 @@ class AppSettings(BaseModel):
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
     )
     )
 
 
-    # Preferred slicer application
+    # Preferred slicer application (server-side / API sidecar slicer)
     preferred_slicer: str = Field(
     preferred_slicer: str = Field(
         default="bambu_studio",
         default="bambu_studio",
-        description="Preferred slicer: 'bambu_studio' or 'orcaslicer'",
+        description="Slicer used for the server-side API / sidecar: 'bambu_studio' or 'orcaslicer'",
+    )
+    # "Open in Slicer" desktop URI handler — independent of the API slicer so
+    # a user can slice via the Bambu Studio sidecar but open files locally in
+    # OrcaSlicer, or vice versa (#1329). None falls back to ``preferred_slicer``
+    # so existing installs behave identically until someone changes it.
+    open_in_slicer: str | None = Field(
+        default=None,
+        description=(
+            "Desktop slicer for the 'Open in Slicer' button: 'bambu_studio' or "
+            "'orcaslicer'. None inherits from preferred_slicer."
+        ),
     )
     )
 
 
     # Slicer dispatch mode: when True, "Slice" actions open the in-app
     # Slicer dispatch mode: when True, "Slice" actions open the in-app
@@ -239,6 +256,10 @@ class AppSettings(BaseModel):
         default=False, description="Default first layer inspection option for new prints"
         default=False, description="Default first layer inspection option for new prints"
     )
     )
     default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
     default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
+    default_nozzle_offset_cali: bool = Field(
+        default=True,
+        description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
+    )
 
 
     # Staggered batch start for multi-printer jobs
     # Staggered batch start for multi-printer jobs
     stagger_group_size: int = Field(
     stagger_group_size: int = Field(
@@ -386,6 +407,7 @@ class AppSettingsUpdate(BaseModel):
     library_disk_warning_gb: float | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     camera_view_mode: str | None = None
     preferred_slicer: str | None = None
     preferred_slicer: str | None = None
+    open_in_slicer: str | None = None
     use_slicer_api: bool | None = None
     use_slicer_api: bool | None = None
     orcaslicer_api_url: str | None = None
     orcaslicer_api_url: str | None = None
     bambu_studio_api_url: str | None = None
     bambu_studio_api_url: str | None = None
@@ -398,6 +420,7 @@ class AppSettingsUpdate(BaseModel):
     default_vibration_cali: bool | None = None
     default_vibration_cali: bool | None = None
     default_layer_inspect: bool | None = None
     default_layer_inspect: bool | None = None
     default_timelapse: bool | None = None
     default_timelapse: bool | None = None
+    default_nozzle_offset_cali: bool | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     require_plate_clear: bool | None = None

+ 12 - 5
backend/app/schemas/slicer.py

@@ -8,13 +8,20 @@ from pydantic import BaseModel, Field, model_validator
 class PresetRef(BaseModel):
 class PresetRef(BaseModel):
     """A source-aware reference to a printer / process / filament preset.
     """A source-aware reference to a printer / process / filament preset.
 
 
-    The SliceModal pulls dropdown options from three tiers (cloud / local /
-    standard). At submit time the client sends one of these per slot so the
-    backend knows where to fetch the preset content from at slice time.
+    The SliceModal pulls dropdown options from four tiers (orca_cloud /
+    cloud / local / standard). At submit time the client sends one of these
+    per slot so the backend knows where to fetch the preset content from at
+    slice time. ``cloud`` is Bambu Cloud (kept as the bare name for backward
+    compatibility with existing requests); ``orca_cloud`` is Orca Cloud.
     """
     """
 
 
-    source: Literal["cloud", "local", "standard"]
-    id: str = Field(..., description=("Cloud setting_id, local DB row id (stringified), or standard preset name."))
+    source: Literal["orca_cloud", "cloud", "local", "standard"]
+    id: str = Field(
+        ...,
+        description=(
+            "Orca Cloud profile id, Bambu Cloud setting_id, local DB row id (stringified), or standard preset name."
+        ),
+    )
 
 
 
 
 class SliceBundleSpec(BaseModel):
 class SliceBundleSpec(BaseModel):

+ 12 - 8
backend/app/schemas/slicer_presets.py

@@ -44,7 +44,7 @@ class UnifiedPreset(BaseModel):
 
 
     id: str
     id: str
     name: str
     name: str
-    source: Literal["cloud", "local", "standard"]
+    source: Literal["orca_cloud", "cloud", "local", "standard"]
     filament_type: str | None = None
     filament_type: str | None = None
     filament_colour: str | None = None
     filament_colour: str | None = None
     compatible_printers: list[str] | None = None
     compatible_printers: list[str] | None = None
@@ -61,17 +61,21 @@ class UnifiedPresetsBySlot(BaseModel):
 class UnifiedPresetsResponse(BaseModel):
 class UnifiedPresetsResponse(BaseModel):
     """Each tier carries only the names that didn't appear in a higher tier.
     """Each tier carries only the names that didn't appear in a higher tier.
 
 
-    Cloud is the highest priority (user's personal customisations win), then
-    the local imports the user explicitly curated, then the slicer's stock
-    fallback. A name that appears in cloud is filtered out of local and
-    standard; a name that appears in local is filtered out of standard.
+    Priority order: ``orca_cloud > cloud > local > standard``. Orca Cloud is
+    highest because it's the most-recently-explicitly-curated source for
+    users who set up Orca sync (they did it on purpose; their Orca picks
+    should outrank everything else). Bambu Cloud follows as the next-most-
+    curated tier. Local imports beat the slicer's stock fallback.
 
 
-    ``cloud_status`` lets the frontend show a banner explaining why the cloud
-    tier is empty when the user expected to see it (signed out / token
-    expired / network down).
+    ``cloud_status`` / ``orca_cloud_status`` let the frontend show a banner
+    explaining why a cloud tier is empty when the user expected to see it
+    (signed out / token expired / network down). Each tier has its own
+    status because they can fail independently.
     """
     """
 
 
+    orca_cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     local: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     local: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     cloud_status: CloudStatus = "ok"
     cloud_status: CloudStatus = "ok"
+    orca_cloud_status: CloudStatus = "ok"

+ 15 - 2
backend/app/services/archive.py

@@ -1,4 +1,5 @@
 import hashlib
 import hashlib
+import html
 import json
 import json
 import logging
 import logging
 import os
 import os
@@ -13,6 +14,7 @@ from sqlalchemy import and_, or_, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -505,9 +507,20 @@ class ThreeMFParser:
             metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
             metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
             matches = re.findall(metadata_pattern, content)
             matches = re.findall(metadata_pattern, content)
 
 
+            # 3MF metadata values are XML-encoded — `&` becomes `&amp;`, etc.
+            # ProjectPageParser learned this the hard way: BambuStudio sometimes
+            # writes triple-encoded payloads (`&amp;amp;amp;`), so we unescape
+            # in a loop until the string stabilises. Without this, a Title like
+            # "Foo & Bar" lands in the DB as raw "Foo &amp; Bar" and React then
+            # double-escapes it on render to "Foo &amp;amp; Bar" (#1658).
             makerworld_fields = {}
             makerworld_fields = {}
             for name, value in matches:
             for name, value in matches:
-                makerworld_fields[name] = value.strip()
+                decoded = value.strip()
+                prev = None
+                while prev != decoded:
+                    prev = decoded
+                    decoded = html.unescape(decoded)
+                makerworld_fields[name] = decoded
 
 
             # Check for direct MakerWorld URL in content
             # Check for direct MakerWorld URL in content
             url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'
             url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'
@@ -1511,7 +1524,7 @@ class ArchiveService:
 
 
         # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
         # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
         if not filename.lower().endswith(".mp4"):
         if not filename.lower().endswith(".mp4"):
-            asyncio.create_task(
+            spawn_background_task(
                 _convert_timelapse_to_mp4(archive_id, timelapse_file),
                 _convert_timelapse_to_mp4(archive_id, timelapse_file),
                 name=f"timelapse-convert-{archive_id}",
                 name=f"timelapse-convert-{archive_id}",
             )
             )

+ 76 - 8
backend/app/services/background_dispatch.py

@@ -20,6 +20,7 @@ from sqlalchemy import select
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
+from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -492,6 +493,17 @@ class BackgroundDispatchService:
         if self._is_cancel_requested(job.id):
         if self._is_cancel_requested(job.id):
             raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
             raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
 
 
+    async def _resolve_effective_timelapse(self, db, archive, job: PrintDispatchJob) -> bool:
+        """Dispatch-flow wrapper around the shared resolver (#1397).
+
+        Returns the effective value to pass to ``start_print(timelapse=...)``.
+        """
+        return await resolve_effective_timelapse(
+            db,
+            archive,
+            user_wanted_timelapse=bool(job.options.get("timelapse", False)),
+        )
+
     def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
     def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
         processing = len(self._active_jobs)
         processing = len(self._active_jobs)
         dispatched = len(self._queued_jobs)
         dispatched = len(self._queued_jobs)
@@ -618,7 +630,10 @@ class BackgroundDispatchService:
                         progress_state["last_emit"] = now
                         progress_state["last_emit"] = now
                         progress_state["last_bytes"] = uploaded
                         progress_state["last_bytes"] = uploaded
                         loop.call_soon_threadsafe(
                         loop.call_soon_threadsafe(
-                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
+                            lambda u=uploaded, t=total: spawn_background_task(
+                                self._set_active_upload_progress(job, u, t),
+                                name=f"upload-progress-{job.id}",
+                            )
                         )
                         )
 
 
                 if ftp_retry_enabled:
                 if ftp_retry_enabled:
@@ -655,29 +670,36 @@ class BackgroundDispatchService:
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                     )
                     )
 
 
+                # Resolve plate_id before register so usage tracking can scope the
+                # 3MF parse to the dispatched plate at print-start (#1697). Pure
+                # transform of file_path + options, safe to reorder.
+                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
+
                 register_expected_print(
                 register_expected_print(
                     job.printer_id,
                     job.printer_id,
                     remote_filename,
                     remote_filename,
                     job.source_id,
                     job.source_id,
                     ams_mapping=job.options.get("ams_mapping"),
                     ams_mapping=job.options.get("ams_mapping"),
+                    plate_id=plate_id,
                 )
                 )
 
 
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
                 self._raise_if_cancel_requested(job)
                 self._raise_if_cancel_requested(job)
 
 
+                effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
+
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 started = printer_manager.start_print(
                 started = printer_manager.start_print(
                     job.printer_id,
                     job.printer_id,
                     remote_filename,
                     remote_filename,
                     plate_id,
                     plate_id,
                     ams_mapping=job.options.get("ams_mapping"),
                     ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=job.options.get("timelapse", False),
+                    timelapse=effective_timelapse,
                     bed_levelling=job.options.get("bed_levelling", True),
                     bed_levelling=job.options.get("bed_levelling", True),
                     flow_cali=job.options.get("flow_cali", False),
                     flow_cali=job.options.get("flow_cali", False),
                     vibration_cali=job.options.get("vibration_cali", True),
                     vibration_cali=job.options.get("vibration_cali", True),
                     layer_inspect=job.options.get("layer_inspect", False),
                     layer_inspect=job.options.get("layer_inspect", False),
                     use_ams=job.options.get("use_ams", True),
                     use_ams=job.options.get("use_ams", True),
+                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
                 )
                 )
 
 
                 if not started:
                 if not started:
@@ -815,7 +837,10 @@ class BackgroundDispatchService:
                         progress_state["last_emit"] = now
                         progress_state["last_emit"] = now
                         progress_state["last_bytes"] = uploaded
                         progress_state["last_bytes"] = uploaded
                         loop.call_soon_threadsafe(
                         loop.call_soon_threadsafe(
-                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
+                            lambda u=uploaded, t=total: spawn_background_task(
+                                self._set_active_upload_progress(job, u, t),
+                                name=f"upload-progress-{job.id}",
+                            )
                         )
                         )
 
 
                 if ftp_retry_enabled:
                 if ftp_retry_enabled:
@@ -853,29 +878,35 @@ class BackgroundDispatchService:
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                     )
                     )
 
 
+                # Resolve plate_id before register so usage tracking can scope the
+                # 3MF parse to the dispatched plate at print-start (#1697).
+                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
+
                 register_expected_print(
                 register_expected_print(
                     job.printer_id,
                     job.printer_id,
                     remote_filename,
                     remote_filename,
                     archive.id,
                     archive.id,
                     ams_mapping=job.options.get("ams_mapping"),
                     ams_mapping=job.options.get("ams_mapping"),
+                    plate_id=plate_id,
                 )
                 )
 
 
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
                 self._raise_if_cancel_requested(job)
                 self._raise_if_cancel_requested(job)
 
 
+                effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
+
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 started = printer_manager.start_print(
                 started = printer_manager.start_print(
                     job.printer_id,
                     job.printer_id,
                     remote_filename,
                     remote_filename,
                     plate_id,
                     plate_id,
                     ams_mapping=job.options.get("ams_mapping"),
                     ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=job.options.get("timelapse", False),
+                    timelapse=effective_timelapse,
                     bed_levelling=job.options.get("bed_levelling", True),
                     bed_levelling=job.options.get("bed_levelling", True),
                     flow_cali=job.options.get("flow_cali", False),
                     flow_cali=job.options.get("flow_cali", False),
                     vibration_cali=job.options.get("vibration_cali", True),
                     vibration_cali=job.options.get("vibration_cali", True),
                     layer_inspect=job.options.get("layer_inspect", False),
                     layer_inspect=job.options.get("layer_inspect", False),
                     use_ams=job.options.get("use_ams", True),
                     use_ams=job.options.get("use_ams", True),
+                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
                 )
                 )
 
 
                 if not started:
                 if not started:
@@ -1077,4 +1108,41 @@ class BackgroundDispatchService:
         return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
         return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
 
 
 
 
+async def resolve_effective_timelapse(db, archive, user_wanted_timelapse: bool) -> bool:
+    """Resolve whether this print should record a timelapse (#1397).
+
+    Shared by both the on-demand dispatch path (``background_dispatch.py``,
+    used by Print Now / Reprint flows) and the queued-dispatch path
+    (``print_scheduler.py``, used by the print queue). Both must apply the
+    same override semantics or the queue path's prints would slip through
+    without a finish photo.
+
+    Bambuddy forces timelapse recording on when:
+      - the global ``capture_finish_photo`` setting is enabled, AND
+      - the user did NOT opt in to a timelapse for this specific print
+
+    The forced bit is recorded on the archive so the post-extraction
+    cleanup path can delete the timelapse afterward (the user didn't
+    ask for a video to keep, only the framed finish photo, #1397).
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    if user_wanted_timelapse:
+        return True
+
+    # User didn't ask — check the master capture-finish-photo toggle.
+    capture_setting = await get_setting(db, "capture_finish_photo")
+    capture_enabled = capture_setting is None or capture_setting.lower() == "true"
+    if not capture_enabled:
+        return False
+
+    archive.bambuddy_forced_timelapse = True
+    await db.commit()
+    logging.getLogger(__name__).info(
+        "[FORCED-TIMELAPSE] Forcing timelapse on for archive %s (capture_finish_photo enabled, user did not opt in)",
+        archive.id,
+    )
+    return True
+
+
 background_dispatch = BackgroundDispatchService()
 background_dispatch = BackgroundDispatchService()

+ 21 - 1
backend/app/services/bambu_mqtt.py

@@ -462,6 +462,19 @@ class BambuMQTTClient:
     def topic_publish(self) -> str:
     def topic_publish(self) -> str:
         return f"device/{self.serial_number}/request"
         return f"device/{self.serial_number}/request"
 
 
+    @property
+    def report_messages_since_connect(self) -> int:
+        """Count of report-topic messages received since the latest (re)connect.
+
+        Exposed for the connection diagnostic so it can distinguish "MQTT
+        broker accepted us but the printer never published" (typically a
+        wrong / mis-cased serial — #1622 follow-up to #1602) from a healthy
+        bridge that happens to be idle right now. Zero immediately after a
+        fresh connect is normal; zero after a full status push cycle is the
+        wrong-serial failure mode.
+        """
+        return self._report_messages_since_connect
+
     # Maximum time (seconds) without a message before considering connection stale
     # Maximum time (seconds) without a message before considering connection stale
     STALE_TIMEOUT = 60.0
     STALE_TIMEOUT = 60.0
 
 
@@ -3287,6 +3300,7 @@ class BambuMQTTClient:
         layer_inspect: bool = False,
         layer_inspect: bool = False,
         timelapse: bool = False,
         timelapse: bool = False,
         use_ams: bool = True,
         use_ams: bool = True,
+        nozzle_offset_cali: bool = False,
     ):
     ):
         """Start a print job on the printer.
         """Start a print job on the printer.
 
 
@@ -3303,6 +3317,8 @@ class BambuMQTTClient:
             vibration_cali: Vibration compensation calibration
             vibration_cali: Vibration compensation calibration
             layer_inspect: First layer AI inspection
             layer_inspect: First layer AI inspection
             use_ams: Use AMS for automatic filament changes
             use_ams: Use AMS for automatic filament changes
+            nozzle_offset_cali: Run nozzle offset calibration before print
+                (dual-nozzle printers only — silently ignored on single-nozzle).
         """
         """
         if self._client and self.state.connected:
         if self._client and self.state.connected:
             # Bambu print command format — matches Bambu Studio's format.
             # Bambu print command format — matches Bambu Studio's format.
@@ -3429,7 +3445,11 @@ class BambuMQTTClient:
                     # regardless of the flow_cali toggle (#1478).
                     # regardless of the flow_cali toggle (#1478).
                     "extrude_cali_flag": 1 if flow_cali else 2,
                     "extrude_cali_flag": 1 if flow_cali else 2,
                     "extrude_cali_manual_mode": 0,
                     "extrude_cali_manual_mode": 0,
-                    "nozzle_offset_cali": 2,
+                    # 1 = run, 2 = skip. BambuStudio exposes the toggle only for
+                    # dual-nozzle machines (H2D/H2D Pro/H2C/X2D); on single-nozzle
+                    # printers we always send 2 so firmware never wastes cycles
+                    # on a calibration their head doesn't support (#1682).
+                    "nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "profile_id": "0",
                     "profile_id": "0",
                     "project_id": submission_id,
                     "project_id": submission_id,

+ 78 - 0
backend/app/services/camera.py

@@ -625,6 +625,84 @@ async def capture_camera_frame_bytes(
         await proxy_server.wait_closed()
         await proxy_server.wait_closed()
 
 
 
 
+async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
+    """Extract the last frame of `video_path` as JPEG at `output_path`.
+
+    Used to source finish photos from a Bambu timelapse. The Bambu firmware
+    stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop
+    end-gcode runs, so the last frame frames the finished print correctly.
+    A live camera grab at `gcode_state=FINISH` captures the bed already
+    lowered (#1397).
+
+    Implementation: ``-update 1`` writes each decoded frame to the same
+    output file (overwriting), so the file left on disk after ffmpeg
+    finishes is the LAST frame. This works regardless of how short the
+    video is — a small print's timelapse can be sub-second / sub-30 frames
+    (one frame per layer-change capture), and the earlier ``-sseof -1.0``
+    approach failed there because the seek went before the start of the
+    file and ffmpeg silently returned frame 0 (empty bed at print start).
+    Decoding every frame is fine: Bambu timelapses are short by
+    construction (<1 minute even on hours-long prints).
+
+    Returns False on missing ffmpeg, missing video, subprocess failure or
+    timeout. Never raises.
+    """
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.warning("Cannot extract video last frame: ffmpeg not available")
+        return False
+
+    if not video_path.exists() or video_path.stat().st_size == 0:
+        logger.warning("Cannot extract last frame: %s missing or empty", video_path)
+        return False
+
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    cmd = [
+        ffmpeg,
+        "-y",
+        "-i",
+        str(video_path),
+        "-q:v",
+        "2",
+        "-update",
+        "1",
+        str(output_path),
+    ]
+
+    process = None
+    try:
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        _, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
+        if process.returncode != 0:
+            logger.warning(
+                "ffmpeg failed extracting last frame from %s: %s",
+                video_path,
+                stderr.decode(errors="replace")[:500],
+            )
+            return False
+        if not output_path.exists() or output_path.stat().st_size == 0:
+            logger.warning("ffmpeg produced no output for %s", video_path)
+            return False
+        return True
+    except asyncio.TimeoutError:
+        logger.warning("ffmpeg timed out extracting last frame from %s", video_path)
+        if process is not None:
+            try:
+                process.kill()
+                await process.wait()
+            except ProcessLookupError:
+                pass  # Already exited
+        return False
+    except OSError as e:
+        logger.warning("ffmpeg subprocess error for %s: %s", video_path, e)
+        return False
+
+
 async def capture_finish_photo(
 async def capture_finish_photo(
     printer_id: int,
     printer_id: int,
     ip_address: str,
     ip_address: str,

+ 140 - 71
backend/app/services/firmware_check.py

@@ -20,6 +20,26 @@ from backend.app.core.config import _data_dir
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Cloudflare on bambulab.com now gates the firmware-download page behind a
+# JA3/TLS-fingerprint challenge (cf-mitigated=challenge) that plain Python
+# TLS can't pass (#1666). curl_cffi replays Chrome's actual ClientHello
+# bytes so the handshake clears CF; we override the HTTP User-Agent back to
+# the honest Bambuddy/1.0 string so the application-layer identity stays
+# truthful (TLS fingerprint matches Chrome because Python's TLS is the
+# signal CF gates on; everything above TLS is still Bambuddy).
+#
+# Soft dependency — if curl_cffi isn't importable on the running platform,
+# firmware_check degrades to httpx (which will likely 403) and wiki-based
+# version detection continues to work for the badge; only the in-app
+# firmware-download URL stops resolving.
+try:
+    from curl_cffi.requests import AsyncSession as _CurlCffiAsyncSession
+
+    _CURL_CFFI_AVAILABLE = True
+except ImportError:  # pragma: no cover — exercised only on platforms without wheels
+    _CurlCffiAsyncSession = None  # type: ignore[misc,assignment]
+    _CURL_CFFI_AVAILABLE = False
+
 # Bambu Lab firmware download page (for download URLs)
 # Bambu Lab firmware download page (for download URLs)
 BAMBU_FIRMWARE_BASE = "https://bambulab.com"
 BAMBU_FIRMWARE_BASE = "https://bambulab.com"
 FIRMWARE_PAGE = "/en/support/firmware-download/all"
 FIRMWARE_PAGE = "/en/support/firmware-download/all"
@@ -121,22 +141,71 @@ class FirmwareCheckService:
         self._version_cache: dict[str, FirmwareVersion] = {}
         self._version_cache: dict[str, FirmwareVersion] = {}
         self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
         self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
         self._cache_time: float = 0
         self._cache_time: float = 0
+        # Plain httpx client for the Bambu Lab wiki (no CF fingerprint check)
+        # and other endpoints. Honest UA throughout.
         self._client = httpx.AsyncClient(
         self._client = httpx.AsyncClient(
             timeout=30.0,
             timeout=30.0,
             headers={
             headers={
-                # Identify honestly as Bambuddy when scraping the public Bambu
-                # Lab firmware wiki — verified 2026-05-12 that the wiki serves
-                # this UA identically to a Chrome UA (same HTML response shape).
-                # No browser impersonation needed for read-only public pages.
                 "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
                 "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
-                # Some Cloudflare bot rules on bambulab.com 403 requests with a
-                # bare UA but no browser-like Accept headers (seen on AU IPs in
-                # #1350). Sending normal Accept hints removes that signal while
-                # staying honestly identified via the UA above.
                 "Accept": "text/html,application/json,*/*;q=0.8",
                 "Accept": "text/html,application/json,*/*;q=0.8",
                 "Accept-Language": "en-US,en;q=0.9",
                 "Accept-Language": "en-US,en;q=0.9",
             },
             },
         )
         )
+        # curl_cffi session for bambulab.com (lazily initialised on first use).
+        # See module-level note on why this is needed.
+        self._bambulab_client: object | None = None
+        if not _CURL_CFFI_AVAILABLE:
+            logger.warning(
+                "curl_cffi not installed — bambulab.com firmware-download page "
+                "will likely return Cloudflare 403 (#1666). Wiki-based version "
+                "detection still works; install curl_cffi to also resolve "
+                "in-app firmware download URLs."
+            )
+
+    def _get_bambulab_client(self) -> object | None:
+        """Lazy-init curl_cffi async session for bambulab.com.
+
+        Chrome TLS impersonation is required to pass Cloudflare's JA3
+        challenge. The HTTP `User-Agent` is overridden back to the honest
+        Bambuddy string so application-layer identity stays truthful.
+        Returns None when curl_cffi is unavailable.
+        """
+        if not _CURL_CFFI_AVAILABLE:
+            return None
+        if self._bambulab_client is None:
+            assert _CurlCffiAsyncSession is not None  # type-narrow for mypy
+            self._bambulab_client = _CurlCffiAsyncSession(
+                impersonate="chrome",
+                headers={
+                    "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
+                    "Accept": "text/html,application/json,*/*;q=0.8",
+                    "Accept-Language": "en-US,en;q=0.9",
+                },
+                timeout=30,
+            )
+        return self._bambulab_client
+
+    async def _bambulab_get(self, url: str) -> tuple[int, str]:
+        """GET against bambulab.com. Returns (status_code, body_text).
+
+        Routes through curl_cffi when available (passes the CF JA3 gate);
+        falls back to httpx otherwise (will likely 403 but worth the try
+        in case CF eases the check). status 0 indicates a transport error.
+        """
+        client = self._get_bambulab_client()
+        if client is not None:
+            try:
+                response = await client.get(url)  # type: ignore[attr-defined]
+                return response.status_code, response.text
+            except Exception as e:
+                logger.error("curl_cffi error fetching %s: %s", url, e)
+                return 0, ""
+        try:
+            response = await self._client.get(url)
+            return response.status_code, response.text
+        except Exception as e:
+            logger.error("httpx error fetching %s: %s", url, e)
+            return 0, ""
 
 
     def _build_id_cache_path(self) -> Path:
     def _build_id_cache_path(self) -> Path:
         cache_dir = _data_dir / "firmware"
         cache_dir = _data_dir / "firmware"
@@ -188,28 +257,29 @@ class FirmwareCheckService:
                 self._build_id_time = disk_time
                 self._build_id_time = disk_time
 
 
         # 3. Live fetch
         # 3. Live fetch
-        try:
-            response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
-            if response.status_code == 200:
-                match = re.search(r'"buildId":"([^"]+)"', response.text)
-                if match:
-                    new_build_id = match.group(1)
-                    if new_build_id != self._build_id:
-                        logger.info("Got Bambu Lab build ID: %s", new_build_id)
-                    self._build_id = new_build_id
-                    self._build_id_time = time.time()
-                    self._download_page_unreachable = False
-                    self._save_build_id_to_disk(new_build_id)
-                    return self._build_id
-            else:
-                # 403/5xx — keep stale cached buildId if we have one (#1350).
-                logger.warning(
-                    "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
-                    response.status_code,
-                )
-                self._download_page_unreachable = True
-        except Exception as e:
-            logger.error("Error fetching Bambu Lab build ID: %s", e)
+        status, body = await self._bambulab_get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
+        if status == 200:
+            match = re.search(r'"buildId":"([^"]+)"', body)
+            if match:
+                new_build_id = match.group(1)
+                if new_build_id != self._build_id:
+                    logger.info("Got Bambu Lab build ID: %s", new_build_id)
+                self._build_id = new_build_id
+                self._build_id_time = time.time()
+                self._download_page_unreachable = False
+                self._save_build_id_to_disk(new_build_id)
+                return self._build_id
+        elif status == 0:
+            # Transport-level error already logged by _bambulab_get.
+            self._download_page_unreachable = True
+        else:
+            # 403/5xx — keep stale cached buildId if we have one (#1350).
+            # Without curl_cffi this is the expected outcome on Cloudflare
+            # JA3-gated zones (#1666).
+            logger.warning(
+                "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
+                status,
+            )
             self._download_page_unreachable = True
             self._download_page_unreachable = True
 
 
         # Return whatever we have — even a stale buildId beats nothing.
         # Return whatever we have — even a stale buildId beats nothing.
@@ -302,50 +372,49 @@ class FirmwareCheckService:
             return []
             return []
 
 
         for attempt in range(2):
         for attempt in range(2):
-            try:
-                url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
-                response = await self._client.get(url)
-
-                if response.status_code == 200:
-                    data = response.json()
-                    page_props = data.get("pageProps", {})
-                    printer_map = page_props.get("printerMap", {})
-                    printer_data = printer_map.get(api_key, {})
-                    versions = printer_data.get("versions", [])
-                    return [
-                        FirmwareVersion(
-                            version=v.get("version", ""),
-                            download_url=v.get("url", ""),
-                            release_notes=v.get("release_notes_en"),
-                            release_time=v.get("release_time"),
-                        )
-                        for v in versions
-                        if v.get("version")
-                    ]
-
-                # 404 with cached buildId → Bambu rebuilt the page; invalidate
-                # and retry once. Other status codes (403, 5xx) are upstream
-                # blocks — don't churn.
-                if response.status_code == 404 and attempt == 0:
-                    logger.info("Cached Bambu buildId stale (404), refreshing")
-                    self._build_id = None
-                    self._build_id_time = 0
-                    build_id = await self._get_build_id()
-                    if not build_id:
-                        return []
-                    continue
+            url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
+            status, body = await self._bambulab_get(url)
 
 
-                # 403 from the JSON endpoint is the same Cloudflare block
-                # signal as on the index page (#1350).
-                if response.status_code == 403:
-                    self._download_page_unreachable = True
+            if status == 200:
+                try:
+                    data = json.loads(body)
+                except ValueError as e:
+                    logger.debug("Download-page JSON for %s parse error: %s", api_key, e)
+                    return []
+                page_props = data.get("pageProps", {})
+                printer_map = page_props.get("printerMap", {})
+                printer_data = printer_map.get(api_key, {})
+                versions = printer_data.get("versions", [])
+                return [
+                    FirmwareVersion(
+                        version=v.get("version", ""),
+                        download_url=v.get("url", ""),
+                        release_notes=v.get("release_notes_en"),
+                        release_time=v.get("release_time"),
+                    )
+                    for v in versions
+                    if v.get("version")
+                ]
+
+            # 404 with cached buildId → Bambu rebuilt the page; invalidate
+            # and retry once. Other status codes (403, 5xx) are upstream
+            # blocks — don't churn.
+            if status == 404 and attempt == 0:
+                logger.info("Cached Bambu buildId stale (404), refreshing")
+                self._build_id = None
+                self._build_id_time = 0
+                build_id = await self._get_build_id()
+                if not build_id:
+                    return []
+                continue
 
 
-                logger.debug("Download-page JSON for %s returned status %s", api_key, response.status_code)
-                return []
+            # 403 from the JSON endpoint is the same Cloudflare block
+            # signal as on the index page (#1350, #1666).
+            if status == 403:
+                self._download_page_unreachable = True
 
 
-            except Exception as e:
-                logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
-                return []
+            logger.debug("Download-page JSON for %s returned status %s", api_key, status)
+            return []
 
 
         return []
         return []
 
 

+ 4 - 2
backend/app/services/firmware_update.py

@@ -16,6 +16,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.compat import StrEnum
 from backend.app.core.compat import StrEnum
+from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_ftp import (
@@ -258,14 +259,15 @@ class FirmwareUpdateService:
         await self._broadcast_progress(printer_id, state)
         await self._broadcast_progress(printer_id, state)
 
 
         # Run the upload in background
         # Run the upload in background
-        asyncio.create_task(
+        spawn_background_task(
             self._do_upload(
             self._do_upload(
                 printer_id=printer_id,
                 printer_id=printer_id,
                 ip_address=printer.ip_address,
                 ip_address=printer.ip_address,
                 access_code=printer.access_code,
                 access_code=printer.access_code,
                 model=model,
                 model=model,
                 target_version=target_version,
                 target_version=target_version,
-            )
+            ),
+            name=f"firmware-upload-{printer_id}",
         )
         )
 
 
         return True
         return True

+ 13 - 0
backend/app/services/ftp_profiles.py

@@ -75,12 +75,25 @@ _PROFILES: dict[str, FTPProfile] = {
     "P2S": FTPProfile(
     "P2S": FTPProfile(
         cap_tls_v1_2=True,
         cap_tls_v1_2=True,
     ),
     ),
+    # X2D firmware 01.01.00.00 fails the implicit-FTPS handshake on
+    # port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` against Python
+    # 3.13's default TLS-1.3 ClientHello (#1638, reporter @vasmarfas).
+    # Without the 3MF download the print falls through to the no-3MF
+    # fallback archive path and the card lands almost empty (no
+    # filament total, no layers, no MakerWorld link). Cap to TLS 1.2
+    # by analogy with P2S; if the symptom turns out to be a different
+    # FTPS variant on the X2D (explicit AUTH TLS, different port) the
+    # entry stays useful as a per-model tuning slot for the follow-up.
+    "X2D": FTPProfile(
+        cap_tls_v1_2=True,
+    ),
 }
 }
 
 
 # SSDP internal codes that should resolve to a display-name profile.
 # SSDP internal codes that should resolve to a display-name profile.
 # Mirrors the same map in :mod:`camera_profiles`.
 # Mirrors the same map in :mod:`camera_profiles`.
 _MODEL_ALIASES: dict[str, str] = {
 _MODEL_ALIASES: dict[str, str] = {
     "N7": "P2S",  # P2S internal SSDP code
     "N7": "P2S",  # P2S internal SSDP code
+    "N6": "X2D",  # X2D internal SSDP code
 }
 }
 
 
 
 

+ 38 - 10
backend/app/services/local_backup.py

@@ -6,8 +6,10 @@ on a configurable schedule with retention management.
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import os
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from pathlib import Path
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 
 from sqlalchemy import select
 from sqlalchemy import select
 
 
@@ -17,6 +19,24 @@ from backend.app.models.settings import Settings
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+
+def _local_zone() -> ZoneInfo:
+    """Resolve the local timezone for scheduled-backup HH:MM interpretation.
+
+    Uses the container's ``TZ`` env var (the same value the support package
+    surfaces); falls back to UTC when unset or unrecognised so a missing TZ
+    keeps the legacy behaviour rather than crashing. See #1602 follow-up.
+    """
+    tz_name = os.environ.get("TZ", "").strip()
+    if not tz_name:
+        return ZoneInfo("UTC")
+    try:
+        return ZoneInfo(tz_name)
+    except ZoneInfoNotFoundError:
+        logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
+        return ZoneInfo("UTC")
+
+
 SCHEDULE_INTERVALS = {
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
     "hourly": 3600,
     "daily": 86400,
     "daily": 86400,
@@ -122,14 +142,16 @@ class LocalBackupService:
     def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime:
     def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime:
         """Calculate the next scheduled run time.
         """Calculate the next scheduled run time.
 
 
-        For hourly: next full hour.
-        For daily/weekly: next occurrence of the configured time (HH:MM).
+        For hourly: next full hour (timezone-agnostic).
+        For daily/weekly: next occurrence of the configured HH:MM, interpreted
+        in the container's local timezone (TZ env var, UTC fallback). Returns
+        a UTC-aware datetime for storage / comparison against ``now``.
         """
         """
-        now = datetime.now(timezone.utc)
+        now_utc = datetime.now(timezone.utc)
 
 
         if schedule_type == "hourly":
         if schedule_type == "hourly":
             # Next full hour
             # Next full hour
-            next_run = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
+            next_run = now_utc.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
             return next_run
             return next_run
 
 
         # Parse HH:MM time
         # Parse HH:MM time
@@ -140,15 +162,21 @@ class LocalBackupService:
         except (ValueError, IndexError):
         except (ValueError, IndexError):
             hour, minute = 3, 0
             hour, minute = 3, 0
 
 
-        # Next occurrence of this time today or tomorrow
-        next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
-        if next_run <= now:
-            next_run += timedelta(days=1)
+        local_tz = _local_zone()
+        now_local = now_utc.astimezone(local_tz)
+        # Next occurrence of HH:MM local time, today or tomorrow.
+        # ``fold=0`` resolves the ambiguous wall-clock window at DST fall-back
+        # to the earlier instance (consistent with cron's behaviour). On the
+        # spring-forward gap the synthesized local time will normalise to the
+        # next valid instant when converted to UTC.
+        next_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0, fold=0)
+        if next_local <= now_local:
+            next_local += timedelta(days=1)
 
 
         if schedule_type == "weekly":
         if schedule_type == "weekly":
-            next_run += timedelta(weeks=1)
+            next_local += timedelta(weeks=1)
 
 
-        return next_run
+        return next_local.astimezone(timezone.utc)
 
 
     def _resolve_backup_dir(self, path_setting: str) -> Path:
     def _resolve_backup_dir(self, path_setting: str) -> Path:
         """Resolve the backup output directory from settings."""
         """Resolve the backup output directory from settings."""

+ 504 - 0
backend/app/services/orca_cloud.py

@@ -0,0 +1,504 @@
+"""
+Orca Cloud API Service
+
+Handles authentication and profile sync with the Orca Cloud (Supabase-backed).
+
+Auth shape: PKCE flow against ``auth.orcaslicer.com`` with the in-source public
+publishable key. Bambuddy generates the verifier/challenge/state, redirects the
+user's browser to Supabase's ``/auth/v1/authorize`` endpoint with
+``redirect_to=http://localhost:41172/callback``, and the user pastes the
+callback URL back into Bambuddy (the loopback URL is the only ``redirect_to``
+Orca's Supabase project actually honors as of v2.4.0-alpha — see
+OrcaSlicer/OrcaSlicer#14028 for the open feature request asking SoftFever to
+broaden this).
+
+Token shape: short-lived access JWT (1h) + rotating single-use refresh token.
+Every refresh issues a new pair and invalidates the old one — the route layer
+is responsible for atomically swapping the stored pair on each refresh, or a
+mid-refresh crash strands the user.
+
+Cloudflare protects ``api.orcaslicer.com`` with a User-Agent gate; sending an
+honest ``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import logging
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# Auth + API endpoints — extracted verbatim from OrcaCloudServiceAgent.cpp
+# v2.4.0-alpha. The "publishable" key is documented in-source as a public
+# client identifier (Supabase anon-key pattern); embedding it in our client
+# is by-design and not a secret leak.
+ORCA_AUTH_BASE = "https://auth.orcaslicer.com"
+ORCA_API_BASE = "https://api.orcaslicer.com"
+ORCA_ANON_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_"
+
+# Loopback redirect from OrcaCloudServiceAgent.cpp. Supabase's redirect_to
+# allowlist on Orca's project only honors localhost URIs — anything else
+# silently falls through to the project Site URL after the OAuth dance.
+ORCA_REDIRECT_URI = "http://localhost:41172/callback"
+
+# Honest client identity. Same posture as Bambu Cloud: identifies Bambuddy
+# without impersonating Orca's desktop client (which would be CWE-style
+# falsified-identity and was the exact thing called out in Bambu Lab's May 2026
+# blog post about cloud-access etiquette).
+_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+
+# Refresh access tokens when they have less than this much life left, on the
+# theory that a slow downstream API call shouldn't expire the token mid-flight.
+_REFRESH_LEEWAY = timedelta(minutes=5)
+
+# PKCE handshake state TTL. If the user clicks "Connect" then walks away,
+# the stored verifier+state is invalid after this window — they have to
+# restart. 10 minutes is the OAuth norm for desktop-app PKCE flows.
+PENDING_PKCE_TTL = timedelta(minutes=10)
+
+
+class OrcaCloudError(Exception):
+    """Base exception for Orca Cloud errors."""
+
+    pass
+
+
+class OrcaCloudAuthError(OrcaCloudError):
+    """Authentication / token-related errors. Caller should typically prompt
+    the user to reconnect — neither a fresh access token nor a refresh will
+    recover without re-authentication."""
+
+    pass
+
+
+_shared_http_client: httpx.AsyncClient | None = None
+
+
+def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
+    """Register an app-scoped ``httpx.AsyncClient`` so per-request
+    ``OrcaCloudService`` instances can reuse its connection pool. Mirrors the
+    pattern used by :mod:`backend.app.services.bambu_cloud`."""
+    global _shared_http_client
+    _shared_http_client = client
+
+
+# ---------------------------------------------------------------------------
+# PKCE helpers (free functions — no service-instance state needed)
+# ---------------------------------------------------------------------------
+
+
+def _b64url(data: bytes) -> str:
+    """RFC 7636-style base64url encoding, no padding."""
+    return base64.urlsafe_b64encode(data).decode().rstrip("=")
+
+
+def generate_pkce() -> tuple[str, str, str]:
+    """Generate a fresh ``(verifier, challenge, state)`` triple for one PKCE
+    handshake. The verifier is the secret kept by Bambuddy until the code
+    exchange; the challenge is sent to Supabase as ``code_challenge``; the
+    state is the CSRF nonce we'll verify against the callback.
+
+    Verifier = 32 random bytes (43 base64url chars), within RFC 7636's
+    43-128 char range. Challenge = ``base64url(sha256(verifier))``.
+    """
+    verifier = _b64url(secrets.token_bytes(32))
+    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
+    state = _b64url(secrets.token_bytes(16))
+    return verifier, challenge, state
+
+
+def build_authorize_url(challenge: str, provider: str = "google") -> str:
+    """Construct the URL the user's browser should visit to start the OAuth
+    handshake.
+
+    Notably **does not** pass a ``state`` query parameter. Supabase's GoTrue
+    uses its own internal state encoding to remember which ``redirect_to``
+    belongs to which OAuth session; a client-passed ``state`` overwrites
+    that, GoTrue can no longer decode the redirect_to from Google's
+    callback, and silently falls back to the project Site URL — which is
+    exactly the bug that broke the live test against our deployed integration.
+
+    CSRF is still protected by the PKCE flow itself: the server-side
+    ``code_verifier`` is single-use and bound to the user's session, so an
+    attacker with a code-only URL can't complete the exchange.
+    """
+    from urllib.parse import urlencode
+
+    qs = urlencode(
+        {
+            "provider": provider,
+            "redirect_to": ORCA_REDIRECT_URI,
+            "code_challenge": challenge,
+            "code_challenge_method": "S256",
+        }
+    )
+    return f"{ORCA_AUTH_BASE}/auth/v1/authorize?{qs}"
+
+
+def parse_callback_url(callback_url: str) -> tuple[str | None, str | None]:
+    """Extract ``(code, state)`` from a pasted callback URL. Both query string
+    and fragment are checked — some Supabase configurations put PKCE codes in
+    the fragment rather than the query string. Returns ``(None, None)`` if
+    nothing parses out; the route layer surfaces the user-facing error."""
+    from urllib.parse import parse_qs, urlparse
+
+    parsed = urlparse(callback_url.strip())
+    qsd = parse_qs(parsed.query)
+    code = qsd.get("code", [""])[0] or None
+    state = qsd.get("state", [""])[0] or None
+    if not code:
+        frag = parse_qs(parsed.fragment)
+        code = frag.get("code", [""])[0] or None
+        state = state or (frag.get("state", [""])[0] or None)
+    return code, state
+
+
+# ---------------------------------------------------------------------------
+# Service class
+# ---------------------------------------------------------------------------
+
+
+class OrcaCloudService:
+    """Stateful per-request client for the Orca Cloud API.
+
+    Instantiated by the route layer, populated with a stored token via
+    :meth:`set_tokens`, then used to call the sync endpoints. Token rotation
+    on refresh is the route layer's responsibility (see
+    :meth:`refresh` — returns the new pair, doesn't persist).
+    """
+
+    def __init__(self, client: httpx.AsyncClient | None = None):
+        self.access_token: str | None = None
+        self.refresh_token: str | None = None
+        self.token_expiry: datetime | None = None
+        # Mirror the bambu_cloud pattern for client ownership: prefer injected
+        # client (tests), fall back to app-scoped shared client (production),
+        # else create our own so ad-hoc scripts still work.
+        if client is not None:
+            self._client = client
+            self._owns_client = False
+        elif _shared_http_client is not None:
+            self._client = _shared_http_client
+            self._owns_client = False
+        else:
+            self._client = httpx.AsyncClient(timeout=30.0)
+            self._owns_client = True
+
+    @property
+    def is_authenticated(self) -> bool:
+        """True iff we have an access token that won't expire within
+        :data:`_REFRESH_LEEWAY`. The leeway prevents a slow API call from
+        timing out mid-flight on a token that was nominally still valid."""
+        if not self.access_token:
+            return False
+        if self.token_expiry is None:
+            # No expiry recorded — pessimistically treat as expired so the
+            # caller refreshes before use.
+            return False
+        return datetime.now(timezone.utc) + _REFRESH_LEEWAY < self.token_expiry
+
+    def set_tokens(
+        self,
+        access_token: str | None,
+        refresh_token: str | None,
+        expires_at: datetime | None,
+    ) -> None:
+        """Hydrate the service from stored credentials."""
+        self.access_token = access_token
+        self.refresh_token = refresh_token
+        # Normalize to timezone-aware UTC so subsequent comparisons against
+        # ``datetime.now(timezone.utc)`` are well-defined. asyncpg returns
+        # naive datetimes from a ``TIMESTAMP WITHOUT TIME ZONE`` column —
+        # we treat naive values as UTC since that's how we stored them.
+        if expires_at is not None and expires_at.tzinfo is None:
+            expires_at = expires_at.replace(tzinfo=timezone.utc)
+        self.token_expiry = expires_at
+
+    def clear_tokens(self) -> None:
+        """Forget all credentials. Used on logout and after auth failures."""
+        self.access_token = None
+        self.refresh_token = None
+        self.token_expiry = None
+
+    def _auth_headers(self) -> dict[str, str]:
+        """Headers for calls to ``auth.orcaslicer.com``. Always includes the
+        apikey; the ``Authorization`` header is added only if we already have
+        an access token (used by ``/logout``, not by token exchange)."""
+        headers = {
+            "User-Agent": _USER_AGENT,
+            "apikey": ORCA_ANON_KEY,
+            "Content-Type": "application/json",
+        }
+        if self.access_token:
+            headers["Authorization"] = f"Bearer {self.access_token}"
+        return headers
+
+    def _api_headers(self) -> dict[str, str]:
+        """Headers for calls to ``api.orcaslicer.com``. Requires a bearer
+        token — callers should ensure the service is authenticated first."""
+        if not self.access_token:
+            raise OrcaCloudAuthError("Orca Cloud API requires an access token")
+        return {
+            "User-Agent": _USER_AGENT,
+            "apikey": ORCA_ANON_KEY,
+            "Authorization": f"Bearer {self.access_token}",
+            "Accept": "application/json",
+        }
+
+    # ------------------------------------------------------------------
+    # Token lifecycle
+    # ------------------------------------------------------------------
+
+    async def password_login(self, email: str, password: str) -> dict[str, Any]:
+        """Direct email+password login via ``/auth/v1/token?grant_type=password``.
+
+        Whether this works depends on the Supabase project's auth config —
+        Orca's web sign-in offers email/password as one option, but their
+        desktop client refuses ``{username, password}`` payloads with
+        ``"Username/password login is disabled. Use the Orca cloud PKCE
+        flow."`` (the SDK enforces PKCE regardless of what the backend
+        allows). The actual server behaviour is what matters for Bambuddy
+        — we POST the credentials and surface whatever response we get;
+        an ``OrcaCloudAuthError`` with the verbatim Supabase error message
+        is the right signal for callers to fall back to an OAuth provider.
+        """
+        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=password"
+        payload = {"email": email, "password": password}
+        try:
+            resp = await self._client.post(
+                url,
+                json=payload,
+                headers={
+                    "User-Agent": _USER_AGENT,
+                    "apikey": ORCA_ANON_KEY,
+                    "Content-Type": "application/json",
+                },
+            )
+        except httpx.HTTPError as e:
+            raise OrcaCloudError(f"Network error during Orca Cloud password login: {e}") from e
+
+        if resp.status_code >= 400:
+            detail = _describe_token_error(resp)
+            if resp.status_code in (400, 401, 403, 422):
+                raise OrcaCloudAuthError(f"Orca Cloud password login rejected: {detail}")
+            raise OrcaCloudError(f"Orca Cloud password login failed ({resp.status_code}): {detail}")
+
+        data = resp.json()
+        self._apply_token_response(data)
+        return data
+
+    async def exchange_code(self, auth_code: str, code_verifier: str) -> dict[str, Any]:
+        """Exchange a PKCE auth code for tokens. Mutates ``self`` so the
+        service is ready for API calls. Returns the raw Supabase token
+        response so the route layer can persist the new credentials."""
+        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=pkce"
+        payload = {"auth_code": auth_code, "code_verifier": code_verifier}
+        try:
+            resp = await self._client.post(
+                url,
+                json=payload,
+                headers={
+                    "User-Agent": _USER_AGENT,
+                    "apikey": ORCA_ANON_KEY,
+                    "Content-Type": "application/json",
+                },
+            )
+        except httpx.HTTPError as e:
+            raise OrcaCloudError(f"Network error during Orca Cloud token exchange: {e}") from e
+
+        if resp.status_code >= 400:
+            # Supabase returns ``{"error":"...", "error_description":"..."}``
+            # on most failures and ``{"msg":"..."}`` on a few. Surface
+            # whatever we can find.
+            detail = _describe_token_error(resp)
+            if resp.status_code in (400, 401, 403):
+                raise OrcaCloudAuthError(f"Orca Cloud token exchange rejected: {detail}")
+            raise OrcaCloudError(f"Orca Cloud token exchange failed ({resp.status_code}): {detail}")
+
+        data = resp.json()
+        self._apply_token_response(data)
+        return data
+
+    async def refresh(self) -> dict[str, Any]:
+        """Use the stored refresh token to obtain a fresh access/refresh pair.
+
+        Supabase issues single-use refresh tokens — the old refresh token is
+        invalidated the moment this call succeeds. The caller MUST persist the
+        new pair atomically with consuming the old one; otherwise a crash
+        between this return and the DB write strands the user. Returns the
+        raw token-response dict so the caller has the full new pair.
+        """
+        if not self.refresh_token:
+            raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
+
+        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=refresh_token"
+        payload = {"refresh_token": self.refresh_token}
+        try:
+            resp = await self._client.post(
+                url,
+                json=payload,
+                headers={
+                    "User-Agent": _USER_AGENT,
+                    "apikey": ORCA_ANON_KEY,
+                    "Content-Type": "application/json",
+                },
+            )
+        except httpx.HTTPError as e:
+            raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
+
+        if resp.status_code >= 400:
+            detail = _describe_token_error(resp)
+            # 400/401 typically means "refresh token rotated or revoked" —
+            # the user has to reconnect. Don't try to recover here.
+            if resp.status_code in (400, 401, 403):
+                self.clear_tokens()
+                raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
+            raise OrcaCloudError(f"Orca Cloud refresh failed ({resp.status_code}): {detail}")
+
+        data = resp.json()
+        self._apply_token_response(data)
+        return data
+
+    def _apply_token_response(self, data: dict[str, Any]) -> None:
+        """Update ``self.access_token`` / ``self.refresh_token`` /
+        ``self.token_expiry`` from a Supabase token-response payload. Caller
+        is still responsible for persisting the values to the DB."""
+        access = data.get("access_token")
+        refresh = data.get("refresh_token")
+        expires_in = data.get("expires_in")
+        if not access:
+            raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
+        self.access_token = access
+        # Supabase always rotates refresh tokens on /token calls; if the
+        # response omits one we keep the previous value to avoid stranding
+        # the session, but that shouldn't happen in practice.
+        if refresh:
+            self.refresh_token = refresh
+        if isinstance(expires_in, (int, float)) and expires_in > 0:
+            self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
+        else:
+            self.token_expiry = None
+
+    # ------------------------------------------------------------------
+    # Sync API
+    # ------------------------------------------------------------------
+
+    async def get_user_info(self) -> dict[str, Any]:
+        """Return Supabase's user record for the current token (id, email,
+        metadata, ...). Used after token exchange to record the user's email
+        for display in Bambuddy's UI."""
+        url = f"{ORCA_AUTH_BASE}/auth/v1/user"
+        try:
+            resp = await self._client.get(url, headers=self._auth_headers())
+        except httpx.HTTPError as e:
+            raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e
+        if resp.status_code == 401:
+            raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked")
+        if resp.status_code >= 400:
+            raise OrcaCloudError(f"Orca Cloud user fetch failed ({resp.status_code}): {resp.text[:200]}")
+        return resp.json()
+
+    async def list_profiles(self) -> list[dict[str, Any]]:
+        """Return the user's Orca Cloud profiles as a flat list of
+        ``ProfileUpsert`` entries (``{id, name, content, updated_time,
+        created_time}``) — forwarded verbatim; callers pick the fields they
+        need.
+
+        Uses ``GET /api/v1/sync/pull`` with NO ``?cursor=`` parameter, which
+        is the same "first-sync bootstrap" path OrcaSlicer's own client
+        uses (``OrcaCloudServiceAgent.cpp::sync_pull``):
+
+            std::string path = ORCA_SYNC_PULL_PATH;
+            if (sync_state.last_sync_timestamp != 0) {
+                path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp);
+            }
+            ...
+            // Handle 410 Gone — cursor too old, need full resync
+            if (http_code == 410) {
+                clear_sync_state();
+                path = ORCA_SYNC_PULL_PATH;  // retry without cursor
+                ...
+            }
+
+        Sending ``cursor=0`` explicitly trips ``410 cursor_too_old`` — the
+        server-side sync log doesn't reach back to the Unix epoch. Omitting
+        the parameter entirely is the documented "give me the full snapshot"
+        semantic. The previously-attempted ``/api/v1/sync/profiles`` is
+        declared as a constant in Orca's source but isn't deployed on the
+        production cloud (returns 404).
+
+        The pull response is a ``SyncPullResponse`` (``{next_cursor, upserts,
+        deletes}``); we extract ``upserts`` and ignore ``deletes`` (no prior
+        state on the client side to invalidate).
+        """
+        url = f"{ORCA_API_BASE}/api/v1/sync/pull"
+        try:
+            resp = await self._client.get(url, headers=self._api_headers())
+        except httpx.HTTPError as e:
+            raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
+        if resp.status_code == 401:
+            raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
+        if resp.status_code >= 400:
+            raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
+        data = resp.json()
+        if isinstance(data, dict):
+            upserts = data.get("upserts")
+            if isinstance(upserts, list):
+                return upserts
+            # Tolerate the shape we'd see if Orca ever rolls out a flat-list
+            # endpoint at this path — forward whatever array is on the dict.
+            for key in ("profiles", "data"):
+                value = data.get(key)
+                if isinstance(value, list):
+                    return value
+        if isinstance(data, list):
+            return data
+        logger.warning("Orca Cloud /sync/pull returned unexpected shape: %r", type(data).__name__)
+        return []
+
+    async def get_profile(self, profile_id: str) -> dict[str, Any]:
+        """Fetch a single profile's full content. Orca's sync API doesn't
+        expose a per-profile GET, so we list and filter. For small profile
+        counts (the realistic case) this is fine; if it becomes a hot path
+        we'll add client-side caching at the route layer rather than hammer
+        the list endpoint.
+        """
+        profiles = await self.list_profiles()
+        for profile in profiles:
+            if str(profile.get("id")) == str(profile_id):
+                return profile
+        raise OrcaCloudError(f"Orca Cloud profile {profile_id!r} not found (scanned {len(profiles)} profiles)")
+
+    # ------------------------------------------------------------------
+    # Lifecycle
+    # ------------------------------------------------------------------
+
+    async def close(self) -> None:
+        """Release the underlying httpx client iff we own it. No-op if we're
+        using an injected or app-shared client (those are managed elsewhere)."""
+        if self._owns_client:
+            await self._client.aclose()
+
+
+def _describe_token_error(resp: httpx.Response) -> str:
+    """Best-effort extraction of a user-facing message from a Supabase token
+    endpoint error response. Tries JSON fields in order; falls back to the
+    raw body (truncated) if nothing parses."""
+    try:
+        data = resp.json()
+    except (json.JSONDecodeError, ValueError):
+        return (resp.text or "<empty body>")[:200]
+    if not isinstance(data, dict):
+        return str(data)[:200]
+    for key in ("error_description", "msg", "error", "message"):
+        val = data.get(key)
+        if isinstance(val, str) and val:
+            return val
+    return str(data)[:200]

+ 62 - 0
backend/app/services/preset_resolver.py

@@ -30,6 +30,7 @@ from fastapi import HTTPException
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.cloud import get_stored_token
 from backend.app.api.routes.cloud import get_stored_token
+from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -39,6 +40,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
 )
 )
+from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -85,6 +87,8 @@ async def resolve_preset_ref(
         return await _resolve_local(db, ref, slot)
         return await _resolve_local(db, ref, slot)
     if ref.source == "cloud":
     if ref.source == "cloud":
         return await _resolve_cloud(db, user, ref, slot)
         return await _resolve_cloud(db, user, ref, slot)
+    if ref.source == "orca_cloud":
+        return await _resolve_orca_cloud(db, user, ref, slot)
     if ref.source == "standard":
     if ref.source == "standard":
         return _resolve_standard(ref, slot)
         return _resolve_standard(ref, slot)
     raise HTTPException(
     raise HTTPException(
@@ -163,6 +167,64 @@ async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, sl
     return json.dumps(payload)
     return json.dumps(payload)
 
 
 
 
+async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
+    """Fetch a single profile from Orca Cloud and return its content JSON.
+
+    The route-layer service builder handles JIT token refresh and stale-credential
+    cleanup, so any exception here means a genuine fetch / network / not-found
+    problem — never a "stale token" situation the caller could retry through.
+    Permission gate matches the rest of the Orca Cloud surface so a user with
+    ``LIBRARY_UPLOAD`` but no ``ORCA_CLOUD_AUTH`` cannot slice using cloud
+    profiles even if their stored token survived a permission revocation.
+    """
+    if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
+        raise HTTPException(
+            status_code=403,
+            detail=f"Orca Cloud presets require the orca_cloud:auth permission ({slot})",
+        )
+
+    try:
+        svc = await _build_orca_service(db, user)
+    except HTTPException:
+        # Builder already produces the right user-facing error (401 not
+        # connected, 401 session refresh failed, 502 unreachable).
+        raise
+
+    try:
+        profile = await svc.get_profile(ref.id)
+    except OrcaCloudAuthError as e:
+        raise HTTPException(
+            status_code=401,
+            detail=f"Orca Cloud session expired while fetching {slot} preset. Sign in again and retry.",
+        ) from e
+    except OrcaCloudError as e:
+        if "not found" in str(e).lower():
+            raise HTTPException(
+                status_code=400,
+                detail=f"Orca Cloud {slot} preset {ref.id!r} not found.",
+            ) from e
+        raise HTTPException(
+            status_code=502,
+            detail=f"Orca Cloud unreachable while fetching {slot} preset: {e}",
+        ) from e
+    finally:
+        await svc.close()
+
+    # ``profile`` is the ProfileUpsert shape — the inner ``content`` is the
+    # actual slicer-format JSON. Fall back to forwarding the wrapper if the
+    # shape doesn't match what we expect (defensive, in case Orca evolves
+    # the wire format).
+    content = profile.get("content") if isinstance(profile, dict) else None
+    if not isinstance(content, dict):
+        logger.info(
+            "Orca Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
+            ref.id,
+            slot,
+        )
+        content = profile
+    return json.dumps(content)
+
+
 def _resolve_standard(ref: PresetRef, slot: str) -> str:
 def _resolve_standard(ref: PresetRef, slot: str) -> str:
     """Build a minimal `{name, inherits, from, type}` stub. The sidecar's
     """Build a minimal `{name, inherits, from, type}` stub. The sidecar's
     resolver walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
     resolver walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,

+ 154 - 49
backend/app/services/print_scheduler.py

@@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
 from backend.app.core.database import async_session, run_with_retry
+from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
@@ -1762,13 +1763,21 @@ class PrintScheduler:
         return False
         return False
 
 
     async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:
     async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:
-        """Check if the previous print on this printer succeeded."""
-        # Find the most recent completed queue item for this printer
+        """Check if the previous print on this printer succeeded.
+
+        A user-cancelled predecessor is treated as neutral — `cancelled` is a
+        deliberate action, not a failure, so subsequent items should still
+        dispatch (#1667). `skipped` is excluded from the lookback entirely:
+        a skip isn't an actual print attempt, so it must not gate downstream
+        items — counting it as a failed predecessor was the cascade bug that
+        let a single cancellation block 18 items over 3 days for the reporter.
+        Only `failed` and `aborted` — real print-attempt failures — block.
+        """
         result = await db.execute(
         result = await db.execute(
             select(PrintQueueItem)
             select(PrintQueueItem)
             .where(PrintQueueItem.printer_id == item.printer_id)
             .where(PrintQueueItem.printer_id == item.printer_id)
             .where(PrintQueueItem.id != item.id)
             .where(PrintQueueItem.id != item.id)
-            .where(PrintQueueItem.status.in_(["completed", "failed", "skipped", "aborted"]))
+            .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled", "aborted"]))
             .order_by(PrintQueueItem.completed_at.desc())
             .order_by(PrintQueueItem.completed_at.desc())
             .limit(1)
             .limit(1)
         )
         )
@@ -1778,7 +1787,7 @@ class PrintScheduler:
         if not prev_item:
         if not prev_item:
             return True
             return True
 
 
-        return prev_item.status == "completed"
+        return prev_item.status in ("completed", "cancelled")
 
 
     async def _power_off_if_needed(self, db: AsyncSession, item: PrintQueueItem):
     async def _power_off_if_needed(self, db: AsyncSession, item: PrintQueueItem):
         """Power off printer if auto_off_after is enabled (waits for cooldown)."""
         """Power off printer if auto_off_after is enabled (waits for cooldown)."""
@@ -1837,6 +1846,14 @@ class PrintScheduler:
         since been swapped to one with enough material clears the flag here
         since been swapped to one with enough material clears the flag here
         so the next scheduler tick dispatches it.
         so the next scheduler tick dispatches it.
         """
         """
+        # User has explicitly acknowledged the deficit ("Print Anyway") —
+        # don't re-flag, don't even compute. Without this short-circuit the
+        # scheduler bounces between "user said anyway" (route clears
+        # manual_start) and "scheduler re-blocked" (this method re-flags it
+        # on identical spool state) (#1698-followup).
+        if item.skip_filament_check:
+            return False
+
         try:
         try:
             deficit = await compute_deficit_for_queue_item(db, item)
             deficit = await compute_deficit_for_queue_item(db, item)
         except Exception as e:
         except Exception as e:
@@ -1874,6 +1891,23 @@ class PrintScheduler:
             await db.commit()
             await db.commit()
         return False
         return False
 
 
+    async def _propagate_owner_to_printer_manager(self, db: AsyncSession, item: PrintQueueItem) -> None:
+        """Hand the queue item's owner to printer_manager so the
+        print-complete callback can credit the user in PrintLogEntry (#1670).
+
+        No-ops when the item has no `created_by_id` or the referenced user
+        row is missing (e.g. user deleted between queue-add and dispatch —
+        in that case the print log row falls back to the existing un-credited
+        behaviour rather than crashing the dispatch).
+        """
+        if not item.created_by_id:
+            return
+        from backend.app.models.user import User
+
+        owner = await db.get(User, item.created_by_id)
+        if owner:
+            printer_manager.set_current_print_user(item.printer_id, owner.id, owner.username)
+
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
         """Upload file and start print for a queue item.
         """Upload file and start print for a queue item.
 
 
@@ -2117,8 +2151,19 @@ class PrintScheduler:
                 archive.id,
                 archive.id,
                 ams_mapping=ams_mapping,
                 ams_mapping=ams_mapping,
                 created_by_id=item.created_by_id,
                 created_by_id=item.created_by_id,
+                plate_id=item.plate_id,
             )
             )
 
 
+        # Propagate the queue item's owner into printer_manager so the
+        # print-complete callback can credit the user in the PrintLogEntry
+        # (#1670). The dispatch path in `background_dispatch.py` does the
+        # equivalent for archive/library "Print" flows; the queue path was
+        # missing this hop, which left the print log's User column blank
+        # for any print started from the queue. `created_by_id` is set
+        # either at queue-add time (UI-added items) or when the user
+        # clicks the manual-start button (#1670 fix in print_queue.py).
+        await self._propagate_owner_to_printer_manager(db, item)
+
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # This prevents phantom reprints if the backend crashes/restarts after the
         # This prevents phantom reprints if the backend crashes/restarts after the
         # print command is sent but before the status update is committed.
         # print command is sent but before the status update is committed.
@@ -2142,6 +2187,22 @@ class PrintScheduler:
         pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
         pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
         pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
         pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
 
 
+        # #1397: force timelapse on when capture_finish_photo is enabled so
+        # the finish-photo extractor has something to pull from. Same override
+        # semantics as background_dispatch.py — both queue paths must apply
+        # the same rule or queued prints slip through without a finish photo.
+        # When archive_print failed (library_file path, line 1968 except), we
+        # have no archive to mark — fall back to the literal user choice; the
+        # downstream finish-photo path can't run without an archive anyway.
+        if archive is not None:
+            from backend.app.services.background_dispatch import resolve_effective_timelapse
+
+            effective_timelapse = await resolve_effective_timelapse(
+                db, archive, user_wanted_timelapse=bool(item.timelapse)
+            )
+        else:
+            effective_timelapse = bool(item.timelapse)
+
         # Start the print with AMS mapping, plate_id and print options
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
         started = printer_manager.start_print(
             item.printer_id,
             item.printer_id,
@@ -2152,8 +2213,9 @@ class PrintScheduler:
             flow_cali=item.flow_cali,
             flow_cali=item.flow_cali,
             vibration_cali=item.vibration_cali,
             vibration_cali=item.vibration_cali,
             layer_inspect=item.layer_inspect,
             layer_inspect=item.layer_inspect,
-            timelapse=item.timelapse,
+            timelapse=effective_timelapse,
             use_ams=item.use_ams,
             use_ams=item.use_ams,
+            nozzle_offset_cali=item.nozzle_offset_cali,
         )
         )
 
 
         if started:
         if started:
@@ -2180,14 +2242,15 @@ class PrintScheduler:
             # that would otherwise cause the item to re-dispatch as a reprint
             # that would otherwise cause the item to re-dispatch as a reprint
             # of the just-finished job (#1078).
             # of the just-finished job (#1078).
             if pre_state:
             if pre_state:
-                asyncio.create_task(
+                spawn_background_task(
                     self._watchdog_print_start(
                     self._watchdog_print_start(
                         item.id,
                         item.id,
                         item.printer_id,
                         item.printer_id,
                         pre_state,
                         pre_state,
                         pre_subtask_id,
                         pre_subtask_id,
                         pre_gcode_file,
                         pre_gcode_file,
-                    )
+                    ),
+                    name=f"watchdog-print-start-{item.id}",
                 )
                 )
 
 
             # Get estimated time for notification
             # Get estimated time for notification
@@ -2262,31 +2325,39 @@ class PrintScheduler:
         pre_subtask_id: str | None = None,
         pre_subtask_id: str | None = None,
         pre_gcode_file: str | None = None,
         pre_gcode_file: str | None = None,
         timeout: float = 90.0,
         timeout: float = 90.0,
+        phase_b_timeout: float = 180.0,
         poll_interval: float = 3.0,
         poll_interval: float = 3.0,
     ) -> None:
     ) -> None:
         """Revert a queue item if the printer never acknowledges the start command.
         """Revert a queue item if the printer never acknowledges the start command.
 
 
         Bambuddy optimistically marks the queue item as "printing" right after the
         Bambuddy optimistically marks the queue item as "printing" right after the
-        MQTT project_file publish succeeds locally. If the printer drops/ignores the
-        command (half-broken MQTT session — #887/#936), the state never transitions
-        and the item would otherwise stay stuck in "printing" forever (#967).
-
-        Exit paths (printer picked up the job — no revert):
-          - gcode_state changed from pre_state, OR
-          - subtask_id advanced past pre_subtask_id — the printer echoes our
-            per-dispatch identity back on push_status, so a subtask_id change is
-            a definitive "command landed" signal even while state is still FINISH.
-            H2D can sit at FINISH for ~50 s after accepting project_file before
-            transitioning to PREPARE, which used to trip the state-only watchdog
-            and caused the scheduler to revert + re-dispatch the item; the next
-            successful dispatch then looked like a reprint of the just-finished
-            job (#1078).
-
-        Timeout raised from 45 s → 90 s as belt-and-braces for slow transitions
-        that also don't emit an early subtask_id tick.
+        MQTT project_file publish succeeds locally. The watchdog runs in two phases:
+
+        Phase A (up to ``timeout``): wait for either an active-state transition
+        or a ``subtask_id`` advance past ``pre_subtask_id``. State alone is the
+        primary signal; subtask_id advance handles the H2D case where state can
+        sit at FINISH for ~50 s after the printer accepted ``project_file``
+        before flipping to PREPARE (#1078). If neither happens, the MQTT publish
+        was lost on a half-broken session (#887/#936) — revert and force
+        reconnect (the #967 recovery path).
+
+        Phase B (up to ``phase_b_timeout``, only if Phase A exited on subtask_id
+        alone): keep watching for the active-state transition. subtask_id alone
+        proves the file landed but not that the printer started — and a printer
+        that accepts the command but stays at IDLE/FINISH indefinitely (e.g.
+        cloud+LAN re-auth dance after a power cycle on old firmware, #1678)
+        used to leave the queue item stuck in 'printing' forever because the
+        old watchdog returned success as soon as subtask_id advanced. If Phase
+        B times out, revert the queue item so the user can retry without
+        restarting Bambuddy. Skip ``force_reconnect`` here: the file landed and
+        a forced reconnect mid-parse triggers 0500_4003 (#1150).
+
+        Phase A timeout raised from 45 s → 90 s as belt-and-braces for slow
+        transitions that also don't emit an early subtask_id tick.
         """
         """
-        deadline = time.monotonic() + timeout
         last_status = None
         last_status = None
+        landed_on_subtask = False
+        deadline = time.monotonic() + timeout
         while time.monotonic() < deadline:
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
             await asyncio.sleep(poll_interval)
             status = printer_manager.get_status(printer_id)
             status = printer_manager.get_status(printer_id)
@@ -2309,14 +2380,28 @@ class PrintScheduler:
                 scheduler._release_dispatch_hold(printer_id)
                 scheduler._release_dispatch_hold(printer_id)
                 return
                 return
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
-                # Printer picked up the job (subtask_id advanced). H2D can
-                # sit at FINISH for ~50 s after accepting project_file
-                # before transitioning to PREPARE, but the subtask_id flips
-                # to our submission_id almost immediately (#1078).
-                scheduler._release_dispatch_hold(printer_id)
-                return
-
-        # No transition. Revert the item so the scheduler can retry.
+                # Phase A exit — printer accepted the file (subtask_id flipped
+                # to our submission id). Don't return yet: the printer may
+                # have accepted the command but never actually start (e.g.
+                # cloud+LAN re-auth dance after a power cycle, #1678). Phase
+                # B watches for the active-state transition.
+                landed_on_subtask = True
+                break
+
+        if landed_on_subtask:
+            phase_b_deadline = time.monotonic() + phase_b_timeout
+            while time.monotonic() < phase_b_deadline:
+                await asyncio.sleep(poll_interval)
+                status = printer_manager.get_status(printer_id)
+                if not status:
+                    scheduler._release_dispatch_hold(printer_id)
+                    return
+                last_status = status
+                if status.state in _ACTIVE_PRINT_STATES:
+                    scheduler._release_dispatch_hold(printer_id)
+                    return
+
+        # No active-state transition. Revert the item so the scheduler can retry.
         # Drop the in-memory hold so the retry isn't blocked by it.
         # Drop the in-memory hold so the retry isn't blocked by it.
         scheduler._release_dispatch_hold(printer_id)
         scheduler._release_dispatch_hold(printer_id)
 
 
@@ -2357,24 +2442,44 @@ class PrintScheduler:
             # session breaks ongoing prints on the same printer.
             # session breaks ongoing prints on the same printer.
             return
             return
 
 
+        total_timeout = timeout + (phase_b_timeout if landed_on_subtask else 0.0)
         if revert_outcome == "reverted":
         if revert_outcome == "reverted":
-            logger.warning(
-                "Queue item %s: printer %d did not respond to print command within "
-                "%.0fs (state still %s, subtask_id still %s) — reverted to 'pending' "
-                "for retry (#967)",
-                queue_item_id,
-                printer_id,
-                timeout,
-                pre_state,
-                pre_subtask_id,
-            )
+            if landed_on_subtask:
+                logger.warning(
+                    "Queue item %s: printer %d accepted project_file (subtask_id "
+                    "advanced) but never transitioned to an active state within "
+                    "%.0fs — printer wedged post-acceptance; reverted to 'pending' "
+                    "for retry (#1678)",
+                    queue_item_id,
+                    printer_id,
+                    total_timeout,
+                )
+            else:
+                logger.warning(
+                    "Queue item %s: printer %d did not respond to print command within "
+                    "%.0fs (state still %s, subtask_id still %s) — reverted to 'pending' "
+                    "for retry (#967)",
+                    queue_item_id,
+                    printer_id,
+                    timeout,
+                    pre_state,
+                    pre_subtask_id,
+                )
+
+        # Phase B was entered iff subtask_id advanced, which means the
+        # project_file landed on the printer. A forced reconnect at this point
+        # would interrupt the printer's parse and trigger 0500_4003 (#1150) —
+        # skip the recovery entirely.
+        if landed_on_subtask:
+            return
 
 
-        # Same #1150 / #887/#936 discriminator as background_dispatch: if the
-        # printer's gcode_file changed since pre-dispatch, the project_file
-        # command landed and the printer is parsing — a forced reconnect
-        # mid-parse triggers 0500_4003. If gcode_file is unchanged, the
-        # publish was silently swallowed (#887/#936) and the original
-        # force_reconnect recovery is what we want.
+        # Phase A timeout path — same #1150 / #887/#936 discriminator as
+        # background_dispatch: if the printer's gcode_file changed since
+        # pre-dispatch, the project_file command landed and the printer is
+        # parsing — a forced reconnect mid-parse triggers 0500_4003. If
+        # gcode_file is unchanged, the publish was silently swallowed
+        # (#887/#936) and the original force_reconnect recovery is what we
+        # want.
         client = printer_manager.get_client(printer_id)
         client = printer_manager.get_client(printer_id)
         current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
         current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
         publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
         publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file

+ 84 - 1
backend/app/services/printer_diagnostic.py

@@ -28,6 +28,16 @@ PORT_RTSPS = 322  # RTSPS — camera stream; optional.
 
 
 _PORT_PROBE_TIMEOUT = 3.0
 _PORT_PROBE_TIMEOUT = 3.0
 
 
+# Default seconds the `printer_publishing` check will wait for the first
+# report-topic message before declaring fail. Bambu printers in idle publish
+# push_status every few seconds; 10s catches healthy bridges with margin while
+# staying short enough that the spinner-with-countdown UX stays acceptable.
+# The check exits the moment a message arrives, so the typical wall-clock is
+# 1–2s, not the full 10. Passed as ``wait_for_publish_seconds`` per call so
+# the support-package code path can skip the wait entirely (defaults to 0).
+PUBLISH_WAIT_DEFAULT = 10.0
+_PUBLISH_POLL_INTERVAL = 0.5
+
 
 
 async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
 async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
     """Test TCP connectivity to ip:port. Returns True if reachable."""
     """Test TCP connectivity to ip:port. Returns True if reachable."""
@@ -93,6 +103,7 @@ async def run_connection_diagnostic(
     printer: Printer | None = None,
     printer: Printer | None = None,
     serial_number: str | None = None,
     serial_number: str | None = None,
     access_code: str | None = None,
     access_code: str | None = None,
+    wait_for_publish_seconds: float = 0.0,
 ) -> PrinterDiagnosticResult:
 ) -> PrinterDiagnosticResult:
     """Run connection checks for a printer.
     """Run connection checks for a printer.
 
 
@@ -149,8 +160,35 @@ async def run_connection_diagnostic(
                 )
                 )
             )
             )
 
 
-    # --- MQTT credentials / connection ---
+    # --- External storage (printer-side "Store sent files on external storage") ---
+    # Install step 4. The setting has two variants depending on
+    # firmware/slicer combo: on newer firmware the toggle lives on the
+    # printer (P2S 01.02 / BambuStudio 2.6+), on older versions it's
+    # purely a slicer-side preference.
+    #
+    # For the printer-side variant, `home_flag` bit 11 is pushed on every
+    # status report and parsed into state.store_to_sdcard (bambu_mqtt.py
+    # line 153). That's the signal here — instant, no FTP I/O.
+    #
+    # For the slicer-side variant, the printer never hears about it and
+    # this check will pass even when the user is missing step 4. That gap
+    # is covered separately by the "no_3mf_available" archive-fallback
+    # banner. An FTP upload-and-verify probe was tried and rejected — the
+    # /cache directory is always writable from Bambuddy regardless of
+    # either toggle, so the probe always passes and detects nothing.
     state = printer_manager.get_status(printer.id) if printer else None
     state = printer_manager.get_status(printer.id) if printer else None
+    if state is None or not state.connected:
+        checks.append(DiagnosticCheck(id="external_storage", status="skip"))
+    elif getattr(state, "store_to_sdcard", None) is True:
+        checks.append(DiagnosticCheck(id="external_storage", status="pass"))
+    elif getattr(state, "store_to_sdcard", None) is False:
+        checks.append(DiagnosticCheck(id="external_storage", status="fail"))
+    else:
+        # State exists but the field was never populated — skip rather than
+        # report a false fail.
+        checks.append(DiagnosticCheck(id="external_storage", status="skip"))
+
+    # --- MQTT credentials / connection ---
     if not mqtt_ok:
     if not mqtt_ok:
         # Can't reach the broker at all — the port check already reported it.
         # Can't reach the broker at all — the port check already reported it.
         checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
         checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
@@ -185,6 +223,51 @@ async def run_connection_diagnostic(
     else:
     else:
         checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
         checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
 
 
+    # --- Printer is actually publishing on its report topic ---
+    # The mqtt_auth check above only proves TCP + TLS + auth + SUBSCRIBE
+    # succeed. A printer with a wrong-cased serial — or one that simply isn't
+    # publishing for some other reason — still passes mqtt_auth because the
+    # broker accepts the subscription regardless. The user-visible symptom in
+    # that case is "AMS / K-profiles / custom filaments missing on the slicer
+    # side": the VP bridge has nothing cached to mirror because no reports
+    # arrived. #1622 surfaced this: bridge keep-alive timeouts paired with
+    # the `Connected and subscribed, but the printer has sent zero status
+    # reports` warning. The check below turns that warning into a structured
+    # diagnostic result the user can act on without grepping container logs.
+    #
+    # If ``_report_messages_since_connect`` is already > 0, we exit
+    # immediately — the bridge has seen reports. If it's 0 and a wait is
+    # requested, we poll every PUBLISH_POLL_INTERVAL up to
+    # ``wait_for_publish_seconds`` so a fresh reconnect (counter reset to 0)
+    # isn't reported as fail before the printer's first idle push lands.
+    publishing_params: dict[str, int | float] | None = None
+    publishing_status = "skip"
+    if printer is not None and state is not None and state.connected:
+        client = printer_manager.get_client(printer.id)
+        if client is not None:
+            wait_budget = max(wait_for_publish_seconds, 0.0)
+            if wait_budget > 0:
+                # Expose the budget so the UI can render a countdown next to
+                # the spinner — the user knows how long this check might take.
+                publishing_params = {"max_wait_seconds": wait_budget}
+            loop = asyncio.get_running_loop()
+            deadline = loop.time() + wait_budget
+            while True:
+                if client.report_messages_since_connect > 0:
+                    publishing_status = "pass"
+                    break
+                if loop.time() >= deadline:
+                    publishing_status = "fail"
+                    break
+                await asyncio.sleep(_PUBLISH_POLL_INTERVAL)
+    checks.append(
+        DiagnosticCheck(
+            id="printer_publishing",
+            status=publishing_status,
+            params=publishing_params or {},
+        )
+    )
+
     statuses = {c.status for c in checks}
     statuses = {c.status for c in checks}
     if "fail" in statuses:
     if "fail" in statuses:
         overall = "problems"
         overall = "problems"

+ 6 - 1
backend/app/services/printer_manager.py

@@ -122,6 +122,9 @@ def is_bed_slinger(model: str | None) -> bool:
 _DRYING_MIN_FIRMWARE: dict[str, str] = {
 _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "H2D": "01.02.30.00",
     "H2D": "01.02.30.00",
     "H2S": "01.02.00.00",
     "H2S": "01.02.00.00",
+    "H2C": "01.02.00.00",
+    "O1C": "01.02.00.00",  # H2C SSDP model code
+    "O1C2": "01.02.00.00",  # H2C dual-nozzle SSDP model code
     "X1": "01.09.00.00",
     "X1": "01.09.00.00",
     "X1C": "01.09.00.00",
     "X1C": "01.09.00.00",
     "P1P": "01.08.00.00",
     "P1P": "01.08.00.00",
@@ -130,7 +133,7 @@ _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "N7": "01.02.00.00",  # P2S internal model code
     "N7": "01.02.00.00",  # P2S internal model code
 }
 }
 # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
 # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
-_DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "H2C", "O1C", "O1C2", "O1S", "N1", "N2S"})
+_DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "O1S", "N1", "N2S"})
 
 
 
 
 def supports_drying(model: str | None, firmware: str | None) -> bool:
 def supports_drying(model: str | None, firmware: str | None) -> bool:
@@ -505,6 +508,7 @@ class PrinterManager:
         layer_inspect: bool = False,
         layer_inspect: bool = False,
         timelapse: bool = False,
         timelapse: bool = False,
         use_ams: bool = True,
         use_ams: bool = True,
+        nozzle_offset_cali: bool = False,
     ) -> bool:
     ) -> bool:
         """Start a print on a connected printer."""
         """Start a print on a connected printer."""
         caller = traceback.extract_stack(limit=3)[0]
         caller = traceback.extract_stack(limit=3)[0]
@@ -527,6 +531,7 @@ class PrinterManager:
                 vibration_cali=vibration_cali,
                 vibration_cali=vibration_cali,
                 layer_inspect=layer_inspect,
                 layer_inspect=layer_inspect,
                 use_ams=use_ams,
                 use_ams=use_ams,
+                nozzle_offset_cali=nozzle_offset_cali,
             )
             )
         return False
         return False
 
 

+ 4 - 3
backend/app/services/smart_plug_manager.py

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.tasks import spawn_background_task
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
@@ -340,7 +341,7 @@ class SmartPlugManager:
         logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
         logger.info("Scheduling turn-off for plug '%s' in %s seconds", plug.name, delay_seconds)
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
-        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
+        spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
 
 
         task = asyncio.create_task(
         task = asyncio.create_task(
             self._delayed_off(
             self._delayed_off(
@@ -419,7 +420,7 @@ class SmartPlugManager:
         logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
         logger.info("Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)", plug.name, temp_threshold)
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
-        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
+        spawn_background_task(self._mark_auto_off_pending(plug.id, True), name=f"plug-auto-off-pending-{plug.id}")
 
 
         task = asyncio.create_task(
         task = asyncio.create_task(
             self._temp_based_off(
             self._temp_based_off(
@@ -579,7 +580,7 @@ class SmartPlugManager:
             self._pending_off[plug_id].cancel()
             self._pending_off[plug_id].cancel()
             del self._pending_off[plug_id]
             del self._pending_off[plug_id]
             # Clear pending state in database
             # Clear pending state in database
-            asyncio.create_task(self._mark_auto_off_pending(plug_id, False))
+            spawn_background_task(self._mark_auto_off_pending(plug_id, False), name=f"plug-auto-off-pending-{plug_id}")
 
 
     def cancel_all_pending(self):
     def cancel_all_pending(self):
         """Cancel all pending turn-off tasks."""
         """Cancel all pending turn-off tasks."""

+ 549 - 0
backend/app/services/spool_csv.py

@@ -0,0 +1,549 @@
+"""CSV import/export for the spool inventory (#1576).
+
+One module owns the round-trip: the same fixed column schema is used to
+serialise existing spools out and to parse + validate a user-supplied CSV
+back in. Validation reuses the `SpoolCreate` Pydantic model so the CSV path
+and the form path share a single source of truth — anything the form rejects,
+the import rejects too, with the same rules.
+
+The import flow is two-phase by design: `parse_and_validate()` never writes.
+The route calls it once for the dry-run preview (so the user sees per-row
+valid/error/skipped before committing) and again on confirm, then persists
+only the rows that came back `valid`.
+"""
+
+import csv
+import io
+from datetime import datetime
+
+from pydantic import BaseModel, ValidationError
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.spool import Spool
+from backend.app.schemas.spool import SpoolCreate
+
+# Fixed CSV header, in output order. Round-trips cleanly: export writes these
+# columns, import expects them. `material` is the only required field; the rest
+# are optional. Keep aligned with the SpoolCreate fields referenced below.
+#
+# `remaining` is a derived, export-only column (= label_weight - weight_used).
+# It's written out for human readability and round-trip clarity, but ignored on
+# import — `weight_used` is the source of truth, and accepting both would let
+# them contradict. `last_used` is a timestamp the model carries but SpoolCreate
+# does not, so import applies it to the ORM object directly (see persist path).
+# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate
+# fields included so a round-trip preserves them (they'd otherwise be lost).
+CSV_COLUMNS = [
+    "material",
+    "brand",
+    "subtype",
+    "color_name",
+    "rgba",
+    "extra_colors",
+    "effect_type",
+    "label_weight",
+    "weight_used",
+    "remaining",
+    "cost_per_kg",
+    "nozzle_temp_min",
+    "nozzle_temp_max",
+    "last_used",
+    "note",
+    "storage_location",
+    "category",
+    "low_stock_threshold_pct",
+]
+
+# Upload ceiling for the import endpoint. A spool inventory CSV is a few KB
+# even with thousands of rows; 5 MB is a generous cap that still refuses an
+# OOM-sized body before it's read into memory.
+MAX_CSV_IMPORT_BYTES = 5 * 1024 * 1024
+
+# Spreadsheet formula-injection guard. A cell whose first character is one of
+# these is treated as a formula by Excel / LibreOffice / Sheets; we prefix it
+# with a single quote on export so the value renders as literal text.
+_FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
+
+# Columns whose CSV cell must be coerced to a number before SpoolCreate sees it.
+# DictReader hands us strings; SpoolCreate wants int/float. Empty cell → omit
+# the field (falls back to the schema default / None).
+_INT_COLUMNS = {"label_weight", "nozzle_temp_min", "nozzle_temp_max", "low_stock_threshold_pct"}
+_FLOAT_COLUMNS = {"cost_per_kg", "weight_used"}
+
+# label_weight default, pulled from the schema so the weight_used bounds check
+# stays in sync if the schema default ever changes.
+_DEFAULT_LABEL_WEIGHT = SpoolCreate.model_fields["label_weight"].default
+
+
+class ImportRowResult(BaseModel):
+    """Per-row outcome of a parse+validate pass.
+
+    `spool` carries the validated, SpoolCreate-shaped dict for `valid` rows so
+    the route can persist without re-parsing. `resolved_color` flags rows whose
+    rgba/extra_colors/effect_type were filled in from the Color Catalog rather
+    than supplied in the CSV — surfaced in the preview so the user knows a
+    colour was inferred.
+    """
+
+    row_number: int  # 1-based data row (header is not counted)
+    status: str  # "valid" | "error" | "skipped"
+    reason: str | None = None
+    material: str | None = None
+    brand: str | None = None
+    color_name: str | None = None
+    rgba: str | None = None
+    resolved_color: bool = False
+    # True when the colour was resolved from a catalog entry of a DIFFERENT
+    # material (no exact material match existed). Surfaced so the preview can
+    # warn the user the colour came from another material's variant.
+    cross_material_color: bool = False
+    # True when an active spool with the same material+brand+color_name already
+    # exists. Informational only — the import still creates the row (there's no
+    # unique constraint); the preview warns so a double-click / re-upload of the
+    # same CSV doesn't silently duplicate the inventory.
+    duplicate_of_existing: bool = False
+    spool: dict | None = None
+
+
+class ImportPreview(BaseModel):
+    """Result of a dry-run (or the pre-write pass of a real import)."""
+
+    columns: list[str]
+    total: int
+    valid_count: int
+    error_count: int
+    skipped_count: int
+    rows: list[ImportRowResult]
+    warnings: list[str] = []
+
+
+class ImportResult(BaseModel):
+    """Summary returned after a real (non-dry-run) import."""
+
+    created: int
+    skipped: int
+    errors: int
+    error_rows: list[ImportRowResult] = []
+
+
+def _normalize_header(name: str) -> str:
+    """Map a CSV header cell to a canonical field name.
+
+    Case- and space-tolerant: "Color Name", "color-name", " COLOR_NAME "
+    all collapse to "color_name".
+    """
+    return name.strip().lower().replace(" ", "_").replace("-", "_")
+
+
+def _normalize_rgba(value: str) -> str | None:
+    """Coerce a user-supplied colour cell to 8-char RRGGBBAA hex, or None.
+
+    Accepts an optional leading `#` and a 6-char RRGGBB form (alpha defaults to
+    `ff`). Returns None if the value isn't valid hex of length 6 or 8 — the
+    caller turns that into a row error so it isn't silently dropped.
+    """
+    raw = value.strip().lstrip("#")
+    if len(raw) not in (6, 8):
+        return None
+    try:
+        int(raw, 16)
+    except ValueError:
+        return None
+    if len(raw) == 6:
+        raw += "ff"
+    return raw.lower()
+
+
+def _parse_datetime(value: str) -> datetime | None:
+    """Parse an ISO-8601 timestamp, or None if it isn't valid.
+
+    Accepts what `datetime.isoformat()` produces (what export writes) plus a
+    trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
+    """
+    raw = value.strip()
+    if not raw:
+        return None
+    if raw.endswith("Z"):
+        raw = raw[:-1] + "+00:00"
+    try:
+        return datetime.fromisoformat(raw)
+    except ValueError:
+        return None
+
+
+async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:
+    """Load the whole Color Catalog once so per-row resolution is in-memory.
+
+    A CSV can hold hundreds of rows; resolving each with its own SELECT would
+    be an N+1 against a small, rarely-changing table. We pull it once here and
+    let `_resolve_color` match against the list.
+    """
+    result = await db.execute(select(ColorCatalogEntry))
+    return list(result.scalars().all())
+
+
+def _spool_key(material: str | None, brand: str | None, color_name: str | None) -> tuple[str, str, str]:
+    """Case/space-insensitive identity used for the duplicate soft-warn."""
+    return (
+        (material or "").strip().lower(),
+        (brand or "").strip().lower(),
+        (color_name or "").strip().lower(),
+    )
+
+
+async def _load_existing_spool_keys(db: AsyncSession) -> set[tuple[str, str, str]]:
+    """Load material+brand+color_name keys of active spools for the dup warning.
+
+    Spool has no unique constraint, so a double-click or re-upload of the same
+    CSV would silently duplicate the inventory. We pull the active spools' keys
+    once and let the preview flag matching rows — informational only, the import
+    still creates them.
+    """
+    result = await db.execute(select(Spool.material, Spool.brand, Spool.color_name).where(Spool.archived_at.is_(None)))
+    return {_spool_key(m, b, c) for m, b, c in result.all()}
+
+
+def _resolve_color(
+    catalog: list[ColorCatalogEntry], brand: str | None, color_name: str | None, material: str | None
+) -> tuple[str, str | None, str | None, bool] | None:
+    """Match brand + color_name against the preloaded catalog (case-insensitive).
+
+    Returns (rgba, extra_colors, effect_type, cross_material) on a match, else
+    None. Prefers an entry whose material matches the row; a catalog entry with
+    a NULL material is the project's "matches any material" convention and counts
+    as an exact match too. Only when neither exists does it fall back to another
+    material's entry and set cross_material=True so the caller can warn that the
+    colour came from a different material's variant.
+    """
+    if not brand or not color_name:
+        return None
+
+    brand_l = brand.strip().lower()
+    name_l = color_name.strip().lower()
+    material_l = material.strip().lower() if material else None
+
+    matches = [
+        entry
+        for entry in catalog
+        if entry.hex_color and entry.manufacturer.lower() == brand_l and entry.color_name.lower() == name_l
+    ]
+    if not matches:
+        return None
+
+    exact = next(
+        (e for e in matches if e.material is None or (material_l and e.material.lower() == material_l)),
+        None,
+    )
+    row = exact or matches[0]
+    cross_material = exact is None
+
+    rgba = _normalize_rgba(row.hex_color)
+    if rgba is None:
+        return None
+    return rgba, row.extra_colors, row.effect_type, cross_material
+
+
+def _readable_validation_error(exc: ValidationError) -> str:
+    """Flatten a Pydantic ValidationError into one short, user-facing line."""
+    parts = []
+    for err in exc.errors():
+        loc = ".".join(str(p) for p in err.get("loc", ())) or "value"
+        parts.append(f"{loc}: {err.get('msg', 'invalid')}")
+    return "; ".join(parts)
+
+
+def _empty_preview(warnings: list[str]) -> ImportPreview:
+    """A preview with no rows — used for the early-exit cases (bad/empty file)."""
+    return ImportPreview(
+        columns=CSV_COLUMNS,
+        total=0,
+        valid_count=0,
+        error_count=0,
+        skipped_count=0,
+        rows=[],
+        warnings=warnings,
+    )
+
+
+async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPreview:
+    """Parse a CSV blob, validate + colour-resolve each row. Never writes.
+
+    Decodes UTF-8 (BOM tolerant), reads with DictReader against the fixed
+    schema, and classifies each row as valid / error / skipped. Valid rows
+    carry a SpoolCreate-shaped `spool` dict ready to persist.
+    """
+    warnings: list[str] = []
+
+    try:
+        text = raw_bytes.decode("utf-8-sig")
+    except UnicodeDecodeError:
+        return _empty_preview(["File is not valid UTF-8 text."])
+
+    reader = csv.reader(io.StringIO(text))
+    try:
+        header = next(reader)
+    except StopIteration:
+        return _empty_preview(["CSV is empty."])
+
+    norm_header = [_normalize_header(h) for h in header]
+    known = set(CSV_COLUMNS)
+    unknown = [h for h in norm_header if h and h not in known]
+    if unknown:
+        warnings.append(f"Ignoring unknown columns: {', '.join(unknown)}")
+    # Map canonical field name → column index in this file (first occurrence).
+    col_index: dict[str, int] = {}
+    for idx, h in enumerate(norm_header):
+        if h in known and h not in col_index:
+            col_index[h] = idx
+
+    if "material" not in col_index:
+        return _empty_preview(warnings + ["Required column 'material' is missing from the header."])
+
+    # Pull the catalog and the existing-spool keys once; per-row colour
+    # resolution and the duplicate soft-warn both match in memory rather than
+    # issuing a SELECT per row.
+    catalog = await _load_color_catalog(db)
+    existing_keys = await _load_existing_spool_keys(db)
+
+    def cell(row: list[str], field: str) -> str:
+        idx = col_index.get(field)
+        if idx is None or idx >= len(row):
+            return ""
+        # Strip whitespace, then undo any export-side formula-injection quoting
+        # so export → import round-trips without accumulating a leading quote.
+        return _desanitize_cell(row[idx].strip())
+
+    rows: list[ImportRowResult] = []
+    valid = error = skipped = 0
+
+    for row_number, raw_row in enumerate(reader, start=1):
+        # Fully blank row (no non-empty cell) → skip silently.
+        if not any(c.strip() for c in raw_row):
+            rows.append(ImportRowResult(row_number=row_number, status="skipped", reason="Empty row"))
+            skipped += 1
+            continue
+
+        material = cell(raw_row, "material")
+        brand = cell(raw_row, "brand") or None
+        color_name = cell(raw_row, "color_name") or None
+
+        if not material:
+            rows.append(
+                ImportRowResult(
+                    row_number=row_number,
+                    status="error",
+                    reason="material is required",
+                    brand=brand,
+                    color_name=color_name,
+                )
+            )
+            error += 1
+            continue
+
+        data: dict = {"material": material}
+        if brand:
+            data["brand"] = brand
+        if color_name:
+            data["color_name"] = color_name
+
+        row_error: str | None = None
+
+        # Plain text passthrough columns.
+        for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"):
+            value = cell(raw_row, field)
+            if value:
+                data[field] = value
+
+        # Numeric columns: parse only if present, else leave to schema defaults.
+        for field in _INT_COLUMNS:
+            value = cell(raw_row, field)
+            if value:
+                try:
+                    data[field] = int(value)
+                except ValueError:
+                    row_error = f"{field} must be a whole number (got '{value}')"
+                    break
+        if row_error is None:
+            for field in _FLOAT_COLUMNS:
+                value = cell(raw_row, field)
+                if value:
+                    try:
+                        data[field] = float(value)
+                    except ValueError:
+                        row_error = f"{field} must be a number (got '{value}')"
+                        break
+
+        # Bounds check: weight_used must be within [0, label_weight]. The schema
+        # accepts any float, so a negative or over-full value would otherwise be
+        # imported silently. label_weight falls back to the schema default when
+        # the CSV omits it.
+        if row_error is None and "weight_used" in data:
+            used = data["weight_used"]
+            label = data.get("label_weight", _DEFAULT_LABEL_WEIGHT)
+            if used < 0:
+                row_error = f"weight_used cannot be negative (got {used})"
+            elif used > label:
+                row_error = f"weight_used ({used}) exceeds label_weight ({label})"
+
+        # `last_used` is an ORM-only timestamp (not on SpoolCreate); parse it
+        # here and apply it to the validated dict after the SpoolCreate gate.
+        last_used: datetime | None = None
+        if row_error is None:
+            last_used_cell = cell(raw_row, "last_used")
+            if last_used_cell:
+                last_used = _parse_datetime(last_used_cell)
+                if last_used is None:
+                    row_error = f"last_used must be an ISO date/time (got '{last_used_cell}')"
+
+        resolved_color = False
+        cross_material_color = False
+        if row_error is None:
+            # Colour precedence: explicit rgba wins; else resolve brand+name
+            # from the catalog; else leave blank.
+            rgba_cell = cell(raw_row, "rgba")
+            if rgba_cell:
+                normalized = _normalize_rgba(rgba_cell)
+                if normalized is None:
+                    row_error = f"rgba must be 6- or 8-char hex (got '{rgba_cell}')"
+                else:
+                    data["rgba"] = normalized
+            else:
+                resolved = _resolve_color(catalog, brand, color_name, material)
+                if resolved is not None:
+                    rgba_val, extra_val, effect_val, cross_material_color = resolved
+                    data["rgba"] = rgba_val
+                    # CSV-supplied extra_colors/effect_type take precedence over
+                    # the catalog's; only fill from catalog when absent.
+                    if extra_val and "extra_colors" not in data:
+                        data["extra_colors"] = extra_val
+                    if effect_val and "effect_type" not in data:
+                        data["effect_type"] = effect_val
+                    resolved_color = True
+
+        if row_error is not None:
+            rows.append(
+                ImportRowResult(
+                    row_number=row_number,
+                    status="error",
+                    reason=row_error,
+                    material=material,
+                    brand=brand,
+                    color_name=color_name,
+                )
+            )
+            error += 1
+            continue
+
+        # Final gate: SpoolCreate runs the same validators the form uses
+        # (rgba pattern, extra_colors/effect_type normalisation, bounds).
+        try:
+            spool = SpoolCreate(**data)
+        except ValidationError as exc:
+            rows.append(
+                ImportRowResult(
+                    row_number=row_number,
+                    status="error",
+                    reason=_readable_validation_error(exc),
+                    material=material,
+                    brand=brand,
+                    color_name=color_name,
+                )
+            )
+            error += 1
+            continue
+
+        spool_data = spool.model_dump()
+        if last_used is not None:
+            # last_used isn't a SpoolCreate field; graft it onto the persisted
+            # dict so the ORM object carries it.
+            spool_data["last_used"] = last_used
+
+        rows.append(
+            ImportRowResult(
+                row_number=row_number,
+                status="valid",
+                material=material,
+                brand=brand,
+                color_name=color_name,
+                rgba=spool.rgba,
+                resolved_color=resolved_color,
+                cross_material_color=cross_material_color,
+                duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
+                spool=spool_data,
+            )
+        )
+        valid += 1
+
+    return ImportPreview(
+        columns=CSV_COLUMNS,
+        total=valid + error + skipped,
+        valid_count=valid,
+        error_count=error,
+        skipped_count=skipped,
+        rows=rows,
+        warnings=warnings,
+    )
+
+
+def serialize(spools: list[Spool]) -> bytes:
+    """Render spools to CSV bytes using the fixed schema (export side).
+
+    rgba is written without a leading `#`, matching the import-side
+    normalisation, so export → import round-trips without transformation.
+    `remaining` is derived (label_weight - weight_used) and `last_used` is
+    written as ISO-8601; empty/None fields become empty cells.
+    """
+    output = io.StringIO()
+    writer = csv.writer(output)
+    writer.writerow(CSV_COLUMNS)
+    for spool in spools:
+        writer.writerow([_sanitize_cell(_cell_value(spool, col)) for col in CSV_COLUMNS])
+    return output.getvalue().encode("utf-8")
+
+
+def _sanitize_cell(value: str) -> str:
+    """Neutralise spreadsheet formula injection.
+
+    A free-text field (note, color_name) starting with =, +, -, @, tab, or CR
+    is evaluated as a formula by Excel/Sheets/LibreOffice when the CSV is
+    opened. Prefixing with a single quote forces it to render as literal text.
+    `_desanitize_cell` is the exact inverse, applied on import.
+    """
+    if value and value[0] in _FORMULA_INJECTION_PREFIXES:
+        return "'" + value
+    return value
+
+
+def _desanitize_cell(value: str) -> str:
+    """Undo `_sanitize_cell` on import so the round-trip is lossless.
+
+    Export prefixes formula-looking cells with a single quote; strip exactly
+    that quote back off when the next character is one of the guarded prefixes,
+    so `'=SUM(A1)` reads back as `=SUM(A1)` and the value doesn't accumulate a
+    leading quote on every export→import cycle. A quote followed by anything
+    else is left untouched — only the prefix `_sanitize_cell` could have added
+    is removed.
+    """
+    if len(value) >= 2 and value[0] == "'" and value[1] in _FORMULA_INJECTION_PREFIXES:
+        return value[1:]
+    return value
+
+
+def _cell_value(spool: Spool, col: str) -> str:
+    """Render one spool field for export. Handles the derived `remaining`
+    column and ISO-formats `last_used`; everything else is str() of the value."""
+    if col == "remaining":
+        # Derived for display: label_weight - weight_used, clamped at 0.
+        return str(max(0, round((spool.label_weight or 0) - (spool.weight_used or 0))))
+    value = getattr(spool, col, None)
+    if value is None:
+        return ""
+    if isinstance(value, datetime):
+        return value.isoformat()
+    # Whole-number floats (weight_used, cost_per_kg) export as ints — "300",
+    # not "300.0" — for a cleaner, human-friendly CSV. import re-parses fine.
+    if isinstance(value, float) and value.is_integer():
+        return str(int(value))
+    return str(value)

+ 27 - 14
backend/app/services/spoolman_tracking.py

@@ -189,6 +189,7 @@ async def store_print_data(
     db,
     db,
     printer_manager,
     printer_manager,
     ams_mapping: list[int] | None = None,
     ams_mapping: list[int] | None = None,
+    plate_id: int | None = None,
 ):
 ):
     """Store Spoolman tracking data at print start (persisted to database).
     """Store Spoolman tracking data at print start (persisted to database).
 
 
@@ -196,6 +197,13 @@ async def store_print_data(
     how the internal Filament Inventory works. The legacy AMS-remain%-based sync
     how the internal Filament Inventory works. The legacy AMS-remain%-based sync
     is no longer used as a weight writer (#1119), so this runs whenever Spoolman
     is no longer used as a weight writer (#1119), so this runs whenever Spoolman
     is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
     is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
+
+    ``plate_id``, when set, scopes the 3MF filament extract to a single plate so
+    queue / direct-Print dispatch of plate N of a multi-plate file doesn't
+    attribute every plate's filament to the printed spool (#1697). When unset,
+    the queue item's plate_id (if any) is used; otherwise the whole-file sum is
+    extracted, which is correct for direct prints that target the first/only
+    plate of a single-plate file.
     """
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
     from backend.app.models.active_print_spoolman import ActivePrintSpoolman
     from backend.app.models.active_print_spoolman import ActivePrintSpoolman
@@ -219,8 +227,20 @@ async def store_print_data(
         logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
         logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
         return
         return
 
 
-    # Extract per-filament usage from 3MF (total usage per slot)
-    filament_usage = extract_filament_usage_from_3mf(full_path)
+    # Resolve the queue item once — used both for the plate-scoped 3MF parsing
+    # fallback (#1697: multi-plate file dispatched for one plate must only count
+    # that plate's filament) and for the ams_mapping fallback below.
+    queue_result = await db.execute(
+        select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
+    )
+    queue_item = queue_result.scalar_one_or_none()
+    # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
+    # item's plate_id (queue dispatch path).
+    effective_plate_id = plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
+
+    # Extract per-filament usage from 3MF (total usage for the dispatched plate,
+    # or the whole file for direct/library prints with no plate_id).
+    filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id)
     if not filament_usage:
     if not filament_usage:
         logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
         logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
         return
         return
@@ -234,18 +254,11 @@ async def store_print_data(
     # Prefer the explicit mapping captured from the print command, then fall back
     # Prefer the explicit mapping captured from the print command, then fall back
     # to any queue mapping stored for scheduled/reprint jobs.
     # to any queue mapping stored for scheduled/reprint jobs.
     slot_to_tray = ams_mapping if ams_mapping is not None else None
     slot_to_tray = ams_mapping if ams_mapping is not None else None
-    if not slot_to_tray:
-        queue_result = await db.execute(
-            select(PrintQueueItem)
-            .where(PrintQueueItem.archive_id == archive_id)
-            .where(PrintQueueItem.status == "printing")
-        )
-        queue_item = queue_result.scalar_one_or_none()
-        if queue_item and queue_item.ams_mapping:
-            try:
-                slot_to_tray = json.loads(queue_item.ams_mapping)
-            except json.JSONDecodeError:
-                pass  # Ignore malformed AMS mapping; fall back to default slot assignment
+    if not slot_to_tray and queue_item and queue_item.ams_mapping:
+        try:
+            slot_to_tray = json.loads(queue_item.ams_mapping)
+        except json.JSONDecodeError:
+            pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
 
     # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
     # 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 = extract_layer_filament_usage_from_3mf(full_path)

+ 29 - 2
backend/app/services/usage_tracker.py

@@ -214,6 +214,10 @@ class PrintSession:
     spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
     spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
     # AMS mapping from print command (captured at start, needed when auto-archive is off)
     # AMS mapping from print command (captured at start, needed when auto-archive is off)
     ams_mapping: list[int] | None = None
     ams_mapping: list[int] | None = None
+    # Queue item's plate_id when this print is a multi-plate 3MF dispatched for a
+    # single plate (#1697). None for non-queue prints — the file's first/only plate
+    # is the default and the 3MF parser already returns the full file in that case.
+    plate_id: int | None = None
 
 
 
 
 # Module-level storage, keyed by printer_id
 # Module-level storage, keyed by printer_id
@@ -380,6 +384,21 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
                 {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
                 {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
             )
             )
 
 
+    # Capture the queue item's plate_id so 3MF parsing at completion is scoped to
+    # the plate that actually ran, not the whole multi-plate file (#1697).
+    plate_id: int | None = None
+    if db:
+        from backend.app.models.print_queue import PrintQueueItem
+
+        queue_result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.printer_id == printer_id)
+            .where(PrintQueueItem.status == "printing")
+        )
+        queue_item = queue_result.scalars().first()
+        if queue_item is not None:
+            plate_id = queue_item.plate_id
+
     # Always create session (even without valid remain data) so print_name
     # Always create session (even without valid remain data) so print_name
     # is available at completion for 3MF-based tracking
     # is available at completion for 3MF-based tracking
     session = PrintSession(
     session = PrintSession(
@@ -390,6 +409,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
         tray_now_at_start=tray_now_at_start,
         tray_now_at_start=tray_now_at_start,
         spool_assignments=spool_assignments,
         spool_assignments=spool_assignments,
         ams_mapping=data.get("ams_mapping"),
         ams_mapping=data.get("ams_mapping"),
+        plate_id=plate_id,
     )
     )
     _active_sessions[printer_id] = session
     _active_sessions[printer_id] = session
 
 
@@ -490,6 +510,7 @@ async def on_print_complete(
             spool_assignments=session.spool_assignments if session else None,
             spool_assignments=session.spool_assignments if session else None,
             print_started_at=session.started_at if session else None,
             print_started_at=session.started_at if session else None,
             threemf_path=threemf_path,
             threemf_path=threemf_path,
+            plate_id=session.plate_id if session else None,
         )
         )
         results.extend(threemf_results)
         results.extend(threemf_results)
 
 
@@ -855,6 +876,7 @@ async def _track_from_3mf(
     spool_assignments: dict[tuple[int, int], int] | None = None,
     spool_assignments: dict[tuple[int, int], int] | None = None,
     print_started_at: datetime | None = None,
     print_started_at: datetime | None = None,
     threemf_path=None,
     threemf_path=None,
+    plate_id: int | None = None,
 ) -> list[dict]:
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
     """Track usage from 3MF per-filament slicer data (primary path).
 
 
@@ -865,6 +887,11 @@ async def _track_from_3mf(
     When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
     When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
     can be provided to still track filament usage from slicer data.
     can be provided to still track filament usage from slicer data.
 
 
+    When ``plate_id`` is set (queue prints of a single plate from a multi-plate
+    3MF), only that plate's filaments contribute. Without it the 3MF parser sums
+    every plate, which is correct for direct/library Print flows that always
+    target the first or only plate (#1697).
+
     Slot-to-tray mapping priority:
     Slot-to-tray mapping priority:
     1. Stored ams_mapping from print command (reprints/direct prints)
     1. Stored ams_mapping from print command (reprints/direct prints)
     2. MQTT mapping field from printer state (universal, all print sources)
     2. MQTT mapping field from printer state (universal, all print sources)
@@ -904,12 +931,12 @@ async def _track_from_3mf(
         logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
         logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
         return []
         return []
 
 
-    filament_usage = extract_filament_usage_from_3mf(file_path)
+    filament_usage = extract_filament_usage_from_3mf(file_path, plate_id)
     if not filament_usage:
     if not filament_usage:
         logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
         logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
         return []
         return []
 
 
-    logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
+    logger.info("[UsageTracker] 3MF: archive %s, plate_id=%s, filament_usage=%s", archive_id, plate_id, filament_usage)
 
 
     # --- Resolve slot-to-tray mapping ---
     # --- Resolve slot-to-tray mapping ---
     mapping_source = None
     mapping_source = None

+ 44 - 12
backend/app/services/virtual_printer/ftp_server.py

@@ -561,17 +561,40 @@ class FTPSession:
         await self.send(226, "Transfer complete")
         await self.send(226, "Transfer complete")
 
 
 
 
+PASSIVE_PORT_BASE = 50000
+PASSIVE_SLICE_SIZE = 10
+PASSIVE_MAX_SLOTS = 100
+
+
+def compute_passive_port_slice(vp_id: int) -> tuple[int, int]:
+    """Return the (min, max) passive-mode data port range for VP `vp_id`.
+
+    Each VP gets a unique non-overlapping slice so bridge-mode Docker users
+    only need to expose `PASSIVE_SLICE_SIZE * <vp count>` ports instead of
+    the full historical 1001-port pool (#1646 — wide pool × Docker's
+    userland-proxy spawned ~2000 host processes at ~3.5 GB RAM). vp_id is
+    taken modulo PASSIVE_MAX_SLOTS so installs that have churned through
+    many VPs over time still produce a valid in-range slice; a same-slot
+    collision falls back to the existing per-session 10-attempt random
+    retry, which is the pre-#1646 behaviour and recovers gracefully.
+    """
+    slot = (max(vp_id, 1) - 1) % PASSIVE_MAX_SLOTS
+    port_min = PASSIVE_PORT_BASE + slot * PASSIVE_SLICE_SIZE
+    port_max = port_min + PASSIVE_SLICE_SIZE - 1
+    return port_min, port_max
+
+
 class VirtualPrinterFTPServer:
 class VirtualPrinterFTPServer:
-    """Implicit FTPS server that accepts uploads from slicers."""
+    """Implicit FTPS server that accepts uploads from slicers.
 
 
-    # Passive-mode data port range. Widened from 50000-50100 (101 ports) to
-    # 50000-51000 (1001 ports) so concurrent transfers across multiple VPs
-    # — particularly when a VP falls back to bind 0.0.0.0 (manager.py picks
-    # this when bind_ip is unset) — don't collide. With 101 ports and 10
-    # random pick attempts per session, birthday-style collisions hit
-    # under load; 1001 ports gives multi-VP setups headroom.
-    PASSIVE_PORT_MIN = 50000
-    PASSIVE_PORT_MAX = 51000
+    Each VP is given a small non-overlapping passive-mode data-port slice
+    via `passive_port_min/passive_port_max` (typically computed by
+    `compute_passive_port_slice(vp_id)` at the call site). The slice is
+    intentionally narrow — 10 ports per VP fits Bambu-style one-passive-
+    socket-per-upload sessions with safe headroom, and bridge-mode docker
+    setups only have to expose `N_vps * 10` ports instead of the historical
+    1001-port pool (#1646).
+    """
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -583,6 +606,8 @@ class VirtualPrinterFTPServer:
         on_file_received: Callable[[Path, str], None] | None = None,
         on_file_received: Callable[[Path, str], None] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
         bind_address: str = "0.0.0.0",  # nosec B104
         vp_name: str = "",
         vp_name: str = "",
+        passive_port_min: int = PASSIVE_PORT_BASE,
+        passive_port_max: int = PASSIVE_PORT_BASE + PASSIVE_SLICE_SIZE - 1,
     ):
     ):
         """Initialize the FTPS server.
         """Initialize the FTPS server.
 
 
@@ -595,6 +620,11 @@ class VirtualPrinterFTPServer:
             on_file_received: Callback when file upload completes (path, source_ip)
             on_file_received: Callback when file upload completes (path, source_ip)
             bind_address: IP address to bind to (default 0.0.0.0)
             bind_address: IP address to bind to (default 0.0.0.0)
             vp_name: Virtual printer name for log identification
             vp_name: Virtual printer name for log identification
+            passive_port_min: Low end of this VP's passive-mode data port slice
+                (inclusive). Per-VP slicing eliminates cross-VP collisions on
+                shared 0.0.0.0 binds without paying for a 1001-port pool (#1646).
+            passive_port_max: High end of the slice (inclusive). Defaults
+                produce a 10-port window starting at PASSIVE_PORT_BASE.
         """
         """
         self.upload_dir = upload_dir
         self.upload_dir = upload_dir
         self.access_code = access_code
         self.access_code = access_code
@@ -604,6 +634,8 @@ class VirtualPrinterFTPServer:
         self.on_file_received = on_file_received
         self.on_file_received = on_file_received
         self.bind_address = bind_address
         self.bind_address = bind_address
         self.vp_name = vp_name
         self.vp_name = vp_name
+        self.passive_port_min = passive_port_min
+        self.passive_port_max = passive_port_max
         self._server: asyncio.Server | None = None
         self._server: asyncio.Server | None = None
         self._running = False
         self._running = False
         # Set after the socket is bound and the server is accepting connections,
         # Set after the socket is bound and the server is accepting connections,
@@ -673,8 +705,8 @@ class VirtualPrinterFTPServer:
             logger.info("Implicit FTPS server started on port %s", self.port)
             logger.info("Implicit FTPS server started on port %s", self.port)
             logger.info(
             logger.info(
                 "FTP passive data port range: %s-%s",
                 "FTP passive data port range: %s-%s",
-                self.PASSIVE_PORT_MIN,
-                self.PASSIVE_PORT_MAX,
+                self.passive_port_min,
+                self.passive_port_max,
             )
             )
             if self._pasv_address:
             if self._pasv_address:
                 logger.info("FTP PASV address override: %s", self._pasv_address)
                 logger.info("FTP PASV address override: %s", self._pasv_address)
@@ -707,7 +739,7 @@ class VirtualPrinterFTPServer:
             access_code=self.access_code,
             access_code=self.access_code,
             ssl_context=self._ssl_context,
             ssl_context=self._ssl_context,
             on_file_received=self.on_file_received,
             on_file_received=self.on_file_received,
-            passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
+            passive_port_range=(self.passive_port_min, self.passive_port_max),
             pasv_address=self._pasv_address,
             pasv_address=self._pasv_address,
             bind_address=self.bind_address,
             bind_address=self.bind_address,
             vp_name=self.vp_name,
             vp_name=self.vp_name,

+ 68 - 3
backend/app/services/virtual_printer/manager.py

@@ -14,12 +14,13 @@ from typing import TYPE_CHECKING
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.models.virtual_printer import (
 from backend.app.models.virtual_printer import (
     VP_MODE_ARCHIVE,
     VP_MODE_ARCHIVE,
+    VP_MODE_PROXY,
     VP_MODE_QUEUE,
     VP_MODE_QUEUE,
     normalize_vp_mode,
     normalize_vp_mode,
 )
 )
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.certificate import CertificateService
-from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
+from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer, compute_passive_port_slice
 from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
@@ -204,6 +205,10 @@ class VirtualPrinterInstance:
         self._ssdp_proxy: SSDPProxy | None = None
         self._ssdp_proxy: SSDPProxy | None = None
         self._tasks: list[asyncio.Task] = []
         self._tasks: list[asyncio.Task] = []
 
 
+        # Pending timer that re-fires gcode_state=FINISH after a project_file
+        # ack. See ``_schedule_finish_release`` for the #1658 rationale.
+        self._finish_release_task: asyncio.Task | None = None
+
     @property
     @property
     def serial(self) -> str:
     def serial(self) -> str:
         """Full serial number for this virtual printer."""
         """Full serial number for this virtual printer."""
@@ -285,9 +290,17 @@ class VirtualPrinterInstance:
         modes ignore the print command, so we skip the stash there to keep
         modes ignore the print command, so we skip the stash there to keep
         the dict from accumulating one entry per print over the VP's
         the dict from accumulating one entry per print over the VP's
         uptime.
         uptime.
+
+        Also schedules the #1658 follow-up that re-fires gcode_state=FINISH a
+        moment after the synthetic project_file ack — for every non-proxy
+        mode — so the slicer's "Downloading" UI releases on the slicer's
+        FTP-first-then-MQTT send order.
         """
         """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         logger.info("[VP %s] Print command for: %s", self.name, filename)
-        if normalize_vp_mode(self.mode) != VP_MODE_QUEUE:
+        mode = normalize_vp_mode(self.mode)
+        if mode != VP_MODE_PROXY and filename and self._mqtt is not None:
+            self._schedule_finish_release(filename)
+        if mode != VP_MODE_QUEUE:
             return
             return
         # Drop the oldest stash if the cache is growing — happens when the
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
         # slicer sends project_file for a filename whose FTP upload was
@@ -307,6 +320,48 @@ class VirtualPrinterInstance:
         if event:
         if event:
             event.set()
             event.set()
 
 
+    def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
+        """Re-set gcode_state=FINISH on the VP after the project_file ack.
+
+        #1280 set FINISH after the FTP upload completes — that was correct
+        for the slicer flow at the time (MQTT project_file → FTP → done).
+        Bambu Studio 2.7.x flipped the order to FTP → FTP → MQTT project_file,
+        which means ``_send_print_response`` runs *after* the FINISH set in
+        ``on_file_received`` and overwrites the state back to PREPARE. The
+        slicer's 1 Hz status stream then carries PREPARE forever and the
+        send modal sits at "Downloading" until the VP is restarted (#1658).
+
+        Re-firing FINISH after a short delay closes the gap: the slicer sees
+        the synthetic PREPARE in the project_file ack (and likely one PREPARE
+        push on the 1 Hz cycle), then the next push carries FINISH and the
+        modal releases. Proxy mode is exempt — there the real printer drives
+        the state through the bridge and a synthetic FINISH would clobber a
+        real PREPARE/RUNNING transition coming back from the printer.
+
+        Cancels any in-flight timer before scheduling a new one so a slicer
+        that fires project_file twice in quick succession only ends in one
+        FINISH.
+        """
+        if self._mqtt is None:
+            return
+        if self._finish_release_task is not None and not self._finish_release_task.done():
+            self._finish_release_task.cancel()
+        self._finish_release_task = asyncio.create_task(
+            self._delayed_finish_release(filename, delay),
+            name=f"vp-{self.id}-finish-release",
+        )
+
+    async def _delayed_finish_release(self, filename: str, delay: float) -> None:
+        """Sleep, then set gcode_state=FINISH. Used by ``_schedule_finish_release``."""
+        try:
+            await asyncio.sleep(delay)
+        except asyncio.CancelledError:
+            return
+        if self._mqtt is None:
+            return
+        self._mqtt.set_gcode_state("FINISH", filename=filename, prepare_percent="100")
+        logger.debug("[VP %s] Re-set gcode_state=FINISH after project_file ack (%s)", self.name, filename)
+
     async def _archive_file(self, file_path: Path, source_ip: str) -> None:
     async def _archive_file(self, file_path: Path, source_ip: str) -> None:
         """Archive file immediately."""
         """Archive file immediately."""
         if not self._session_factory:
         if not self._session_factory:
@@ -694,7 +749,12 @@ class VirtualPrinterInstance:
 
 
         self._tasks = []
         self._tasks = []
 
 
-        # FTP server
+        # FTP server. Each VP gets a non-overlapping passive-mode port slice
+        # derived from its DB id so bridge-mode Docker users only have to
+        # expose a narrow range (#1646). Default slice is 10 ports per VP;
+        # see ftp_server.compute_passive_port_slice for the wrap-around
+        # behaviour on installs with very high VP ids.
+        passive_port_min, passive_port_max = compute_passive_port_slice(self.id)
         self._ftp = VirtualPrinterFTPServer(
         self._ftp = VirtualPrinterFTPServer(
             upload_dir=self.upload_dir,
             upload_dir=self.upload_dir,
             access_code=self.access_code,
             access_code=self.access_code,
@@ -703,6 +763,8 @@ class VirtualPrinterInstance:
             on_file_received=self.on_file_received,
             on_file_received=self.on_file_received,
             bind_address=bind_addr,
             bind_address=bind_addr,
             vp_name=self.name,
             vp_name=self.name,
+            passive_port_min=passive_port_min,
+            passive_port_max=passive_port_max,
         )
         )
         self._tasks.append(
         self._tasks.append(
             asyncio.create_task(
             asyncio.create_task(
@@ -832,6 +894,9 @@ class VirtualPrinterInstance:
 
 
     async def stop_server(self) -> None:
     async def stop_server(self) -> None:
         """Stop server-mode services."""
         """Stop server-mode services."""
+        if self._finish_release_task is not None and not self._finish_release_task.done():
+            self._finish_release_task.cancel()
+            self._finish_release_task = None
         if self._mqtt_bridge:
         if self._mqtt_bridge:
             try:
             try:
                 await self._mqtt_bridge.stop()
                 await self._mqtt_bridge.stop()

+ 74 - 3
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -35,8 +35,10 @@ from __future__ import annotations
 
 
 import asyncio
 import asyncio
 import copy
 import copy
+import ipaddress
 import json
 import json
 import logging
 import logging
+import socket
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
@@ -90,6 +92,38 @@ def _ip_to_uint32_le(ip_str: str) -> int:
     return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
     return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
 
 
 
 
+def _resolve_target_to_ipv4(target: str) -> str | None:
+    """Return a dotted-quad IPv4 for `target`, resolving hostnames if needed.
+
+    The printer client may be configured by IPv4 *or* by hostname/FQDN
+    (e.g. `p1s.fritz.box`) — the latter is common on home LANs with a
+    DNS-providing router. The downstream `net.info[].ip` field is a
+    32-bit little-endian integer though, so a hostname can't round-trip
+    through it; we have to pick *one* concrete IPv4 to write in.
+
+    Returns None if `target` is empty, not parseable as IPv4, and DNS
+    resolution fails — caller logs that as the not-armed reason and
+    re-tries on the next refresh tick (DHCP/DNS churn picks itself up).
+    """
+    if not target:
+        return None
+    try:
+        return str(ipaddress.IPv4Address(target))
+    except (ValueError, ipaddress.AddressValueError):
+        pass
+    try:
+        # AF_INET filters to IPv4 only; the rewrite field is uint32 LE,
+        # there's no IPv6 representation that fits.
+        infos = socket.getaddrinfo(target, None, family=socket.AF_INET)
+    except OSError:
+        return None
+    for info in infos:
+        sockaddr = info[4]
+        if sockaddr and isinstance(sockaddr[0], str):
+            return sockaddr[0]
+    return None
+
+
 def _resolve_host_interface_for_target(target_ip: str) -> str | None:
 def _resolve_host_interface_for_target(target_ip: str) -> str | None:
     """Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
     """Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
 
 
@@ -244,6 +278,14 @@ class MQTTBridge:
         self._target_serial: str | None = None
         self._target_serial: str | None = None
         self._target_ip_uint32_le: int | None = None
         self._target_ip_uint32_le: int | None = None
         self._vp_ip_uint32_le: int | None = None
         self._vp_ip_uint32_le: int | None = None
+        # Last reason `_refresh_ip_encoding` early-returned without arming.
+        # Used to throttle the "NOT armed" diagnostic log to one line per
+        # state change — refresh runs every 30s, so without throttling an
+        # idle-but-unarmed bridge would emit one line per tick forever. Set
+        # to None once arming succeeds so the next failure re-logs. #1429
+        # follow-up: makes silent early-returns visible without grepping the
+        # source.
+        self._not_armed_reason: str | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._refresh_task: asyncio.Task | None = None
         self._refresh_task: asyncio.Task | None = None
         self._stopping = False
         self._stopping = False
@@ -389,12 +431,33 @@ class MQTTBridge:
         arms on a default-config flat-LAN install and `net.info[].ip` leaks
         arms on a default-config flat-LAN install and `net.info[].ip` leaks
         the real printer IP — slicer follows it on Send (#1429 residual).
         the real printer IP — slicer follows it on Send (#1429 residual).
         """
         """
+
+        def _log_not_armed(reason: str) -> None:
+            # Throttle: only log when the reason changes, otherwise an idle
+            # unarmed bridge would emit one INFO line every refresh tick
+            # (~30s) forever. Cleared on arm so a regression re-logs.
+            if reason != self._not_armed_reason:
+                logger.info("[%s] MQTT bridge IP encoding NOT armed: %s", self.vp_name, reason)
+                self._not_armed_reason = reason
+
         client = self._target_client
         client = self._target_client
         if client is None:
         if client is None:
+            _log_not_armed("target_client is None (bridge not bound to a printer)")
             return
             return
 
 
-        target_ip = getattr(client, "ip_address", None)
+        configured_target = getattr(client, "ip_address", None)
+        if not configured_target:
+            _log_not_armed("printer client has no ip_address yet")
+            return
+
+        # Printers configured by hostname/FQDN (e.g. `p1s.fritz.box`) need to
+        # be resolved to an IPv4 before encoding: net.info[*].ip is uint32 LE
+        # and can't carry a hostname (#1429 follow-up).
+        target_ip = _resolve_target_to_ipv4(configured_target)
         if not target_ip:
         if not target_ip:
+            _log_not_armed(
+                f"could not resolve printer host {configured_target!r} to IPv4 (invalid address and DNS lookup failed)"
+            )
             return
             return
 
 
         vp_ip = getattr(self._mqtt_server, "bind_address", None)
         vp_ip = getattr(self._mqtt_server, "bind_address", None)
@@ -402,6 +465,10 @@ class MQTTBridge:
         if not vp_ip or vp_ip in ("0.0.0.0", ""):  # nosec B104
         if not vp_ip or vp_ip in ("0.0.0.0", ""):  # nosec B104
             resolved = _resolve_host_interface_for_target(target_ip)
             resolved = _resolve_host_interface_for_target(target_ip)
             if not resolved:
             if not resolved:
+                _log_not_armed(
+                    f"no host interface shares a subnet with printer IP {target_ip} "
+                    "(and VP bind_address is 0.0.0.0/empty)"
+                )
                 return
                 return
             vp_ip = resolved
             vp_ip = resolved
             vp_ip_source = "auto-resolved"
             vp_ip_source = "auto-resolved"
@@ -409,7 +476,8 @@ class MQTTBridge:
         try:
         try:
             new_target_le = _ip_to_uint32_le(target_ip)
             new_target_le = _ip_to_uint32_le(target_ip)
             new_vp_le = _ip_to_uint32_le(vp_ip)
             new_vp_le = _ip_to_uint32_le(vp_ip)
-        except ValueError:
+        except ValueError as e:
+            _log_not_armed(f"invalid IPv4 (target={target_ip!r}, vp={vp_ip!r}): {e}")
             return
             return
 
 
         if new_target_le == self._target_ip_uint32_le and new_vp_le == self._vp_ip_uint32_le:
         if new_target_le == self._target_ip_uint32_le and new_vp_le == self._vp_ip_uint32_le:
@@ -420,11 +488,14 @@ class MQTTBridge:
         was_armed = self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None
         was_armed = self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None
         self._target_ip_uint32_le = new_target_le
         self._target_ip_uint32_le = new_target_le
         self._vp_ip_uint32_le = new_vp_le
         self._vp_ip_uint32_le = new_vp_le
+        # Clear the dedup so a future failure re-emits the diagnostic line.
+        self._not_armed_reason = None
+        target_display = target_ip if target_ip == configured_target else f"{configured_target}→{target_ip}"
         logger.info(
         logger.info(
             "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
             "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
             self.vp_name,
             self.vp_name,
             "updated" if was_armed else "armed",
             "updated" if was_armed else "armed",
-            target_ip,
+            target_display,
             vp_ip,
             vp_ip,
             vp_ip_source,
             vp_ip_source,
         )
         )

+ 32 - 11
backend/app/services/virtual_printer/mqtt_server.py

@@ -9,6 +9,7 @@ import copy
 import hmac
 import hmac
 import json
 import json
 import logging
 import logging
+import socket
 import ssl
 import ssl
 from collections.abc import Callable
 from collections.abc import Callable
 from pathlib import Path
 from pathlib import Path
@@ -519,10 +520,14 @@ class SimpleMQTTServer:
 
 
         authenticated = False
         authenticated = False
         # Per-packet read timeout. Before CONNECT we default to 60 s so a
         # Per-packet read timeout. Before CONNECT we default to 60 s so a
-        # client that opens TCP but never sends anything still gets reaped;
-        # after CONNECT the value is updated to 1.5× the keepalive the
-        # client negotiated (MQTT spec §4.4). ``None`` means no timeout,
-        # which is what spec §3.1.2.10 mandates for keep_alive == 0.
+        # client that opens TCP but never sends anything still gets reaped.
+        # After CONNECT we drop the application-level read timeout entirely
+        # and rely on TCP keepalive (SO_KEEPALIVE) to detect dead connections
+        # — this matches real Bambu firmware, which does not enforce MQTT
+        # spec §4.4's 1.5× idle disconnect (#1548 round 2). OrcaSlicer's
+        # MQTT client on some platforms does not emit PINGREQ at all on idle
+        # connections; the same install that stays connected to a real P1S
+        # indefinitely was disconnecting from us at keepalive×1.5.
         read_timeout: float | None = 60.0
         read_timeout: float | None = 60.0
 
 
         try:
         try:
@@ -565,13 +570,29 @@ class SimpleMQTTServer:
                         self._record_auth_failure(source_ip)
                         self._record_auth_failure(source_ip)
                         break
                         break
                     self._clear_auth_failures(source_ip)
                     self._clear_auth_failures(source_ip)
-                    # Honour the client's negotiated keepalive (#1548). Before
-                    # this fix, the hardcoded 60 s above would close
-                    # OrcaSlicer's idle connection at the keepalive boundary
-                    # instead of waiting 1.5× as the spec requires — Orca
-                    # sends PINGREQ within its own keepalive interval but
-                    # we'd already have closed the socket.
-                    read_timeout = keep_alive * 1.5 if keep_alive > 0 else None
+                    # Drop the application-level read timeout; rely on
+                    # SO_KEEPALIVE below for dead-connection detection.
+                    # Real Bambu firmware does the same — accept any
+                    # negotiated keepalive but never enforce §4.4's 1.5×
+                    # disconnect on the otherwise-idle MQTT session
+                    # (#1548 round 2). keep_alive is logged for support
+                    # bundles but no longer drives a disconnect.
+                    read_timeout = None
+                    logger.info(
+                        "%sMQTT client %s authenticated (negotiated keepalive=%ds, idle disconnect disabled)",
+                        self._log_prefix,
+                        client_id,
+                        keep_alive,
+                    )
+                    # Enable TCP keepalive so a hard network drop is detected
+                    # by the OS within a few minutes rather than waiting for
+                    # the next outbound write to ECONNRESET.
+                    sock = writer.get_extra_info("socket")
+                    if sock is not None:
+                        try:
+                            sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+                        except OSError as e:
+                            logger.debug("%sFailed to set SO_KEEPALIVE on %s: %s", self._log_prefix, client_id, e)
                     # Register client for periodic status pushes; start with
                     # Register client for periodic status pushes; start with
                     # self.serial as the fallback until we learn the slicer's
                     # self.serial as the fallback until we learn the slicer's
                     # preferred serial from the first SUBSCRIBE/PUBLISH.
                     # preferred serial from the first SUBSCRIBE/PUBLISH.

+ 3 - 0
backend/app/utils/printer_models.py

@@ -15,6 +15,9 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab A1": "A1",
     "Bambu Lab A1": "A1",
     "Bambu Lab A1 Mini": "A1 Mini",
     "Bambu Lab A1 Mini": "A1 Mini",
     "Bambu Lab A1 mini": "A1 Mini",
     "Bambu Lab A1 mini": "A1 Mini",
+    # Bambu cloud rolled out a terse model-code rename mid-2026 (#1649);
+    # 3MFs prepared with newer cloud presets may carry this short form.
+    "Bambu Lab A1M": "A1 Mini",
     "Bambu Lab H2D": "H2D",
     "Bambu Lab H2D": "H2D",
     "Bambu Lab H2D Pro": "H2D Pro",
     "Bambu Lab H2D Pro": "H2D Pro",
     "Bambu Lab H2C": "H2C",
     "Bambu Lab H2C": "H2C",

+ 50 - 0
backend/app/utils/threemf_tools.py

@@ -484,6 +484,56 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
     return filament_usage
     return filament_usage
 
 
 
 
+def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
+    """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281).
+
+    ``archive.bed_type`` is captured at ingest time but is one value per archive
+    (the first plate's `curr_bed_type` — see services/archive.py:235). For a
+    multi-plate 3MF where different plates target different beds (e.g. a 40-plate
+    file mixing PEI + Engineering), the archive-level value lies. When a queue
+    item or print modal targets a specific plate, this re-reads the 3MF and
+    returns that plate's actual bed type.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Plate index to filter for; if None, returns the first plate's
+            ``curr_bed_type`` (matches the archive-level capture).
+
+    Returns:
+        Bed type string (e.g. "Textured PEI Plate"), or None if not found.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            for plate_elem in root.findall(".//plate"):
+                plate_index = None
+                bed_value: str | None = None
+                for meta in plate_elem.findall("metadata"):
+                    key = meta.get("key")
+                    if key == "index":
+                        try:
+                            plate_index = int(meta.get("value", "0"))
+                        except ValueError:
+                            pass  # Skip plate with unparseable index
+                    elif key == "curr_bed_type" and meta.get("value"):
+                        bed_value = (meta.get("value") or "").strip()
+
+                if plate_id is None:
+                    # First plate wins when no plate_id is requested.
+                    return bed_value
+                if plate_index == plate_id:
+                    return bed_value
+    except Exception:
+        pass  # Return None on any failure rather than raising — caller decides
+
+    return None
+
+
 # Header values exposed as `{placeholder}` substitutions inside snippets.
 # Header values exposed as `{placeholder}` substitutions inside snippets.
 # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
 # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
 # Bambu/Orca header keys (`max_z_height`).
 # Bambu/Orca header keys (`max_z_height`).

+ 363 - 0
backend/tests/integration/test_archives_api.py

@@ -534,6 +534,369 @@ class TestArchivesAPI:
         assert "successful_prints" in result
         assert "successful_prints" in result
 
 
 
 
+class TestNo3MFWarning:
+    """`GET /archives/no-3mf-warning` — install step 4 reactive nudge.
+
+    The connection diagnostic's external_storage check only catches the
+    printer-side variant of the setting (newer firmware). For older slicers
+    where the toggle lives only in BambuStudio, the printer never reports
+    it. The fallback path in main.py creates the archive with
+    extra_data.no_3mf_available=True; this endpoint exposes that as a
+    boolean so the frontend can surface a one-time banner.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_true_when_recent_fallback_exists(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": True}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_false_when_no_archives(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": False}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_false_when_only_normal_archives(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        # extra_data has other keys but no_3mf_available is absent — normal
+        # archives must not trigger the nudge.
+        await archive_factory(printer.id, extra_data={"makerworld_url": "https://example"})
+        await archive_factory(printer.id, extra_data=None)
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": False}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ignores_archives_older_than_30_days(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from datetime import datetime, timedelta, timezone
+
+        from backend.app.models.archive import PrintArchive
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+        # Backdate past the 30-day window — old fallbacks are forgiven.
+        archive.created_at = datetime.now(timezone.utc) - timedelta(days=45)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        assert response.json() == {"has_fallback": False}
+        # Sanity: row really is in the DB, we just don't surface it.
+        assert (await db_session.get(PrintArchive, archive.id)) is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ignores_soft_deleted_fallbacks(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+        archive.deleted_at = datetime.now(timezone.utc)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.status_code == 200
+        # Soft-deleted fallbacks have been actioned (user clearing the
+        # evidence). Stop nudging.
+        assert response.json() == {"has_fallback": False}
+
+
+class TestPrintLogEntryDelete:
+    """#1687: per-row delete on the Print Log page.
+
+    Pin the route's three contracts: (1) deleting a row drops its filament
+    / cost / count contribution from /archives/stats in the same response
+    cycle; (2) the matching archive (if any) is untouched; (3) missing IDs
+    return 404 rather than 200-silently.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_log_entry_drops_from_stats(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
+        drop = await archive_factory(printer.id, status="completed", filament_used_grams=125.0)
+
+        drop_run = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == drop.id))
+        ).scalar_one()
+
+        resp = await async_client.delete(f"/api/v1/print-log/{drop_run.id}")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "deleted"
+        assert resp.json()["id"] == drop_run.id
+
+        # The linked archive survives — the row was a stats row, not the archive.
+        listing = (await async_client.get("/api/v1/archives/")).json()
+        assert {a["id"] for a in listing} == {keep.id, drop.id}
+
+        # /stats no longer counts the dropped run's filament contribution.
+        stats = (await async_client.get("/api/v1/archives/stats")).json()
+        assert stats["total_prints"] == 1
+        assert stats["total_filament_grams"] == 50.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_log_entry_404_when_missing(self, async_client: AsyncClient):
+        resp = await async_client.delete("/api/v1/print-log/999999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_log_entry_does_not_clear_others(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Deleting one row must not touch siblings — guard against an accidental
+        ``delete(PrintLogEntry)`` without a ``where`` clause (cf. clear_print_log
+        which intentionally drops everything)."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        a = await archive_factory(printer.id, status="completed", filament_used_grams=10.0)
+        b = await archive_factory(printer.id, status="completed", filament_used_grams=20.0)
+        c = await archive_factory(printer.id, status="completed", filament_used_grams=30.0)
+
+        runs = {r.archive_id: r for r in (await db_session.execute(select(PrintLogEntry))).scalars().all()}
+
+        resp = await async_client.delete(f"/api/v1/print-log/{runs[b.id].id}")
+        assert resp.status_code == 200
+
+        survivors = (await db_session.execute(select(PrintLogEntry.archive_id))).scalars().all()
+        assert set(survivors) == {a.id, c.id}
+
+
+class TestPrintLogEntryUpdate:
+    """Tests for ``PATCH /print-log/{entry_id}`` (#1687 part 4).
+
+    Pin the route's contracts: (1) GET serialiser actually surfaces
+    ``failure_reason`` (previously it was silently dropped from the response
+    even when set in the DB); (2) PATCH persists ``failure_reason`` and
+    ``status``; (3) unknown vocabulary returns 400 rather than getting stored
+    as raw garbage; (4) missing IDs return 404.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_surfaces_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Pre-fix the GET endpoint built PrintLogEntrySchema without
+        ``failure_reason`` even though the column was populated, so the Print
+        Log table couldn't render what the Failure Analysis widget already
+        groups by. Regression guard for the silent-drop bug.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.failure_reason = "spaghettiDetached"
+        await db_session.commit()
+
+        body = (await async_client.get("/api/v1/print-log/")).json()
+        match = next(item for item in body["items"] if item["id"] == entry.id)
+        assert match["failure_reason"] == "spaghettiDetached"
+        # archive_id should also flow through so the frontend can tell orphan
+        # entries apart from archive-linked ones.
+        assert match["archive_id"] == archive.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_sets_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        assert entry.failure_reason is None
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"failure_reason": "cloggedNozzle"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["failure_reason"] == "cloggedNozzle"
+
+        await db_session.refresh(entry)
+        assert entry.failure_reason == "cloggedNozzle"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_can_clear_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Empty-string failure_reason stores back as NULL (the column's
+        nullable=True intent is preserved end-to-end)."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.failure_reason = "warping"
+        await db_session.commit()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"failure_reason": ""},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["failure_reason"] is None
+
+        await db_session.refresh(entry)
+        assert entry.failure_reason is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_unknown_failure_reason(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Unknown values must 400 — otherwise the UI would render raw garbage
+        because the i18n layer maps the value back through the canonical
+        vocabulary."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"failure_reason": "completely-made-up"},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_updates_status(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="completed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.status = "completed"
+        await db_session.commit()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"status": "failed", "failure_reason": "layerShift"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "failed"
+        assert resp.json()["failure_reason"] == "layerShift"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_unknown_status(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{entry.id}",
+            json={"status": "bogus-status"},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_404_when_missing(self, async_client: AsyncClient):
+        resp = await async_client.patch(
+            "/api/v1/print-log/999999",
+            json={"failure_reason": "cloggedNozzle"},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_works_on_orphan_entry(self, async_client: AsyncClient, printer_factory, db_session):
+        """Orphan log entries (no archive_id) are the actual reason this
+        endpoint exists — the Archive Edit modal can't reach them. Make sure
+        the PATCH works for those rows specifically."""
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        orphan = PrintLogEntry(
+            archive_id=None,
+            print_name="failed-before-archive-created",
+            printer_id=printer.id,
+            status="failed",
+            failure_reason=None,
+        )
+        db_session.add(orphan)
+        await db_session.commit()
+        await db_session.refresh(orphan)
+        assert orphan.archive_id is None
+
+        resp = await async_client.patch(
+            f"/api/v1/print-log/{orphan.id}",
+            json={"failure_reason": "powerFailure"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["failure_reason"] == "powerFailure"
+        assert resp.json()["archive_id"] is None
+
+
 class TestArchivesSlimAPI:
 class TestArchivesSlimAPI:
     """Integration tests for /api/v1/archives/slim endpoint."""
     """Integration tests for /api/v1/archives/slim endpoint."""
 
 

+ 134 - 0
backend/tests/integration/test_inventory_assign.py

@@ -1141,3 +1141,137 @@ class TestAssignSpoolEmptyDetection:
         body = response.json()
         body = response.json()
         assert body["pending_config"] is False
         assert body["pending_config"] is False
         assert body["configured"] is True
         assert body["configured"] is True
+
+
+class TestAssignSpoolPfcnCloudPreset:
+    """Assign path for PFCN-prefix cloud presets (#1648).
+
+    PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
+    and GFS (Bambu official) — used for cloud-shared / partner-uploaded
+    presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
+    the assign path skipped the cloud-detail lookup and left the raw PFCN
+    string in tray_info_idx, which the printer's calibration table can't
+    resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
+    itself, making "Configure" feel like a mandatory follow-up step.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
+        the raw PFCN must be discarded as slicer-invalid and the slot configures
+        with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
+        was to leak the raw PFCN, which the slicer can't resolve."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # PFCN never leaks into tray_info_idx — must resolve to the
+            # generic-material fallback when cloud lookup couldn't.
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Symmetry with the PFUS case: when the spool's PFCN is discarded as
+        slicer-invalid, the slot's existing valid P-prefix preset is reused
+        if material matches — preserves calibration context instead of
+        resetting to generic."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(
+            ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """When the user is authenticated against Bambu Cloud, the PFCN setting_id
+        triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
+        filament_id from `detail["filament_id"]` and ships that as
+        tray_info_idx. This is the happy path the Configure modal already had
+        but the assign path didn't, #1648."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
+
+        # Cloud responds with a real filament_id for the PFCN preset — exactly
+        # what the Configure modal already exploits.
+        mock_cloud = MagicMock()
+        mock_cloud.is_authenticated = True
+
+        async def fake_get_detail(setting_id):
+            assert setting_id == "PFCN80e80c1f79db85"
+            return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
+
+        async def fake_close():
+            return None
+
+        mock_cloud.get_setting_detail = fake_get_detail
+        mock_cloud.close = fake_close
+
+        async def fake_build_cloud(_db, _user):
+            return mock_cloud
+
+        with (
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
+        ):
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # tray_info_idx is the resolved cloud filament_id; setting_id is the
+            # original PFCN (which the slicer needs separately).
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
+            assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"

+ 520 - 0
backend/tests/integration/test_inventory_csv.py

@@ -0,0 +1,520 @@
+"""Integration tests for inventory CSV import/export (#1576).
+
+Covers the export → import round-trip, dry-run preview (no writes), real
+import (only valid rows persisted, atomically), and Color Catalog resolution
+of brand + color_name → rgba.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.spool import Spool
+
+
+def _csv_upload(text: str):
+    """Build the multipart `files=` payload for the import endpoint."""
+    return {"file": ("inventory.csv", text.encode("utf-8"), "text/csv")}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvExport:
+    async def test_export_returns_csv_with_header_and_rows(self, async_client: AsyncClient, db_session: AsyncSession):
+        db_session.add(
+            Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff", label_weight=1000)
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/inventory/spools/export")
+
+        assert response.status_code == 200, response.text
+        assert response.headers["content-type"].startswith("text/csv")
+        body = response.text
+        lines = body.strip().splitlines()
+        # Header row uses the fixed schema.
+        assert lines[0].split(",")[0] == "material"
+        assert "rgba" in lines[0]
+        # Data row present, rgba written without leading '#'.
+        assert "Polymaker" in body
+        assert "e8e8e8ff" in body
+        assert "#e8e8e8ff" not in body
+
+    async def test_export_excludes_archived(self, async_client: AsyncClient, db_session: AsyncSession):
+        from datetime import datetime, timezone
+
+        db_session.add(Spool(material="PLA", brand="Active", color_name="A", rgba="ffffffff"))
+        db_session.add(
+            Spool(
+                material="PETG",
+                brand="Archived",
+                color_name="B",
+                rgba="000000ff",
+                archived_at=datetime.now(timezone.utc),
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/inventory/spools/export")
+
+        assert response.status_code == 200, response.text
+        assert "Active" in response.text
+        assert "Archived" not in response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvImportDryRun:
+    async def test_dry_run_classifies_rows_and_writes_nothing(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        csv_text = (
+            "material,brand,color_name,rgba,label_weight\n"
+            "PLA,Polymaker,Jade White,e8e8e8ff,1000\n"  # valid
+            ",Polymaker,No Material,ffffffff,1000\n"  # error: material missing
+            "PETG,Brand,Bad Hex,zzzz,1000\n"  # error: invalid rgba
+            "\n"  # skipped: blank
+            "ABS,Brand,Color,#00ff00,500\n"  # valid: 6-char + '#' tolerated
+        )
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["valid_count"] == 2
+        assert data["error_count"] == 2
+        assert data["skipped_count"] == 1
+        # 6-char hex got normalised to 8-char.
+        valid_rows = [r for r in data["rows"] if r["status"] == "valid"]
+        green = next(r for r in valid_rows if r["color_name"] == "Color")
+        assert green["rgba"] == "00ff00ff"
+
+        # Nothing was written.
+        result = await db_session.execute(select(Spool))
+        assert result.scalars().first() is None
+
+    async def test_missing_material_column_fails_whole_file(self, async_client: AsyncClient):
+        csv_text = "brand,color_name\nPolymaker,Jade White\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["valid_count"] == 0
+        assert any("material" in w for w in data["warnings"])
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvImportReal:
+    async def test_import_persists_only_valid_rows(self, async_client: AsyncClient, db_session: AsyncSession):
+        csv_text = (
+            "material,brand,color_name,rgba\n"
+            "PLA,Polymaker,White,ffffffff\n"  # valid
+            ",Polymaker,No Material,ffffffff\n"  # error
+            "PETG,Brand,Color,ff0000ff\n"  # valid
+        )
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["created"] == 2
+        assert data["errors"] == 1
+        assert len(data["error_rows"]) == 1
+
+        result = await db_session.execute(select(Spool).order_by(Spool.material))
+        spools = result.scalars().all()
+        assert len(spools) == 2
+        assert {s.material for s in spools} == {"PLA", "PETG"}
+
+    async def test_case_and_space_tolerant_headers(self, async_client: AsyncClient, db_session: AsyncSession):
+        csv_text = "Material, Color Name ,RGBA\nPLA,Snow,ffffffff\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        assert response.json()["created"] == 1
+        result = await db_session.execute(select(Spool))
+        spool = result.scalars().one()
+        assert spool.color_name == "Snow"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvColorResolution:
+    async def test_brand_and_color_resolve_rgba_from_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
+        db_session.add(
+            ColorCatalogEntry(
+                manufacturer="Polymaker",
+                color_name="Jade White",
+                hex_color="#E8E8E8",
+                material="PLA",
+                is_default=False,
+            )
+        )
+        await db_session.commit()
+
+        # No rgba in CSV — resolved from catalog (case-insensitive match).
+        csv_text = "material,brand,color_name\nPLA,polymaker,jade white\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["valid_count"] == 1
+        row = data["rows"][0]
+        assert row["resolved_color"] is True
+        assert row["rgba"] == "e8e8e8ff"
+
+    async def test_explicit_rgba_wins_over_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
+        db_session.add(
+            ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
+        )
+        await db_session.commit()
+
+        csv_text = "material,brand,color_name,rgba\nPLA,Polymaker,Jade White,123456ff\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        row = response.json()["rows"][0]
+        assert row["rgba"] == "123456ff"
+        assert row["resolved_color"] is False
+
+    async def test_cross_material_fallback_is_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
+        # Catalog only has a PLA variant of this colour; a PETG row resolves it
+        # via cross-material fallback and must be flagged.
+        db_session.add(
+            ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
+        )
+        await db_session.commit()
+
+        csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        row = response.json()["rows"][0]
+        assert row["resolved_color"] is True
+        assert row["cross_material_color"] is True
+        assert row["rgba"] == "e8e8e8ff"
+
+    async def test_exact_material_match_not_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
+        db_session.add(
+            ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PETG")
+        )
+        await db_session.commit()
+
+        csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        row = response.json()["rows"][0]
+        assert row["resolved_color"] is True
+        assert row["cross_material_color"] is False
+
+    async def test_generic_material_catalog_entry_not_flagged(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        # A NULL-material catalog entry is the project's "matches any material"
+        # convention — resolving a PLA row from it is an exact match, not a
+        # cross-material fallback, so it must not raise the yellow warning.
+        db_session.add(
+            ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material=None)
+        )
+        await db_session.commit()
+
+        csv_text = "material,brand,color_name\nPLA,Polymaker,Jade White\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        row = response.json()["rows"][0]
+        assert row["resolved_color"] is True
+        assert row["cross_material_color"] is False
+        assert row["rgba"] == "e8e8e8ff"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvReviewFollowups:
+    """Covers the maintainer-requested hardening (PR #1659 review)."""
+
+    async def test_oversized_upload_rejected_413(self, async_client: AsyncClient):
+        # Build a body just over the 5 MB cap.
+        from backend.app.services.spool_csv import MAX_CSV_IMPORT_BYTES
+
+        header = "material\n"
+        filler = "PLA\n" * ((MAX_CSV_IMPORT_BYTES // 4) + 10)
+        big = header + filler
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(big))
+
+        assert response.status_code == 413, response.text
+        detail = response.json()["detail"]
+        assert detail["code"] == "csv_import_too_large"
+
+    async def test_weight_used_negative_is_error(self, async_client: AsyncClient):
+        csv_text = "material,color_name,rgba,weight_used\nPLA,X,ffffffff,-5\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["error_count"] == 1
+        assert "weight_used" in data["rows"][0]["reason"]
+
+    async def test_weight_used_exceeds_label_is_error(self, async_client: AsyncClient):
+        csv_text = "material,color_name,rgba,label_weight,weight_used\nPLA,X,ffffffff,1000,1500\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["error_count"] == 1
+        assert "exceeds" in data["rows"][0]["reason"]
+
+    async def test_export_neutralises_formula_injection(self, async_client: AsyncClient, db_session: AsyncSession):
+        # A note starting with '=' must be prefixed with a quote on export so
+        # spreadsheets don't evaluate it as a formula.
+        db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1:A9)"))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/inventory/spools/export")
+
+        assert response.status_code == 200, response.text
+        assert "'=SUM(A1:A9)" in response.text
+
+    async def test_formula_injection_round_trips_without_quote_accumulation(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        # The export quote-guard must be undone on import so a formula-looking
+        # note survives export → import unchanged (no accumulating leading ').
+        db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1)"))
+        await db_session.commit()
+
+        export = await async_client.get("/api/v1/inventory/spools/export")
+        assert export.status_code == 200, export.text
+        assert "'=SUM(A1)" in export.text  # guarded on export
+
+        # Wipe, re-import the exact export, and confirm the note is restored
+        # to its original value (not "'=SUM(A1)").
+        existing = await db_session.execute(select(Spool))
+        for spool in existing.scalars().all():
+            await db_session.delete(spool)
+        await db_session.commit()
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(export.text))
+        assert response.status_code == 200, response.text
+        assert response.json()["created"] == 1
+
+        result = await db_session.execute(select(Spool))
+        spool = result.scalars().one()
+        assert spool.note == "=SUM(A1)"  # original value, no leading quote
+
+    async def test_export_filename_is_date_stamped(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/inventory/spools/export")
+
+        assert response.status_code == 200, response.text
+        disposition = response.headers.get("content-disposition", "")
+        assert "bambuddy_inventory_" in disposition
+        assert disposition.rstrip('"').endswith(".csv")
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvRoundTrip:
+    async def test_export_then_import_recreates_spools(self, async_client: AsyncClient, db_session: AsyncSession):
+        db_session.add(
+            Spool(
+                material="PLA",
+                brand="Polymaker",
+                subtype="Matte",
+                color_name="Jade White",
+                rgba="e8e8e8ff",
+                label_weight=1000,
+                weight_used=250,
+                cost_per_kg=24.99,
+                note="batch order",
+            )
+        )
+        await db_session.commit()
+
+        export = await async_client.get("/api/v1/inventory/spools/export")
+        assert export.status_code == 200, export.text
+        csv_text = export.text
+
+        # Wipe and re-import the exact export.
+        existing = await db_session.execute(select(Spool))
+        for spool in existing.scalars().all():
+            await db_session.delete(spool)
+        await db_session.commit()
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+        assert response.status_code == 200, response.text
+        assert response.json()["created"] == 1
+
+        result = await db_session.execute(select(Spool))
+        spool = result.scalars().one()
+        assert spool.material == "PLA"
+        assert spool.brand == "Polymaker"
+        assert spool.subtype == "Matte"
+        assert spool.color_name == "Jade White"
+        assert spool.rgba == "e8e8e8ff"
+        assert spool.label_weight == 1000
+        assert spool.weight_used == 250  # usage round-trips
+        assert spool.cost_per_kg == 24.99
+        assert spool.note == "batch order"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvUsageColumns:
+    async def test_export_writes_weight_used_and_derived_remaining(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        from datetime import datetime, timezone
+
+        db_session.add(
+            Spool(
+                material="PLA",
+                brand="Polymaker",
+                color_name="White",
+                rgba="ffffffff",
+                label_weight=1000,
+                weight_used=300,
+                last_used=datetime(2026, 6, 1, 12, 30, tzinfo=timezone.utc),
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/inventory/spools/export")
+
+        assert response.status_code == 200, response.text
+        header, row = response.text.strip().splitlines()[:2]
+        cols = header.split(",")
+        cells = row.split(",")
+        record = dict(zip(cols, cells, strict=False))
+        assert record["weight_used"] == "300"
+        assert record["remaining"] == "700"  # 1000 - 300, derived
+        assert record["last_used"].startswith("2026-06-01T12:30")
+
+    async def test_import_reads_weight_used_ignores_remaining(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        # remaining is intentionally contradictory — it must be ignored; only
+        # weight_used is read back.
+        csv_text = (
+            "material,brand,color_name,rgba,label_weight,weight_used,remaining\nPLA,Brand,White,ffffffff,1000,400,999\n"
+        )
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        assert response.json()["created"] == 1
+        result = await db_session.execute(select(Spool))
+        spool = result.scalars().one()
+        assert spool.weight_used == 400  # from CSV
+        assert spool.label_weight == 1000  # remaining=999 ignored, not used to back-compute
+
+    async def test_import_parses_last_used_iso(self, async_client: AsyncClient, db_session: AsyncSession):
+        csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,2026-06-01T12:30:00+00:00\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        assert response.json()["created"] == 1
+        result = await db_session.execute(select(Spool))
+        spool = result.scalars().one()
+        assert spool.last_used is not None
+        assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
+
+    async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
+        csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["error_count"] == 1
+        assert "last_used" in data["rows"][0]["reason"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvExtraColumns:
+    async def test_storage_category_threshold_round_trip(self, async_client: AsyncClient, db_session: AsyncSession):
+        # storage_location / category / low_stock_threshold_pct must survive an
+        # export → import cycle (would otherwise be silently lost).
+        db_session.add(
+            Spool(
+                material="PLA",
+                brand="Polymaker",
+                color_name="White",
+                rgba="ffffffff",
+                storage_location="Shelf B3",
+                category="Production",
+                low_stock_threshold_pct=20,
+            )
+        )
+        await db_session.commit()
+
+        csv_text = (await async_client.get("/api/v1/inventory/spools/export")).text
+        for spool in (await db_session.execute(select(Spool))).scalars().all():
+            await db_session.delete(spool)
+        await db_session.commit()
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+        assert response.status_code == 200, response.text
+        assert response.json()["created"] == 1
+
+        spool = (await db_session.execute(select(Spool))).scalars().one()
+        assert spool.storage_location == "Shelf B3"
+        assert spool.category == "Production"
+        assert spool.low_stock_threshold_pct == 20
+
+    async def test_low_stock_threshold_out_of_range_is_error(self, async_client: AsyncClient):
+        # SpoolCreate bounds low_stock_threshold_pct to 1..99; the CSV path must
+        # reject an out-of-range value rather than persist it.
+        csv_text = "material,color_name,rgba,low_stock_threshold_pct\nPLA,White,ffffffff,150\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert data["error_count"] == 1
+        assert "low_stock_threshold_pct" in data["rows"][0]["reason"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestInventoryCsvDuplicateWarning:
+    async def test_existing_spool_flags_duplicate_but_still_imports(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        db_session.add(Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff"))
+        await db_session.commit()
+
+        # Row 1 matches the existing spool (case-insensitively); row 2 is new.
+        csv_text = (
+            "material,brand,color_name,rgba\n"
+            "pla,polymaker,jade white,e8e8e8ff\n"  # duplicate of existing
+            "PETG,OtherBrand,Black,000000ff\n"  # new
+        )
+
+        response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        rows = response.json()["rows"]
+        assert rows[0]["duplicate_of_existing"] is True
+        assert rows[1]["duplicate_of_existing"] is False
+
+        # Soft-warn only: a real import still creates the duplicate row.
+        real = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+        assert real.json()["created"] == 2
+        all_spools = (await db_session.execute(select(Spool))).scalars().all()
+        assert len(all_spools) == 3  # 1 pre-existing + 2 imported

+ 46 - 0
backend/tests/integration/test_library_api.py

@@ -243,6 +243,52 @@ class TestLibraryFilesAPI:
         assert len(result) == 1
         assert len(result) == 1
         assert result[0]["id"] == other_file.id
         assert result[0]["id"] == other_file.id
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
+        """#1621: `internal_only=true` restricts the listing to files in managed
+        storage (`is_external=False`) so a linked NAS with hundreds of files
+        doesn't drown the user's own uploads in the "All Files" sidebar view."""
+        internal_folder = await folder_factory(name="My uploads")
+        external_folder = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
+
+        internal_file = await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
+        await file_factory(folder_id=external_folder.id, filename="nas.3mf", is_external=True)
+        root_file = await file_factory(filename="root.3mf", is_external=False)  # Root-uploaded is always internal.
+
+        response = await async_client.get("/api/v1/library/files?include_root=false&internal_only=true")
+        assert response.status_code == 200
+        ids = {f["id"] for f in response.json()}
+        assert ids == {internal_file.id, root_file.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_external_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
+        """#1621 symmetric: `external_only=true` returns the combined view
+        across every linked external folder so users with several mounts can
+        see all external content in one place without clicking each folder."""
+        internal_folder = await folder_factory(name="My uploads")
+        nas_a = await folder_factory(name="NAS A", is_external=True, external_path="/mnt/a")
+        nas_b = await folder_factory(name="NAS B", is_external=True, external_path="/mnt/b")
+
+        await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
+        ext_a = await file_factory(folder_id=nas_a.id, filename="a.3mf", is_external=True)
+        ext_b = await file_factory(folder_id=nas_b.id, filename="b.3mf", is_external=True)
+
+        response = await async_client.get("/api/v1/library/files?include_root=false&external_only=true")
+        assert response.status_code == 200
+        ids = {f["id"] for f in response.json()}
+        assert ids == {ext_a.id, ext_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_internal_and_external_mutually_exclusive(self, async_client: AsyncClient, db_session):
+        """Both flags together is a caller bug — fail loud (400) rather than
+        silently picking one, so a frontend regression is caught immediately."""
+        response = await async_client.get("/api/v1/library/files?internal_only=true&external_only=true")
+        assert response.status_code == 400
+        assert "mutually exclusive" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):
     async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):

+ 81 - 0
backend/tests/integration/test_print_queue_api.py

@@ -147,6 +147,40 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["status"] == "pending"
         assert result["manual_start"] is True
         assert result["manual_start"] is True
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_skip_filament_check(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """PrintModal "Print Anyway" persists skip_filament_check on creation (#1698-followup)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "skip_filament_check": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["skip_filament_check"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_skip_filament_check_defaults_false(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Default add-to-queue has skip_filament_check=False — no silent bypass."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {"printer_id": printer.id, "archive_id": archive.id}
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["skip_filament_check"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_project_id(
     async def test_add_to_queue_with_project_id(
@@ -583,6 +617,53 @@ class TestQueueStartEndpoint:
         # decision to print anyway.
         # decision to print anyway.
         assert called_with == {}
         assert called_with == {}
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_with_skip_flag_persists_acknowledgement(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+    ):
+        """skip_filament_check=true sets the persistent flag on the queue item
+        so the scheduler doesn't re-flag it on the next tick (#1698-followup).
+
+        Without persistence the route's flag-clearing only survives until the
+        next scheduler tick re-runs the deficit check on identical spool
+        state and re-promotes the item — the user has to click Play+Confirm
+        every single tick.
+        """
+        item = await queue_item_factory(manual_start=True, filament_short=True)
+        assert item.skip_filament_check is False
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["skip_filament_check"] is True
+
+        await db_session.refresh(item)
+        assert item.skip_filament_check is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_without_skip_flag_does_not_set_acknowledgement(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+    ):
+        """A successful Play click with no deficit must NOT silently set the
+        acknowledgement flag — only an explicit Print Anyway should.
+        """
+        item = await queue_item_factory(manual_start=False, filament_short=False)
+        assert item.skip_filament_check is False
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 200
+
+        await db_session.refresh(item)
+        assert item.skip_filament_check is False
+
 
 
 class TestQueueCancelEndpoint:
 class TestQueueCancelEndpoint:
     """Tests for the /queue/{item_id}/cancel endpoint."""
     """Tests for the /queue/{item_id}/cancel endpoint."""

+ 247 - 0
backend/tests/integration/test_queue_start_user_attribution.py

@@ -0,0 +1,247 @@
+"""Regression tests for #1670: queue manual-start path lost user attribution.
+
+Before the fix, a VP-uploaded queue item (created over FTP, so unattributed)
+that was then started by an authenticated user via the `/start` button would
+land in the PrintLogEntry table with `created_by_username = NULL` because
+the scheduler dispatch path never set `current_print_user` and the `/start`
+route didn't record the clicker.
+
+The fix is two-sided:
+  - `POST /queue/{id}/start` credits the clicker as `created_by_id` when
+    no prior owner is set (does NOT overwrite an existing owner — a
+    UI-added queue item's original uploader keeps attribution).
+  - `PrintScheduler._start_print` propagates `item.created_by_id` into
+    `printer_manager.set_current_print_user` so the print-complete callback
+    can write the username into the PrintLogEntry row.
+
+These tests pin both halves so a future refactor can't silently regress
+either one back to "blank User column."
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.print_queue import PrintQueueItem
+
+
+async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
+    """Fresh-session DB read. The `db_session` fixture's connection can
+    look stale after a route call dispatches through its own session via
+    `Depends(get_db)`, so verification reads use a new session against the
+    same engine."""
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as fresh:
+        return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+async def _enable_auth_with_admin(async_client: AsyncClient) -> tuple[str, dict]:
+    """Boot the app's auth setup and return (admin_token, admin_user)."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "queue1670admin",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "queue1670admin", "password": "AdminPass1!"},
+    )
+    body = login.json()
+    return body["access_token"], body["user"]
+
+
+@pytest.fixture
+async def queue_item(db_session):
+    """A pending, manual-start, UNATTRIBUTED queue item — mirrors what the
+    VP-queue path produces (FTP upload has no user, manual_start is the
+    Queue-mode default)."""
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="P2S Test",
+        ip_address="192.168.2.201",
+        serial_number="00M00A1234567890",
+        access_code="12345678",
+        model="P2S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    await db_session.refresh(printer)
+
+    archive = PrintArchive(
+        filename="Plate_1.gcode.3mf",
+        print_name="Plate 1",
+        file_path="/tmp/queue1670_plate.3mf",  # nosec B108
+        file_size=1024,
+        content_hash="queue1670hash",
+        status="completed",
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    await db_session.refresh(archive)
+
+    item = PrintQueueItem(
+        printer_id=printer.id,
+        archive_id=archive.id,
+        status="pending",
+        position=1,
+        manual_start=True,
+        created_by_id=None,  # unattributed — VP-queue shape
+    )
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item)
+    return item
+
+
+class TestStartCreditsTheClicker:
+    """`/start` writes the clicker's id to `created_by_id` when none was set."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_writes_created_by_id_when_unattributed(
+        self, async_client: AsyncClient, test_engine, queue_item
+    ):
+        admin_token, admin_user = await _enable_auth_with_admin(async_client)
+
+        response = await async_client.post(
+            f"/api/v1/queue/{queue_item.id}/start",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert response.status_code == 200
+
+        refreshed = await _read_item(test_engine, queue_item.id)
+        assert refreshed.created_by_id == admin_user["id"]
+        assert refreshed.manual_start is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_preserves_existing_owner(self, async_client: AsyncClient, db_session, test_engine, queue_item):
+        """A queue item that was created by user A and then started by user B
+        keeps user A's attribution — the original uploader's claim is stronger
+        than the dispatcher's. (Matches the standard ownership semantics in
+        `auth.py::require_ownership_permission`.)"""
+        admin_token, admin_user = await _enable_auth_with_admin(async_client)
+
+        # Pre-set a different owner on the queue item using a fresh session
+        # (the test's `db_session` is detached from the route's session pool).
+        prior_owner_id = admin_user["id"] + 9999
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as fresh:
+            item = (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == queue_item.id))).scalar_one()
+            item.created_by_id = prior_owner_id
+            await fresh.commit()
+
+        response = await async_client.post(
+            f"/api/v1/queue/{queue_item.id}/start",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert response.status_code == 200
+
+        refreshed = await _read_item(test_engine, queue_item.id)
+        # Prior owner survives — `/start` did not promote the clicker.
+        assert refreshed.created_by_id == prior_owner_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_with_auth_disabled_leaves_created_by_id_null(
+        self, async_client: AsyncClient, test_engine, queue_item
+    ):
+        """When auth is off the route's user dep returns None — the item stays
+        unattributed (no synthetic 'system' user invented). Regression guard
+        in case a future refactor accidentally invents a placeholder user id."""
+        response = await async_client.post(f"/api/v1/queue/{queue_item.id}/start")
+        assert response.status_code == 200
+
+        refreshed = await _read_item(test_engine, queue_item.id)
+        assert refreshed.created_by_id is None
+
+
+class TestSchedulerPropagatesOwnerToPrinterManager:
+    """`PrintScheduler._propagate_owner_to_printer_manager` looks up the
+    user row by `created_by_id` and forwards it into
+    `printer_manager.set_current_print_user` so the print-complete callback
+    can write the username into PrintLogEntry."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_propagates_when_created_by_id_resolves_to_user(self, db_session, queue_item, monkeypatch):
+        from backend.app.models.user import User
+        from backend.app.services import print_scheduler as scheduler_module
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        user = User(username="clickeruser", password_hash="x", is_active=True)
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        queue_item.created_by_id = user.id
+        db_session.add(queue_item)
+        await db_session.commit()
+        await db_session.refresh(queue_item)
+
+        captured: list[tuple[int, int, str]] = []
+        monkeypatch.setattr(
+            scheduler_module.printer_manager,
+            "set_current_print_user",
+            lambda printer_id, uid, username: captured.append((printer_id, uid, username)),
+        )
+
+        await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
+
+        assert captured == [(queue_item.printer_id, user.id, "clickeruser")]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_noop_when_created_by_id_is_none(self, db_session, queue_item, monkeypatch):
+        """VP-uploaded queue items that never got manual-started (e.g.
+        auto-dispatch) carry no owner — the helper must stay silent rather
+        than synthesise a placeholder user."""
+        from backend.app.services import print_scheduler as scheduler_module
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        assert queue_item.created_by_id is None
+
+        captured: list = []
+        monkeypatch.setattr(
+            scheduler_module.printer_manager,
+            "set_current_print_user",
+            lambda *args: captured.append(args),
+        )
+
+        await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
+        assert captured == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_noop_when_user_row_missing(self, db_session, queue_item, monkeypatch):
+        """`created_by_id` points at a user that's since been deleted —
+        helper must not crash the dispatch. The print log row will just be
+        un-credited for this run, same as auth-disabled."""
+        from backend.app.services import print_scheduler as scheduler_module
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        queue_item.created_by_id = 999_999  # no such user row
+        db_session.add(queue_item)
+        await db_session.commit()
+        await db_session.refresh(queue_item)
+
+        captured: list = []
+        monkeypatch.setattr(
+            scheduler_module.printer_manager,
+            "set_current_print_user",
+            lambda *args: captured.append(args),
+        )
+
+        # Must not raise — the dispatch loop would otherwise lose the whole
+        # queue item to an exception trace for what's effectively a missing
+        # foreign key.
+        await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
+        assert captured == []

+ 59 - 0
backend/tests/integration/test_settings_api.py

@@ -802,6 +802,65 @@ class TestSettingsAPI:
         assert result["ha_token"] == "my-long-lived-token"
         assert result["ha_token"] == "my-long-lived-token"
 
 
 
 
+class TestOpenInSlicerOverride:
+    """Per #1329, the desktop 'Open in Slicer' target can diverge from the API
+    sidecar slicer. The new `open_in_slicer` setting is None by default (frontend
+    inherits from `preferred_slicer`); setting it to 'orcaslicer' or 'bambu_studio'
+    overrides only the desktop URI handoff, not the in-app SliceModal."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_open_in_slicer_default_is_null(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        # Default null so existing installs behave identically — the frontend
+        # then falls back to preferred_slicer.
+        assert response.json()["open_in_slicer"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_open_in_slicer_override_persists(self, async_client: AsyncClient):
+        # Set preferred_slicer=bambu_studio (API sidecar) but
+        # open_in_slicer=orcaslicer (desktop). Exactly the reporter's case:
+        # slice via Bambu Studio sidecar, open files locally in OrcaSlicer.
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"preferred_slicer": "bambu_studio", "open_in_slicer": "orcaslicer"},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["preferred_slicer"] == "bambu_studio"
+        assert body["open_in_slicer"] == "orcaslicer"
+
+        # Persisted across a fresh GET.
+        get_resp = await async_client.get("/api/v1/settings/")
+        assert get_resp.json()["preferred_slicer"] == "bambu_studio"
+        assert get_resp.json()["open_in_slicer"] == "orcaslicer"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_open_in_slicer_can_be_cleared_to_null(self, async_client: AsyncClient):
+        # Reset path: user picks an override, then later goes back to "Same as
+        # API slicer". The literal string "None" the PUT path writes for a
+        # None value must be normalized back to a real null on GET — otherwise
+        # the frontend can't distinguish "explicit override absent" from
+        # "explicit override set to a bogus value".
+        await async_client.put(
+            "/api/v1/settings/",
+            json={"open_in_slicer": "orcaslicer"},
+        )
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"open_in_slicer": None},
+        )
+        assert response.status_code == 200
+        assert response.json()["open_in_slicer"] is None
+
+        # And a fresh GET also sees it as null, not the literal string "None".
+        get_resp = await async_client.get("/api/v1/settings/")
+        assert get_resp.json()["open_in_slicer"] is None
+
+
 class TestSimplifiedBackupRestore:
 class TestSimplifiedBackupRestore:
     """Integration tests for the simplified backup/restore endpoints (ZIP-based).
     """Integration tests for the simplified backup/restore endpoints (ZIP-based).
 
 

+ 15 - 10
backend/tests/integration/test_spool_reset_usage.py

@@ -1,4 +1,9 @@
-"""Reset-usage endpoint regressions (#1390 follow-up).
+"""Reset-consumed-counter endpoint regressions (#1390 follow-up).
+
+Endpoint paths renamed from ``/reset-usage`` to ``/reset-consumed-counter``
+to match what the endpoint actually does (the previous name implied
+``weight_used`` itself would drop to 0, which surprised callers reading
+the JSON response — see the discussion that drove this rename).
 
 
 The per-spool and bulk reset endpoints stamp `weight_used_baseline =
 The per-spool and bulk reset endpoints stamp `weight_used_baseline =
 weight_used` instead of zeroing `weight_used` directly. This decouples
 weight_used` instead of zeroing `weight_used` directly. This decouples
@@ -60,7 +65,7 @@ class TestResetSpoolUsage:
         """
         """
         spool = await spool_factory(label_weight=1000, weight_used=456.0)
         spool = await spool_factory(label_weight=1000, weight_used=456.0)
 
 
-        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-consumed-counter")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         body = response.json()
         body = response.json()
@@ -86,7 +91,7 @@ class TestResetSpoolUsage:
         """
         """
         spool = await spool_factory(weight_used=100.0, weight_locked=False)
         spool = await spool_factory(weight_used=100.0, weight_locked=False)
 
 
-        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-consumed-counter")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         await db_session.refresh(spool)
         await db_session.refresh(spool)
@@ -100,7 +105,7 @@ class TestResetSpoolUsage:
         """If the user previously locked the spool, the lock is preserved."""
         """If the user previously locked the spool, the lock is preserved."""
         spool = await spool_factory(weight_used=500.0, weight_locked=True)
         spool = await spool_factory(weight_used=500.0, weight_locked=True)
 
 
-        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-consumed-counter")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         await db_session.refresh(spool)
         await db_session.refresh(spool)
@@ -117,7 +122,7 @@ class TestResetSpoolUsage:
         counter while remaining keeps decrementing normally.
         counter while remaining keeps decrementing normally.
         """
         """
         spool = await spool_factory(label_weight=1000, weight_used=456.0)
         spool = await spool_factory(label_weight=1000, weight_used=456.0)
-        await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+        await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-consumed-counter")
 
 
         # Simulate a 50g print (usage_tracker increments weight_used).
         # Simulate a 50g print (usage_tracker increments weight_used).
         await db_session.refresh(spool)
         await db_session.refresh(spool)
@@ -133,7 +138,7 @@ class TestResetSpoolUsage:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
     async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
-        response = await async_client.post("/api/v1/inventory/spools/99999/reset-usage")
+        response = await async_client.post("/api/v1/inventory/spools/99999/reset-consumed-counter")
         assert response.status_code == 404
         assert response.status_code == 404
 
 
 
 
@@ -149,7 +154,7 @@ class TestBulkResetSpoolUsage:
         untouched = await spool_factory(weight_used=300.0)
         untouched = await spool_factory(weight_used=300.0)
 
 
         response = await async_client.post(
         response = await async_client.post(
-            "/api/v1/inventory/spools/reset-usage-bulk",
+            "/api/v1/inventory/spools/reset-consumed-counter-bulk",
             json={"spool_ids": [target1.id, target2.id]},
             json={"spool_ids": [target1.id, target2.id]},
         )
         )
 
 
@@ -173,7 +178,7 @@ class TestBulkResetSpoolUsage:
     async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
     async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
         """Empty list must be rejected — guards against accidental wildcard wipes."""
         """Empty list must be rejected — guards against accidental wildcard wipes."""
         response = await async_client.post(
         response = await async_client.post(
-            "/api/v1/inventory/spools/reset-usage-bulk",
+            "/api/v1/inventory/spools/reset-consumed-counter-bulk",
             json={"spool_ids": []},
             json={"spool_ids": []},
         )
         )
         assert response.status_code == 400
         assert response.status_code == 400
@@ -183,7 +188,7 @@ class TestBulkResetSpoolUsage:
     async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
     async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
         """Missing spool_ids field must be rejected."""
         """Missing spool_ids field must be rejected."""
         response = await async_client.post(
         response = await async_client.post(
-            "/api/v1/inventory/spools/reset-usage-bulk",
+            "/api/v1/inventory/spools/reset-consumed-counter-bulk",
             json={},
             json={},
         )
         )
         assert response.status_code == 400
         assert response.status_code == 400
@@ -196,7 +201,7 @@ class TestBulkResetSpoolUsage:
         locked = await spool_factory(weight_used=200.0, weight_locked=True)
         locked = await spool_factory(weight_used=200.0, weight_locked=True)
 
 
         response = await async_client.post(
         response = await async_client.post(
-            "/api/v1/inventory/spools/reset-usage-bulk",
+            "/api/v1/inventory/spools/reset-consumed-counter-bulk",
             json={"spool_ids": [unlocked.id, locked.id]},
             json={"spool_ids": [unlocked.id, locked.id]},
         )
         )
 
 

+ 7 - 7
backend/tests/integration/test_spoolman_inventory_api.py

@@ -560,13 +560,13 @@ class TestSpoolmanInventoryCRUD:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_reset_spool_usage(
+    async def test_reset_spool_consumed_counter(
         self,
         self,
         async_client: AsyncClient,
         async_client: AsyncClient,
         spoolman_settings,
         spoolman_settings,
         mock_spoolman_client,
         mock_spoolman_client,
     ):
     ):
-        """POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman.
+        """POST /spoolman/inventory/spools/{id}/reset-consumed-counter zeroes the displayed counter.
 
 
         Parity with internal mode (#1390): the InventorySpool response
         Parity with internal mode (#1390): the InventorySpool response
         carries `weight_used = label - remaining` and
         carries `weight_used = label - remaining` and
@@ -575,7 +575,7 @@ class TestSpoolmanInventoryCRUD:
         while remaining (= label - weight_used) preserves Spoolman's
         while remaining (= label - weight_used) preserves Spoolman's
         independent remaining_weight field.
         independent remaining_weight field.
         """
         """
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-usage")
+        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-consumed-counter")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         body = response.json()
         body = response.json()
@@ -588,15 +588,15 @@ class TestSpoolmanInventoryCRUD:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_bulk_reset_spool_usage(
+    async def test_bulk_reset_spool_consumed_counter(
         self,
         self,
         async_client: AsyncClient,
         async_client: AsyncClient,
         spoolman_settings,
         spoolman_settings,
         mock_spoolman_client,
         mock_spoolman_client,
     ):
     ):
-        """Bulk endpoint resets each listed spool and returns the count."""
+        """Bulk endpoint resets each listed spool's counter and returns the count."""
         response = await async_client.post(
         response = await async_client.post(
-            "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
+            "/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk",
             json={"spool_ids": [1, 2, 3]},
             json={"spool_ids": [1, 2, 3]},
         )
         )
 
 
@@ -614,7 +614,7 @@ class TestSpoolmanInventoryCRUD:
     ):
     ):
         """Empty list must be rejected — guards against accidental wildcard wipes."""
         """Empty list must be rejected — guards against accidental wildcard wipes."""
         response = await async_client.post(
         response = await async_client.post(
-            "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
+            "/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk",
             json={"spool_ids": []},
             json={"spool_ids": []},
         )
         )
 
 

+ 65 - 0
backend/tests/integration/test_system_api.py

@@ -29,6 +29,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -58,6 +59,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -82,6 +84,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -114,6 +117,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -144,6 +148,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -170,6 +175,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -200,6 +206,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
 
 
@@ -257,6 +264,7 @@ class TestSystemAPI:
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
                 total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_percent.return_value = 25.0
             mock_psutil.cpu_percent.return_value = 25.0
             mock_pm._clients = {}
             mock_pm._clients = {}
@@ -271,6 +279,63 @@ class TestSystemAPI:
         assert db_info["archives_failed"] >= 1
         assert db_info["archives_failed"] >= 1
         assert db_info["total_print_time_seconds"] >= 5400
         assert db_info["total_print_time_seconds"] >= 5400
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_boot_time_uses_pid1_create_time(self, async_client: AsyncClient):
+        """#1690: container installs (Docker/LXC) share the host kernel, so
+        psutil.boot_time() returns the host's boot time instead of the
+        container's. Reading PID 1's create_time gives the container start
+        time on containers and matches host boot on bare metal."""
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
+            mock_psutil.disk_usage.return_value = MagicMock(
+                total=500000000000, used=250000000000, free=250000000000, percent=50.0
+            )
+            mock_psutil.virtual_memory.return_value = MagicMock(
+                total=16000000000, available=8000000000, used=8000000000, percent=50.0
+            )
+            # Host boot is FOUR DAYS earlier than the container's PID 1 start.
+            # The route must report the PID 1 value, not the host value.
+            mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700345600.0
+            mock_psutil.cpu_count.return_value = 4
+            mock_psutil.cpu_percent.return_value = 25.0
+
+            response = await async_client.get("/api/v1/system/info")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["system"]["boot_time"].startswith("2023-11-18T")  # 1700345600 UTC
+        # PID 1 was queried with pid=1 (not the worker pid).
+        mock_psutil.Process.assert_called_with(1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_boot_time_falls_back_to_psutil_boot_time_on_pid1_failure(self, async_client: AsyncClient):
+        """If PID 1 is unreadable (rare — locked-down container, /proc not
+        mounted), fall back to psutil.boot_time() so the endpoint still
+        returns 200 with the best available answer."""
+        import psutil as real_psutil
+
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
+            mock_psutil.disk_usage.return_value = MagicMock(
+                total=500000000000, used=250000000000, free=250000000000, percent=50.0
+            )
+            mock_psutil.virtual_memory.return_value = MagicMock(
+                total=16000000000, available=8000000000, used=8000000000, percent=50.0
+            )
+            mock_psutil.boot_time.return_value = 1700000000.0
+            # Use the real exception classes so the route's except clause matches.
+            mock_psutil.Error = real_psutil.Error
+            mock_psutil.Process.side_effect = real_psutil.NoSuchProcess(1)
+            mock_psutil.cpu_count.return_value = 4
+            mock_psutil.cpu_percent.return_value = 25.0
+
+            response = await async_client.get("/api/v1/system/info")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["system"]["boot_time"].startswith("2023-11-14T")  # 1700000000 UTC
+
 
 
 class TestSystemHelperFunctions:
 class TestSystemHelperFunctions:
     """Tests for system info helper functions."""
     """Tests for system info helper functions."""

+ 227 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -452,3 +452,230 @@ class TestVirtualPrinterDiagnosticAPI:
         by_id = {c["id"]: c["status"] for c in result["checks"]}
         by_id = {c["id"]: c["status"] for c in result["checks"]}
         assert by_id["enabled"] == "fail"
         assert by_id["enabled"] == "fail"
         assert by_id["running"] == "skip"
         assert by_id["running"] == "skip"
+
+
+class TestVirtualPrinterAccessCodeInheritance:
+    """Non-proxy VPs with a target printer must inherit the target's access
+    code at write time.
+
+    The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
+    the real printer — if the codes diverge the slicer binds the VP but the
+    bridge fails at the second hop. The route layer force-derives the code
+    on every create / update so a non-UI client can't introduce a divergence
+    either.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_target_ignores_submitted_access_code(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "QueueVP",
+                "mode": "queue",
+                "access_code": "WRONGAAA",
+                "target_printer_id": target.id,
+            },
+        )
+        assert response.status_code == 200
+        vp_id = response.json()["id"]
+        assert response.json()["access_code_set"] is True
+
+        vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
+        assert vp.access_code == "REALCODE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_target_and_no_access_code_still_enables(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """A non-proxy VP with a target set can be enabled without supplying
+        access_code separately — the inheritance makes the explicit field
+        redundant, and the validator now knows this."""
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "QueueVP",
+                "mode": "queue",
+                "target_printer_id": target.id,
+                "bind_ip": "192.168.1.50",
+                "enabled": True,
+            },
+        )
+        assert response.status_code == 200
+        assert response.json()["access_code_set"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
+        """The relaxation only kicks in when a target is set. A standalone
+        non-proxy VP still needs its own access code."""
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "StandaloneVP",
+                "mode": "archive",
+                "bind_ip": "192.168.1.51",
+                "enabled": True,
+            },
+        )
+        assert response.status_code == 400
+        assert "access code" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
+        second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "MovingTarget",
+                "mode": "queue",
+                "target_printer_id": first.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
+        assert vp.access_code == "AAAAAAAA"
+
+        # Repoint to the second printer — access code should follow.
+        update_resp = await async_client.put(
+            f"/api/v1/virtual-printers/{vp_id}",
+            json={"target_printer_id": second.id},
+        )
+        assert update_resp.status_code == 200
+
+        await db_session.refresh(vp)
+        assert vp.access_code == "BBBBBBBB"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_explicit_access_code_with_target_is_overridden(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """An update that submits both an explicit access_code AND keeps a
+        target_printer_id silently uses the target's code — belt-and-braces
+        for non-UI clients that might try to set a divergent value."""
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "BeltBraces",
+                "mode": "queue",
+                "target_printer_id": target.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        update_resp = await async_client.put(
+            f"/api/v1/virtual-printers/{vp_id}",
+            json={"access_code": "FORGEDCD"},
+        )
+        assert update_resp.status_code == 200
+
+        vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
+        assert vp.access_code == "REALCODE"
+
+
+class TestVirtualPrinterSerialSurface:
+    """Proxy-mode VPs must surface the target printer's serial in API responses.
+
+    The bridge advertises the target's serial over SSDP and forwards the
+    target's identity to the slicer; the VP-settings card should show the
+    same serial so the user sees one consistent identity per VP. Archive /
+    queue / review VPs keep the self-generated suffix-based serial since
+    those modes never speak the target's identity.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_proxy_vp_response_uses_target_printer_serial(self, async_client: AsyncClient, printer_factory):
+        target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "ProxyVP",
+                "mode": "proxy",
+                "target_printer_id": target.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+        assert create_resp.json()["serial"] == "00M09A123456789"
+
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
+        assert get_resp.status_code == 200
+        assert get_resp.json()["serial"] == "00M09A123456789"
+
+        list_resp = await async_client.get("/api/v1/virtual-printers")
+        assert list_resp.status_code == 200
+        listed = next(p for p in list_resp.json()["printers"] if p["id"] == vp_id)
+        assert listed["serial"] == "00M09A123456789"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_proxy_vp_keeps_self_generated_serial(self, async_client: AsyncClient, printer_factory):
+        # Even with a target printer set (#1429 access-code inheritance flow),
+        # archive / queue / review VPs are NOT bridging the target's identity
+        # to the slicer — they synthesise their own. Self-generated serial.
+        target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "QueueVP",
+                "mode": "queue",
+                "target_printer_id": target.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        assert create_resp.json()["serial"] != "00M09A123456789"
+        # The synthesised serial follows _get_serial_for_model's `<prefix><suffix>`
+        # shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
+        assert len(create_resp.json()["serial"]) >= 8
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
+        self, async_client: AsyncClient, db_session
+    ):
+        # Defensive fallback: a proxy VP whose target_printer_id points at a
+        # row that no longer exists (printer deleted mid-config, manual SQL
+        # tweak, race) must not 500 — it returns the self-generated serial
+        # so the card still renders and the user can fix the target.
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        vp = VirtualPrinter(name="OrphanProxy", mode="proxy", target_printer_id=99999, enabled=False)
+        db_session.add(vp)
+        await db_session.commit()
+
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp.id}")
+        assert get_resp.status_code == 200
+        body = get_resp.json()
+        assert body["serial"]  # non-empty
+        assert len(body["serial"]) >= 8

+ 104 - 0
backend/tests/unit/core/test_tasks.py

@@ -0,0 +1,104 @@
+"""Unit tests for the spawn_background_task helper (#1648 follow-up).
+
+asyncio holds only weak references to tasks, so a fire-and-forget
+create_task whose return value is discarded can be GC'd mid-flight and
+log ``Task was destroyed but it is pending!`` with no traceback.
+``spawn_background_task`` is the central helper that fixes this: it
+stores a strong reference until completion, surfaces uncaught exceptions
+through the logger, and auto-removes finished tasks.
+"""
+
+import asyncio
+import logging
+
+import pytest
+
+from backend.app.core.tasks import active_task_count, spawn_background_task
+
+
+@pytest.mark.asyncio
+async def test_holds_strong_ref_until_completion():
+    """Discarding the returned task must not let asyncio reap it mid-flight.
+    Pre-fix, ``asyncio.create_task(coro)`` with no caller-side reference
+    would let GC swallow short tasks before they finished."""
+    finished = asyncio.Event()
+
+    async def work() -> None:
+        await asyncio.sleep(0)
+        finished.set()
+
+    # Note: NOT storing the returned task -- this is exactly the
+    # pattern the helper exists to support.
+    spawn_background_task(work())
+    await asyncio.wait_for(finished.wait(), timeout=1.0)
+
+
+@pytest.mark.asyncio
+async def test_removes_from_strong_ref_set_after_completion():
+    """The strong-ref set must shrink as tasks complete; otherwise a
+    long-running process accumulates one entry per spawned task and the
+    helper itself becomes a leak."""
+    before = active_task_count()
+
+    async def work() -> None:
+        await asyncio.sleep(0)
+
+    spawn_background_task(work())
+    spawn_background_task(work())
+    # Yield enough times for both tasks + their done-callbacks to run.
+    for _ in range(5):
+        await asyncio.sleep(0)
+    assert active_task_count() == before
+
+
+@pytest.mark.asyncio
+async def test_uncaught_exception_logged_as_warning(caplog):
+    """A fire-and-forget task that raises must surface the exception via
+    the logger with the traceback attached -- otherwise the error vanishes
+    silently and only an opaque ``Task was destroyed`` notice reaches the
+    support bundle."""
+
+    async def boom() -> None:
+        raise RuntimeError("synthetic failure for test")
+
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.tasks"):
+        spawn_background_task(boom(), name="boom-task")
+        for _ in range(5):
+            await asyncio.sleep(0)
+
+    # One WARNING with the task name and the exception info.
+    boom_records = [r for r in caplog.records if "boom-task" in r.message]
+    assert len(boom_records) == 1
+    assert boom_records[0].levelno == logging.WARNING
+    assert boom_records[0].exc_info is not None
+    assert isinstance(boom_records[0].exc_info[1], RuntimeError)
+
+
+@pytest.mark.asyncio
+async def test_cancelled_task_does_not_log_exception(caplog):
+    """Explicit cancellation isn't an error -- a service shutting down
+    its background loops should not be reported as 'uncaught exception'."""
+
+    async def long_running() -> None:
+        await asyncio.sleep(10.0)
+
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.tasks"):
+        task = spawn_background_task(long_running(), name="cancel-me")
+        await asyncio.sleep(0)  # Let it start.
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+    assert not any("cancel-me" in r.message for r in caplog.records)
+
+
+@pytest.mark.asyncio
+async def test_task_name_propagates():
+    """Named tasks make the leak source visible in tracebacks and the
+    done-callback log line. Pin that ``name`` reaches the underlying
+    Task so support bundles surface the spawn site."""
+    task = spawn_background_task(asyncio.sleep(0), name="named-spawn-test")
+    assert task.get_name() == "named-spawn-test"
+    await task

+ 63 - 0
backend/tests/unit/services/test_archive_service.py

@@ -255,6 +255,69 @@ class TestArchiveThumbnails:
         assert parsed.get("_thumbnail_data") == b"PLATE1"
         assert parsed.get("_thumbnail_data") == b"PLATE1"
 
 
 
 
+class TestThreeMFMetadataHTMLUnescape:
+    """3MF `<metadata name="Title">…</metadata>` values are XML-encoded.
+    BambuStudio sometimes writes triple-encoded payloads (the
+    ProjectPageParser comment documents this). Without an unescape loop,
+    a Title like ``Foo & Bar`` lands in the DB as raw ``Foo &amp; Bar`` and
+    React then escapes the `&` on render to ``Foo &amp;amp; Bar`` — the
+    user-visible symptom reported on #1658."""
+
+    def test_title_with_ampersand_is_unescaped(self, tmp_path):
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "ampersand.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr(
+                "3D/3dmodel.model",
+                '<?xml version="1.0" encoding="UTF-8"?>\n'
+                '<model><metadata name="Title">PCB Vise &amp; Solder Station</metadata>'
+                '<metadata name="Designer">Chefkoch</metadata></model>',
+            )
+
+        parsed = ThreeMFParser(str(threemf_path)).parse()
+        assert parsed.get("print_name") == "PCB Vise & Solder Station"
+        assert parsed.get("designer") == "Chefkoch"
+
+    def test_title_with_triple_encoded_ampersand_is_fully_unescaped(self, tmp_path):
+        """BambuStudio has been observed writing triple-encoded payloads
+        (`&amp;amp;amp;`). The decoder loops until the string stops changing
+        so all layers get peeled in one pass."""
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "triple.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr(
+                "3D/3dmodel.model",
+                '<?xml version="1.0" encoding="UTF-8"?>\n'
+                '<model><metadata name="Title">Foo &amp;amp;amp; Bar</metadata></model>',
+            )
+
+        parsed = ThreeMFParser(str(threemf_path)).parse()
+        assert parsed.get("print_name") == "Foo & Bar"
+
+    def test_title_without_entities_passes_through_unchanged(self, tmp_path):
+        """The unescape loop must be a no-op when there's nothing to unescape —
+        regression guard against accidentally munging plain ASCII titles."""
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "plain.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr(
+                "3D/3dmodel.model",
+                '<?xml version="1.0" encoding="UTF-8"?>\n<model><metadata name="Title">Benchy</metadata></model>',
+            )
+
+        parsed = ThreeMFParser(str(threemf_path)).parse()
+        assert parsed.get("print_name") == "Benchy"
+
+
 class TestPrintableObjectsExtraction:
 class TestPrintableObjectsExtraction:
     """Tests for extracting printable objects count from 3MF files."""
     """Tests for extracting printable objects count from 3MF files."""
 
 

+ 6 - 1
backend/tests/unit/services/test_background_dispatch.py

@@ -231,7 +231,12 @@ def test_dispatch_option_defaults_align_with_request_schema_defaults():
     from backend.app.schemas.library import FilePrintRequest
     from backend.app.schemas.library import FilePrintRequest
     from backend.app.services import background_dispatch as bd
     from backend.app.services import background_dispatch as bd
 
 
-    fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "timelapse", "use_ams")
+    # `timelapse` deliberately excluded — the dispatcher now resolves it via
+    # ``_resolve_effective_timelapse`` so the value passed to ``start_print``
+    # depends on the ``capture_finish_photo`` setting + ``bambuddy_forced_timelapse``
+    # column (#1397). The original literal `job.options.get("timelapse", False)`
+    # pattern no longer appears.
+    fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "use_ams")
     reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
     reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
     libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
     libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
     assert reprint_defaults == libprint_defaults, (
     assert reprint_defaults == libprint_defaults, (

+ 16 - 0
backend/tests/unit/services/test_background_dispatch_watchdog.py

@@ -606,6 +606,14 @@ class TestReprintArchiveDispatchWiring:
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
             ),
             ),
             patch("backend.app.main.register_expected_print"),
             patch("backend.app.main.register_expected_print"),
+            # #1397: _resolve_effective_timelapse touches DB + setting layer
+            # that this watchdog-focused test isn't equipped to mock. Stub
+            # the whole helper so the dispatch flow proceeds unaffected.
+            patch.object(
+                BackgroundDispatchService,
+                "_resolve_effective_timelapse",
+                new=AsyncMock(return_value=False),
+            ),
             pytest.raises(RuntimeError, match="did not acknowledge print command"),
             pytest.raises(RuntimeError, match="did not acknowledge print command"),
         ):
         ):
             await service._run_reprint_archive(job)
             await service._run_reprint_archive(job)
@@ -674,6 +682,14 @@ class TestReprintArchiveDispatchWiring:
                 new_callable=AsyncMock,
                 new_callable=AsyncMock,
             ),
             ),
             patch("backend.app.main.register_expected_print"),
             patch("backend.app.main.register_expected_print"),
+            # #1397: _resolve_effective_timelapse touches DB + setting layer
+            # that this watchdog-focused test isn't equipped to mock. Stub
+            # the whole helper so the dispatch flow proceeds unaffected.
+            patch.object(
+                BackgroundDispatchService,
+                "_resolve_effective_timelapse",
+                new=AsyncMock(return_value=False),
+            ),
         ):
         ):
             await service._run_reprint_archive(job)  # must not raise
             await service._run_reprint_archive(job)  # must not raise
 
 

+ 55 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3826,6 +3826,61 @@ class TestStartPrintAmsMapping:
         # flow-dynamics calibration instead of reusing the stored PA value.
         # flow-dynamics calibration instead of reusing the stored PA value.
         assert cmd["extrude_cali_flag"] == 1
         assert cmd["extrude_cali_flag"] == 1
 
 
+    def test_nozzle_offset_cali_default_is_skip(self, mqtt_client):
+        """Default `nozzle_offset_cali=False` → wire value `2` (skip).
+
+        Matches the legacy behavior on every model: BambuStudio sends `2`
+        unless the user enabled the toggle for a dual-nozzle machine. The
+        legacy hardcoded value before #1682 was `2` for everyone — this
+        test pins that default so we don't regress.
+        """
+        mqtt_client.model = "P1S"
+        mqtt_client.start_print("test.3mf")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["nozzle_offset_cali"] == 2
+
+    def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
+        """Single-nozzle printer: `nozzle_offset_cali=True` is silently dropped.
+
+        H2S is in the H2 firmware family but single-nozzle. The toggle has
+        no physical meaning on single-nozzle machines and the UI gates it
+        behind `nozzle_count==2`. Even if a stale queue item from when the
+        printer was misidentified as dual carries the flag, the MQTT layer
+        must downgrade it so firmware never tries to calibrate a head it
+        doesn't have (#1682).
+        """
+        mqtt_client.model = "P1S"
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["nozzle_offset_cali"] == 2
+
+    def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
+        """Dual-nozzle printer (H2D): `nozzle_offset_cali=True` → wire value `1`.
+
+        H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
+        printer runs the nozzle-offset calibration pass before the print
+        starts. `1`=run, `2`=skip — matches BambuStudio's encoding (#1682).
+        """
+        mqtt_client.model = "H2D"
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["nozzle_offset_cali"] == 1
+
+    def test_nozzle_offset_cali_false_on_dual_nozzle(self, mqtt_client):
+        """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali=False` → `2` (skip).
+
+        Same wire encoding as legacy. Critical for users like #1682 who run
+        diamond nozzles and need to keep the calibration off.
+        """
+        mqtt_client.model = "H2D Pro"
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali=False)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["nozzle_offset_cali"] == 2
+
 
 
 class TestStartPrintUniqueIdentityFields:
 class TestStartPrintUniqueIdentityFields:
     """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
     """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).

+ 156 - 0
backend/tests/unit/services/test_dispatch_force_timelapse.py

@@ -0,0 +1,156 @@
+"""Tests for _resolve_effective_timelapse (#1397).
+
+Bambuddy forces timelapse recording on at dispatch time when the
+capture_finish_photo setting is enabled and the user did not opt in
+to timelapse for the specific print. The forced bit is recorded on
+the archive so the post-extraction cleanup path can delete the file.
+
+These tests exercise the four decision shapes the helper has to handle:
+
+  1. capture_finish_photo OFF → no override regardless of user choice
+  2. capture_finish_photo ON, user chose timelapse → no override (the
+     user's choice already covers the photo path)
+  3. capture_finish_photo ON, user chose NO timelapse → override to ON,
+     mark archive.bambuddy_forced_timelapse=True
+  4. capture_finish_photo unset (None / missing) → defaults to ON, so
+     the same override applies as case 3
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.background_dispatch import (
+    BackgroundDispatchService,
+    PrintDispatchJob,
+)
+
+
+def _make_job(timelapse: bool | None) -> PrintDispatchJob:
+    """Mint a job with the smallest valid shape — the only field
+    _resolve_effective_timelapse reads from job is `options`."""
+    return PrintDispatchJob(
+        id=1,
+        kind="print_library_file",
+        source_id=42,
+        source_name="test.gcode.3mf",
+        printer_id=10,
+        printer_name="Printer A",
+        options={"timelapse": timelapse} if timelapse is not None else {},
+    )
+
+
+def _make_archive() -> SimpleNamespace:
+    """Stand-in archive object; the helper only touches .id and
+    .bambuddy_forced_timelapse."""
+    return SimpleNamespace(id=99, bambuddy_forced_timelapse=False)
+
+
+def _make_db() -> AsyncMock:
+    """Fake db with a no-op .commit()."""
+    db = AsyncMock()
+    return db
+
+
+@pytest.mark.asyncio
+async def test_capture_finish_photo_off_means_no_override():
+    """Master toggle off → user's timelapse=False stays False, no flag set."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=False)
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="false"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is False
+    assert archive.bambuddy_forced_timelapse is False
+    db.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_user_opted_in_passes_through_unchanged():
+    """User asked for a timelapse → no override needed (their normal flow
+    already records one). bambuddy_forced_timelapse stays False so cleanup
+    leaves the file alone."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=True)
+
+    # get_setting shouldn't even be consulted — but if it is, no override
+    # should still fire.
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="true"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is False
+    db.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_capture_on_user_off_forces_timelapse_and_marks_flag():
+    """The whole point of the fix: capture_finish_photo=on + user-timelapse=off
+    flips the MQTT command to timelapse=True and marks the archive for
+    post-extraction cleanup."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=False)
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="true"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is True
+    db.commit.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_capture_finish_photo_unset_defaults_to_enabled():
+    """Setting absent from DB → default is True (per the Field default in the
+    schema), so the override fires just like when explicitly enabled."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=False)
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value=None),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is True
+    db.commit.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_user_missing_timelapse_treated_as_false():
+    """Some queue paths pass options without a timelapse key. Treat absent
+    as False (matches existing job.options.get('timelapse', False) default
+    that the caller previously used)."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=None)  # falls through to {}
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="true"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is True

+ 16 - 0
backend/tests/unit/services/test_ftp_profiles.py

@@ -43,10 +43,26 @@ def test_p2s_internal_ssdp_code_resolves_to_p2s():
     assert profile.cap_tls_v1_2 is True
     assert profile.cap_tls_v1_2 is True
 
 
 
 
+def test_x2d_caps_tls_v1_2():
+    """X2D firmware 01.01.00.00 fails implicit-FTPS handshake on port
+    990 with WRONG_VERSION_NUMBER against Python 3.13's TLS-1.3 default
+    (#1638, reporter @vasmarfas). The profile caps to TLS 1.2 by
+    analogy with P2S."""
+    profile = get_ftp_profile("X2D")
+    assert profile.cap_tls_v1_2 is True
+
+
+def test_x2d_internal_ssdp_code_resolves_to_x2d():
+    """SSDP internal code N6 → X2D profile."""
+    profile = get_ftp_profile("N6")
+    assert profile.cap_tls_v1_2 is True
+
+
 def test_lookup_is_case_insensitive():
 def test_lookup_is_case_insensitive():
     """Printer.model may carry mixed case; the lookup normalises."""
     """Printer.model may carry mixed case; the lookup normalises."""
     assert get_ftp_profile("p2s").cap_tls_v1_2 is True
     assert get_ftp_profile("p2s").cap_tls_v1_2 is True
     assert get_ftp_profile("P2s").cap_tls_v1_2 is True
     assert get_ftp_profile("P2s").cap_tls_v1_2 is True
+    assert get_ftp_profile("x2d").cap_tls_v1_2 is True
 
 
 
 
 def test_non_capped_models_still_default():
 def test_non_capped_models_still_default():

+ 433 - 0
backend/tests/unit/services/test_orca_cloud.py

@@ -0,0 +1,433 @@
+"""Tests for the Orca Cloud service — PKCE generation, authorize URL shape,
+token exchange / refresh round-trip, single-use refresh token rotation,
+and Cloudflare-cleaning User-Agent header."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+from urllib.parse import parse_qs, urlparse
+
+import httpx
+import pytest
+
+from backend.app.services import orca_cloud
+from backend.app.services.orca_cloud import (
+    ORCA_ANON_KEY,
+    ORCA_AUTH_BASE,
+    ORCA_REDIRECT_URI,
+    OrcaCloudAuthError,
+    OrcaCloudError,
+    OrcaCloudService,
+    build_authorize_url,
+    generate_pkce,
+    parse_callback_url,
+)
+
+# ---------------------------------------------------------------------------
+# PKCE primitives
+# ---------------------------------------------------------------------------
+
+
+class TestPkce:
+    def test_challenge_is_sha256_of_verifier(self):
+        """The challenge must be base64url(sha256(verifier)) — this is the
+        RFC 7636 invariant Supabase will check on the exchange step. A bug
+        here means the exchange always fails with code_verifier mismatch."""
+        verifier, challenge, _state = generate_pkce()
+        expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
+        assert challenge == expected
+
+    def test_verifier_length_in_rfc_range(self):
+        verifier, _challenge, _state = generate_pkce()
+        # 32 random bytes -> 43 chars after base64url-no-pad; RFC 7636
+        # requires 43-128.
+        assert 43 <= len(verifier) <= 128
+
+    def test_state_is_unique_per_call(self):
+        """Two consecutive calls must not share state — otherwise a stolen
+        state from one flow could be replayed against another in-flight one."""
+        _, _, s1 = generate_pkce()
+        _, _, s2 = generate_pkce()
+        assert s1 != s2
+
+    def test_characters_are_url_safe(self):
+        """Both verifier and challenge must be URL-safe base64 (no padding,
+        no + or /) so they can be sent as query-string values without
+        re-encoding."""
+        verifier, challenge, state = generate_pkce()
+        for value in (verifier, challenge, state):
+            assert all(c.isalnum() or c in ("-", "_") for c in value), value
+
+
+class TestAuthorizeUrl:
+    def test_url_targets_authorize_endpoint(self):
+        url = build_authorize_url("CHALLENGE")
+        assert url.startswith(f"{ORCA_AUTH_BASE}/auth/v1/authorize?")
+
+    def test_url_contains_required_pkce_params(self):
+        """The four PKCE params Supabase needs at authorize time. Missing any
+        of these = Supabase 400s the request before redirecting to Google."""
+        url = build_authorize_url("CHALLENGE")
+        params = parse_qs(urlparse(url).query)
+        assert params["provider"] == ["google"]
+        assert params["redirect_to"] == [ORCA_REDIRECT_URI]
+        assert params["code_challenge"] == ["CHALLENGE"]
+        assert params["code_challenge_method"] == ["S256"]
+
+    def test_url_does_not_pass_state(self):
+        """Regression guard against re-introducing the bug we hit in the
+        first deployed integration: passing ``state`` to GoTrue's authorize
+        endpoint silently overrides its internal redirect_to tracking, so
+        the user lands at the project Site URL instead of our localhost
+        callback. CSRF is protected by PKCE alone — verifier is server-side
+        and single-use."""
+        url = build_authorize_url("CHALLENGE")
+        params = parse_qs(urlparse(url).query)
+        assert "state" not in params
+
+
+class TestParseCallback:
+    def test_extracts_code_and_state_from_query(self):
+        code, state = parse_callback_url("http://localhost:41172/callback?code=ABC&state=XYZ")
+        assert code == "ABC"
+        assert state == "XYZ"
+
+    def test_falls_back_to_fragment(self):
+        """Some Supabase configurations put PKCE codes in the URL fragment
+        rather than the query (depends on response_mode setting). Both must
+        be handled or some users get a confusing 'no code in URL' error."""
+        code, state = parse_callback_url("http://localhost:41172/callback#code=ABC&state=XYZ")
+        assert code == "ABC"
+        assert state == "XYZ"
+
+    def test_returns_none_when_no_code(self):
+        code, state = parse_callback_url("http://localhost:41172/callback?error=denied")
+        assert code is None
+        assert state is None
+
+    def test_handles_whitespace_padding(self):
+        """Users paste from address bars and sometimes accidentally include
+        a leading/trailing space — the parser must be forgiving."""
+        code, _state = parse_callback_url("  http://localhost:41172/callback?code=ABC&state=XYZ  ")
+        assert code == "ABC"
+
+
+# ---------------------------------------------------------------------------
+# Token exchange + refresh
+# ---------------------------------------------------------------------------
+
+
+def _mock_response(
+    *,
+    status_code: int = 200,
+    json_data: dict | None = None,
+    text_body: str = "",
+) -> MagicMock:
+    """Build an httpx-like response mock with the only attributes the
+    service touches: ``status_code``, ``.json()``, ``.text``."""
+    resp = MagicMock(spec=["status_code", "json", "text"])
+    resp.status_code = status_code
+    if json_data is not None:
+        resp.json.return_value = json_data
+        resp.text = json.dumps(json_data)
+    else:
+        resp.json.side_effect = ValueError("not json")
+        resp.text = text_body
+    return resp
+
+
+@pytest.fixture
+def svc() -> OrcaCloudService:
+    return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient))
+
+
+class TestExchangeCode:
+    @pytest.mark.asyncio
+    async def test_success_populates_tokens_and_expiry(self, svc):
+        token_resp = _mock_response(
+            json_data={
+                "access_token": "ACCESS-1",
+                "refresh_token": "REFRESH-1",
+                "expires_in": 3600,
+                "token_type": "bearer",
+            }
+        )
+        svc._client.post = AsyncMock(return_value=token_resp)
+
+        await svc.exchange_code("CODE", "VERIFIER")
+
+        assert svc.access_token == "ACCESS-1"
+        assert svc.refresh_token == "REFRESH-1"
+        assert svc.token_expiry is not None
+        # Expiry should be approximately now + 3600s (within a 60s window).
+        delta = svc.token_expiry - datetime.now(timezone.utc)
+        assert timedelta(seconds=3540) <= delta <= timedelta(seconds=3660)
+
+    @pytest.mark.asyncio
+    async def test_sends_apikey_and_user_agent_headers(self, svc):
+        """Two load-bearing headers: the publishable apikey (Supabase
+        requires it) and a non-default User-Agent (Cloudflare 1010s
+        ``Python-urllib/X.Y`` so an honest ``Bambuddy/<v>`` UA is needed)."""
+        token_resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 3600})
+        svc._client.post = AsyncMock(return_value=token_resp)
+
+        await svc.exchange_code("CODE", "VERIFIER")
+
+        _args, kwargs = svc._client.post.call_args
+        headers = kwargs["headers"]
+        assert headers["apikey"] == ORCA_ANON_KEY
+        assert headers["User-Agent"].startswith("Bambuddy/")
+        assert headers["Content-Type"] == "application/json"
+
+    @pytest.mark.asyncio
+    async def test_400_raises_auth_error_not_generic(self, svc):
+        """400 from Supabase usually means a bad verifier or stale code —
+        the user has to restart sign-in. Raising auth-specific exception
+        lets the route map to a sensible 400 with a 'click Connect again'
+        message rather than a generic 502."""
+        err_resp = _mock_response(
+            status_code=400,
+            json_data={"error": "invalid_grant", "error_description": "code expired"},
+        )
+        svc._client.post = AsyncMock(return_value=err_resp)
+
+        with pytest.raises(OrcaCloudAuthError) as exc:
+            await svc.exchange_code("CODE", "VERIFIER")
+        assert "code expired" in str(exc.value)
+
+    @pytest.mark.asyncio
+    async def test_network_error_wraps_as_orca_error(self, svc):
+        svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
+        with pytest.raises(OrcaCloudError):
+            await svc.exchange_code("CODE", "VERIFIER")
+
+
+class TestPasswordLogin:
+    @pytest.mark.asyncio
+    async def test_success_populates_tokens(self, svc):
+        resp = _mock_response(
+            json_data={
+                "access_token": "PWD-A",
+                "refresh_token": "PWD-R",
+                "expires_in": 3600,
+            }
+        )
+        svc._client.post = AsyncMock(return_value=resp)
+
+        await svc.password_login("user@example.com", "secret")
+
+        assert svc.access_token == "PWD-A"
+        assert svc.refresh_token == "PWD-R"
+
+    @pytest.mark.asyncio
+    async def test_disabled_provider_raises_auth_error_not_generic(self, svc):
+        """Whether Orca's Supabase project accepts password grant is config-
+        dependent. When it doesn't (their desktop SDK refuses passwords by
+        design, the backend may follow suit), the failure mode is a 400 /
+        422 with an error like ``email_provider_disabled``. The caller maps
+        ``OrcaCloudAuthError`` to a 400 with a "use OAuth instead" hint —
+        a 502 would imply Orca is down, which would be wrong UX."""
+        err = _mock_response(
+            status_code=422,
+            json_data={"error": "email_provider_disabled", "error_description": "Email logins are disabled"},
+        )
+        svc._client.post = AsyncMock(return_value=err)
+        with pytest.raises(OrcaCloudAuthError, match="Email logins are disabled"):
+            await svc.password_login("user@example.com", "secret")
+
+    @pytest.mark.asyncio
+    async def test_invalid_credentials_raises_auth_error(self, svc):
+        err = _mock_response(
+            status_code=400,
+            json_data={"error": "invalid_grant", "error_description": "Invalid login credentials"},
+        )
+        svc._client.post = AsyncMock(return_value=err)
+        with pytest.raises(OrcaCloudAuthError, match="Invalid login credentials"):
+            await svc.password_login("user@example.com", "wrong")
+
+
+class TestRefresh:
+    @pytest.mark.asyncio
+    async def test_rotates_refresh_token(self, svc):
+        """Supabase refresh tokens are single-use — every successful refresh
+        returns a NEW refresh token and invalidates the old. If the service
+        kept the old one, the next refresh would 400 and the user would be
+        force-logged-out."""
+        svc.refresh_token = "REFRESH-1"
+        resp = _mock_response(
+            json_data={
+                "access_token": "ACCESS-2",
+                "refresh_token": "REFRESH-2",
+                "expires_in": 3600,
+            }
+        )
+        svc._client.post = AsyncMock(return_value=resp)
+
+        await svc.refresh()
+
+        assert svc.access_token == "ACCESS-2"
+        assert svc.refresh_token == "REFRESH-2"
+
+    @pytest.mark.asyncio
+    async def test_no_refresh_token_raises_auth_error(self, svc):
+        svc.refresh_token = None
+        with pytest.raises(OrcaCloudAuthError):
+            await svc.refresh()
+
+    @pytest.mark.asyncio
+    async def test_rejected_refresh_clears_tokens(self, svc):
+        """If Supabase rejects the refresh token (revoked / rotated out from
+        under us / hit by a token-replay defense), the service must clear
+        the now-useless stored credentials so the UI can flip to the
+        disconnected state rather than retrying forever."""
+        svc.access_token = "OLD-ACCESS"
+        svc.refresh_token = "OLD-REFRESH"
+        svc.token_expiry = datetime.now(timezone.utc)
+        err = _mock_response(
+            status_code=401,
+            json_data={"error": "invalid_grant", "error_description": "refresh token rotated"},
+        )
+        svc._client.post = AsyncMock(return_value=err)
+
+        with pytest.raises(OrcaCloudAuthError):
+            await svc.refresh()
+
+        assert svc.access_token is None
+        assert svc.refresh_token is None
+        assert svc.token_expiry is None
+
+
+class TestIsAuthenticated:
+    def test_no_token_means_not_authenticated(self, svc):
+        assert svc.is_authenticated is False
+
+    def test_no_expiry_means_not_authenticated(self, svc):
+        """Pessimistic default: if we don't know when the token expires,
+        treat it as expired so the next API call triggers a refresh
+        rather than fails halfway through."""
+        svc.access_token = "ACCESS"
+        svc.token_expiry = None
+        assert svc.is_authenticated is False
+
+    def test_within_refresh_leeway_is_not_authenticated(self, svc):
+        """The 5-minute leeway prevents a long-running API call from timing
+        out mid-flight on a token that was technically still valid when the
+        call started."""
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2)
+        assert svc.is_authenticated is False
+
+    def test_with_comfortable_expiry_is_authenticated(self, svc):
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        assert svc.is_authenticated is True
+
+
+class TestApiHeaders:
+    def test_api_headers_include_apikey_and_bearer(self, svc):
+        svc.access_token = "ACCESS-123"
+        headers = svc._api_headers()
+        assert headers["apikey"] == ORCA_ANON_KEY
+        assert headers["Authorization"] == "Bearer ACCESS-123"
+        assert headers["User-Agent"].startswith("Bambuddy/")
+
+    def test_api_headers_without_token_raises(self, svc):
+        svc.access_token = None
+        with pytest.raises(OrcaCloudAuthError):
+            svc._api_headers()
+
+
+class TestListProfiles:
+    @pytest.mark.asyncio
+    async def test_pull_response_upserts_extracted(self, svc):
+        """The bare-cursor /sync/pull returns a ``SyncPullResponse`` shape;
+        we extract the ``upserts`` list and ignore ``next_cursor`` / ``deletes``
+        (no prior client state to invalidate)."""
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc._client.get = AsyncMock(
+            return_value=_mock_response(
+                json_data={
+                    "next_cursor": 12345,
+                    "upserts": [
+                        {"id": "a", "name": "A", "content": {"x": 1}},
+                        {"id": "b", "name": "B", "content": {"x": 2}},
+                    ],
+                    "deletes": ["zzz"],
+                },
+            )
+        )
+        result = await svc.list_profiles()
+        assert [p["id"] for p in result] == ["a", "b"]
+
+    @pytest.mark.asyncio
+    async def test_pull_hits_path_without_cursor(self, svc):
+        """Regression guard: ``cursor=0`` trips ``410 cursor_too_old`` on
+        the production endpoint. The first-sync bootstrap must hit
+        ``/api/v1/sync/pull`` with no ``?cursor=`` parameter — same behaviour
+        as OrcaSlicer's own client."""
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc._client.get = AsyncMock(
+            return_value=_mock_response(json_data={"upserts": [], "deletes": []}),
+        )
+        await svc.list_profiles()
+        called_url = svc._client.get.call_args.args[0]
+        assert called_url.endswith("/api/v1/sync/pull")
+        assert "cursor" not in called_url
+        # And no ``params`` kwarg either, which would be a second way to
+        # smuggle the cursor in.
+        assert "params" not in svc._client.get.call_args.kwargs
+
+    @pytest.mark.asyncio
+    async def test_bare_list_response_tolerated(self, svc):
+        """If the server ever rolls out a flat-list response shape, we
+        forward it verbatim rather than logging-and-empty."""
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc._client.get = AsyncMock(
+            return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]),
+        )
+        assert [p["id"] for p in await svc.list_profiles()] == ["a"]
+
+
+class TestGetProfile:
+    @pytest.mark.asyncio
+    async def test_returns_matching_profile_with_content(self, svc):
+        """``get_profile`` lists then filters since Orca has no dedicated
+        per-profile GET — verify the matched entry returns with full
+        content, not stripped to metadata."""
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc._client.get = AsyncMock(
+            return_value=_mock_response(
+                json_data={
+                    "upserts": [
+                        {"id": "a", "name": "A", "content": {"foo": 1}},
+                        {"id": "target", "name": "Target", "content": {"hit": True}},
+                    ],
+                    "deletes": [],
+                },
+            )
+        )
+
+        profile = await svc.get_profile("target")
+
+        assert profile["id"] == "target"
+        assert profile["content"] == {"hit": True}
+
+    @pytest.mark.asyncio
+    async def test_not_found_raises(self, svc):
+        svc.access_token = "ACCESS"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc._client.get = AsyncMock(
+            return_value=_mock_response(
+                json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []},
+            ),
+        )
+        with pytest.raises(OrcaCloudError, match="not found"):
+            await svc.get_profile("missing")

+ 77 - 0
backend/tests/unit/services/test_preset_resolver.py

@@ -208,6 +208,83 @@ async def test_cloud_auth_error_returns_401():
     assert exc.value.status_code == 401
     assert exc.value.status_code == 401
 
 
 
 
+# --- orca_cloud tier -------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_orca_cloud_blocks_user_without_orca_cloud_auth():
+    """Defence-in-depth, same shape as the Bambu Cloud permission check:
+    a user holding LIBRARY_UPLOAD but not ORCA_CLOUD_AUTH can't slice with
+    Orca Cloud presets even if their User row still carries a token."""
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=False)
+    with pytest.raises(HTTPException) as exc:
+        await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
+    assert exc.value.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_orca_cloud_unwraps_content():
+    """Orca's profile shape is ``{id, name, content, updated_time, created_time}``
+    — the inner ``content`` is the actual slicer-format JSON. We forward
+    that, not the wrapper."""
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    svc_mock = MagicMock()
+    svc_mock.get_profile = AsyncMock(
+        return_value={
+            "id": "abc",
+            "name": "X1C Custom",
+            "content": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
+        }
+    )
+    svc_mock.close = AsyncMock()
+    with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
+        out = await preset_resolver._resolve_orca_cloud(
+            db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer"
+        )
+    payload = json.loads(out)
+    assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
+    svc_mock.close.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_orca_cloud_auth_error_returns_401():
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    svc_mock = MagicMock()
+    svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudAuthError("expired"))
+    svc_mock.close = AsyncMock()
+    with (
+        patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        pytest.raises(HTTPException) as exc,
+    ):
+        await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
+    assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_orca_cloud_not_found_returns_400():
+    """``get_profile`` raises generic ``OrcaCloudError`` for "not found" —
+    the resolver maps that to a 400 (not 502) so the UI can show "profile
+    no longer exists" rather than "service down"."""
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    svc_mock = MagicMock()
+    svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudError("profile 'abc' not found (scanned 0)"))
+    svc_mock.close = AsyncMock()
+    with (
+        patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        pytest.raises(HTTPException) as exc,
+    ):
+        await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
+    assert exc.value.status_code == 400
+
+
 # --- top-level dispatcher -------------------------------------------------
 # --- top-level dispatcher -------------------------------------------------
 
 
 
 

+ 122 - 3
backend/tests/unit/services/test_printer_diagnostic.py

@@ -30,8 +30,12 @@ def _port_probe(overrides=None):
     return _probe
     return _probe
 
 
 
 
-def _state(*, connected=True, developer_mode=True):
-    return types.SimpleNamespace(connected=connected, developer_mode=developer_mode)
+def _state(*, connected=True, developer_mode=True, store_to_sdcard=True):
+    return types.SimpleNamespace(
+        connected=connected,
+        developer_mode=developer_mode,
+        store_to_sdcard=store_to_sdcard,
+    )
 
 
 
 
 class _Env:
 class _Env:
@@ -46,6 +50,7 @@ class _Env:
         host_ip="192.168.1.5",
         host_ip="192.168.1.5",
         state=None,
         state=None,
         test_connection_success=True,
         test_connection_success=True,
+        report_messages_since_connect: int | None = 5,
     ):
     ):
         self.ports = ports or _port_probe()
         self.ports = ports or _port_probe()
         self.in_docker = in_docker
         self.in_docker = in_docker
@@ -53,12 +58,21 @@ class _Env:
         self.host_ip = host_ip
         self.host_ip = host_ip
         self.state = state
         self.state = state
         self.test_connection_success = test_connection_success
         self.test_connection_success = test_connection_success
+        # ``None`` means get_client returns None (e.g. pre-add flow); an int
+        # means there's a client with that counter value.
+        self.report_messages_since_connect = report_messages_since_connect
         self._stack = ExitStack()
         self._stack = ExitStack()
 
 
     def __enter__(self):
     def __enter__(self):
         manager = MagicMock()
         manager = MagicMock()
         manager.get_status.return_value = self.state
         manager.get_status.return_value = self.state
         manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
         manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
+        if self.report_messages_since_connect is None:
+            manager.get_client.return_value = None
+        else:
+            client = MagicMock()
+            client.report_messages_since_connect = self.report_messages_since_connect
+            manager.get_client.return_value = client
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
         self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
         self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
@@ -91,7 +105,10 @@ class TestSameSubnet:
 
 
 class TestExistingPrinter:
 class TestExistingPrinter:
     async def test_all_healthy(self):
     async def test_all_healthy(self):
-        with _Env(state=_state(connected=True, developer_mode=True)):
+        with _Env(
+            state=_state(connected=True, developer_mode=True, store_to_sdcard=True),
+            report_messages_since_connect=42,
+        ):
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
         s = _statuses(result)
         s = _statuses(result)
         assert result.overall == "ok"
         assert result.overall == "ok"
@@ -101,8 +118,10 @@ class TestExistingPrinter:
             "port_rtsps": "pass",
             "port_rtsps": "pass",
             "network_mode": "pass",
             "network_mode": "pass",
             "subnet": "pass",
             "subnet": "pass",
+            "external_storage": "pass",
             "mqtt_auth": "pass",
             "mqtt_auth": "pass",
             "developer_mode": "pass",
             "developer_mode": "pass",
+            "printer_publishing": "pass",
         }
         }
 
 
     async def test_mqtt_port_unreachable_is_a_problem(self):
     async def test_mqtt_port_unreachable_is_a_problem(self):
@@ -138,6 +157,8 @@ class TestExistingPrinter:
         assert s["developer_mode"] == "skip"
         assert s["developer_mode"] == "skip"
         # Reachable port but no connection -> credential failure class.
         # Reachable port but no connection -> credential failure class.
         assert s["mqtt_auth"] == "fail"
         assert s["mqtt_auth"] == "fail"
+        # Can't observe report messages without a connection.
+        assert s["printer_publishing"] == "skip"
 
 
     async def test_bridge_mode_warns_and_skips_subnet(self):
     async def test_bridge_mode_warns_and_skips_subnet(self):
         with _Env(network_mode="bridge", state=_state()):
         with _Env(network_mode="bridge", state=_state()):
@@ -157,6 +178,53 @@ class TestExistingPrinter:
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
         assert _statuses(result)["subnet"] == "warn"
         assert _statuses(result)["subnet"] == "warn"
 
 
+    async def test_printer_publishing_passes_when_reports_seen(self):
+        # Counter > 0 means the printer is publishing on the report topic.
+        with _Env(state=_state(), report_messages_since_connect=1):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["printer_publishing"] == "pass"
+
+    async def test_printer_publishing_fails_when_zero_reports_after_wait(self):
+        # Counter stays at 0 across the wait window — printer never published.
+        # Tiny wait_for_publish_seconds keeps the test sub-second.
+        with _Env(state=_state(), report_messages_since_connect=0):
+            result = await run_connection_diagnostic(
+                "192.168.1.50",
+                printer=_printer(),
+                wait_for_publish_seconds=0.05,
+            )
+        s = _statuses(result)
+        assert s["printer_publishing"] == "fail"
+        # Overall escalates because fail is present.
+        assert result.overall == "problems"
+        # The check exposes the wait budget so the UI can render a countdown.
+        params = next(c.params for c in result.checks if c.id == "printer_publishing")
+        assert params == {"max_wait_seconds": 0.05}
+
+    async def test_printer_publishing_skips_when_disconnected(self):
+        # No live MQTT connection -> can't observe report messages.
+        with _Env(state=_state(connected=False), report_messages_since_connect=0):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["printer_publishing"] == "skip"
+
+    async def test_printer_publishing_skips_when_no_client(self):
+        # State says connected but printer_manager has no client object
+        # (race between client teardown and a fresh diagnostic request).
+        with _Env(state=_state(), report_messages_since_connect=None):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["printer_publishing"] == "skip"
+
+    async def test_printer_publishing_no_wait_returns_instantly_on_zero(self):
+        # Default wait is 0 — instant pass/fail without polling. Used by the
+        # support-package code path so bundling stays fast.
+        with _Env(state=_state(), report_messages_since_connect=0):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["printer_publishing"] == "fail"
+        params = next(c.params for c in result.checks if c.id == "printer_publishing")
+        # No wait -> no max_wait_seconds param surfaced to the UI.
+        assert params == {}
+
 
 
 class TestPreAddFlow:
 class TestPreAddFlow:
     async def test_bad_credentials_fail_mqtt_auth(self):
     async def test_bad_credentials_fail_mqtt_auth(self):
@@ -176,3 +244,54 @@ class TestPreAddFlow:
         with _Env():
         with _Env():
             result = await run_connection_diagnostic("192.168.1.50")
             result = await run_connection_diagnostic("192.168.1.50")
         assert _statuses(result)["mqtt_auth"] == "skip"
         assert _statuses(result)["mqtt_auth"] == "skip"
+
+
+class TestExternalStorageCheck:
+    """Install step 4 — "Store sent files on external storage".
+
+    Detected via ``state.store_to_sdcard`` (parsed from MQTT push_status
+    ``home_flag`` bit 11). Only catches the printer-side variant of the
+    setting on newer firmware (P2S 01.02 / Studio 2.6+) — the older
+    slicer-side variant is undetectable from outside the slicer and is
+    covered separately by the no-3MF archive-fallback banner.
+    """
+
+    async def test_passes_when_store_to_sdcard_true(self):
+        with _Env(state=_state(store_to_sdcard=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["external_storage"] == "pass"
+
+    async def test_fails_when_store_to_sdcard_false(self):
+        # Bit 11 reported as 0 -> printer-side toggle is off. Overall
+        # escalates to "problems" because a fail is present.
+        with _Env(state=_state(store_to_sdcard=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["external_storage"] == "fail"
+        assert result.overall == "problems"
+
+    async def test_skips_when_disconnected(self):
+        # State exists (we have a saved printer) but the MQTT connection
+        # dropped, so the latest store_to_sdcard value can't be trusted.
+        with _Env(state=_state(connected=False, store_to_sdcard=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["external_storage"] == "skip"
+
+    async def test_skips_pre_add_flow(self):
+        # No saved printer -> no state -> nothing to read. The check has
+        # to skip; pre-add can't probe this without a live MQTT session.
+        with _Env():
+            result = await run_connection_diagnostic(
+                "192.168.1.50",
+                serial_number="01P",
+                access_code="probe-code",
+            )
+        assert _statuses(result)["external_storage"] == "skip"
+
+    async def test_skips_when_field_missing(self):
+        # State exists and is connected but store_to_sdcard was never
+        # populated (firmware that doesn't push home_flag). Skip rather
+        # than fabricate a False from a missing field.
+        bare = types.SimpleNamespace(connected=True, developer_mode=True)
+        with _Env(state=bare):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["external_storage"] == "skip"

+ 8 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -377,6 +377,7 @@ class TestPrinterManager:
             vibration_cali=True,
             vibration_cali=True,
             layer_inspect=False,
             layer_inspect=False,
             use_ams=True,
             use_ams=True,
+            nozzle_offset_cali=False,
         )
         )
         assert result is True
         assert result is True
 
 
@@ -1362,6 +1363,9 @@ class TestSupportsDrying:
         assert supports_drying("P1S", "01.08.00.00") is True
         assert supports_drying("P1S", "01.08.00.00") is True
         assert supports_drying("H2D", "01.02.30.00") is True
         assert supports_drying("H2D", "01.02.30.00") is True
         assert supports_drying("H2S", "01.02.00.00") is True
         assert supports_drying("H2S", "01.02.00.00") is True
+        assert supports_drying("H2C", "01.02.00.00") is True
+        assert supports_drying("O1C", "01.02.00.00") is True
+        assert supports_drying("O1C2", "01.02.00.00") is True
         assert supports_drying("P2S", "01.02.00.00") is True
         assert supports_drying("P2S", "01.02.00.00") is True
         assert supports_drying("N7", "01.02.00.00") is True
         assert supports_drying("N7", "01.02.00.00") is True
 
 
@@ -1370,6 +1374,9 @@ class TestSupportsDrying:
         assert supports_drying("X1C", "01.08.00.00") is False
         assert supports_drying("X1C", "01.08.00.00") is False
         assert supports_drying("P1S", "01.07.00.00") is False
         assert supports_drying("P1S", "01.07.00.00") is False
         assert supports_drying("H2S", "01.01.00.00") is False
         assert supports_drying("H2S", "01.01.00.00") is False
+        assert supports_drying("H2C", "01.01.99.99") is False
+        assert supports_drying("O1C", "01.01.99.99") is False
+        assert supports_drying("O1C2", "01.01.99.99") is False
         assert supports_drying("P2S", "01.01.99.99") is False
         assert supports_drying("P2S", "01.01.99.99") is False
         assert supports_drying("N7", "01.01.99.99") is False
         assert supports_drying("N7", "01.01.99.99") is False
 
 
@@ -1380,7 +1387,7 @@ class TestSupportsDrying:
 
 
     def test_unsupported_models(self):
     def test_unsupported_models(self):
         """Verify models without AMS drying support return False regardless of firmware."""
         """Verify models without AMS drying support return False regardless of firmware."""
-        for model in ["A1", "A1MINI", "A1-MINI", "H2C", "N1", "N2S"]:
+        for model in ["A1", "A1MINI", "A1-MINI", "N1", "N2S"]:
             assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
             assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
 
 
     def test_unknown_models_allowed(self):
     def test_unknown_models_allowed(self):

+ 9 - 2
backend/tests/unit/services/test_spoolman_tracking.py

@@ -1,5 +1,6 @@
 """Unit tests for Spoolman tracking service helpers."""
 """Unit tests for Spoolman tracking service helpers."""
 
 
+import json
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
@@ -213,8 +214,14 @@ class TestStorePrintData:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
     async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
         db = AsyncMock()
         db = AsyncMock()
+        # store_print_data now queries the queue item unconditionally (to pick up
+        # plate_id for multi-plate 3MFs, #1697), then deletes any stale spoolman
+        # row before inserting the new one. Two execute calls in that order.
+        queue_item = SimpleNamespace(ams_mapping=json.dumps([2, -1, -1, -1]), plate_id=None)
+        queue_result = MagicMock()
+        queue_result.scalar_one_or_none.return_value = queue_item
         delete_result = MagicMock()
         delete_result = MagicMock()
-        db.execute = AsyncMock(side_effect=[delete_result])
+        db.execute = AsyncMock(side_effect=[queue_result, delete_result])
         db.add = MagicMock()
         db.add = MagicMock()
         db.commit = AsyncMock()
         db.commit = AsyncMock()
 
 
@@ -250,7 +257,7 @@ class TestStorePrintData:
         db.add.assert_called_once()
         db.add.assert_called_once()
         tracking = db.add.call_args.args[0]
         tracking = db.add.call_args.args[0]
         assert tracking.slot_to_tray == [1, -1, -1, -1]
         assert tracking.slot_to_tray == [1, -1, -1, -1]
-        db.execute.assert_called_once()
+        assert db.execute.await_count == 2
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_stores_tracking_when_disable_weight_sync_is_false(self):
     async def test_stores_tracking_when_disable_weight_sync_is_false(self):

+ 98 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -206,6 +206,104 @@ class TestVirtualPrinterInstance:
 
 
         instance._mqtt.set_gcode_state.assert_not_called()
         instance._mqtt.set_gcode_state.assert_not_called()
 
 
+    @pytest.mark.asyncio
+    async def test_on_print_command_schedules_finish_release_non_proxy(self, instance):
+        """#1658: Bambu Studio 2.7.x flipped the slicer's Send flow to
+        FTP → FTP → MQTT project_file. Under that order the synthetic
+        project_file ack overwrites the FINISH set by #1280 in
+        ``on_file_received`` back to PREPARE, leaving the slicer's
+        "Downloading" modal stuck forever. Re-firing FINISH a moment after
+        the ack releases the modal.
+        """
+        instance.mode = "archive"
+        instance._mqtt = MagicMock()
+        instance._mqtt.set_gcode_state = MagicMock()
+
+        with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
+            await instance.on_print_command("test.3mf", {"command": "project_file"})
+
+        mock_delayed.assert_called_once()
+        # First positional arg should be the filename; second is the delay seconds.
+        args = mock_delayed.call_args.args
+        assert args[0] == "test.3mf"
+        assert isinstance(args[1], int | float)
+
+    @pytest.mark.asyncio
+    async def test_on_print_command_proxy_mode_does_not_reschedule_finish(self, instance):
+        """Proxy mode hands push_status straight from the real printer through
+        the bridge. Re-firing a synthetic FINISH would clobber a real
+        PREPARE / RUNNING transition coming back from the printer, so the
+        scheduler is exempt for proxy mode."""
+        instance.mode = "proxy"
+        instance._mqtt = MagicMock()
+
+        with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
+            await instance.on_print_command("test.3mf", {"command": "project_file"})
+
+        mock_delayed.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_command_no_mqtt_does_not_schedule(self, instance):
+        """If the MQTT server isn't running yet (transient race during boot),
+        the scheduler must skip silently — no AttributeError, no orphan task."""
+        instance.mode = "queue"
+        instance._mqtt = None
+
+        # Should not raise.
+        await instance.on_print_command("test.3mf", {"command": "project_file"})
+        assert instance._finish_release_task is None
+
+    @pytest.mark.asyncio
+    async def test_schedule_finish_release_cancels_previous_timer(self, instance):
+        """A slicer that fires project_file twice (e.g. retry after a transient
+        FTP hiccup) must only result in one FINISH transition — the earlier
+        in-flight timer is cancelled when the next one is scheduled."""
+        instance.mode = "queue"
+        instance._mqtt = MagicMock()
+
+        instance._schedule_finish_release("first.3mf", delay=10.0)
+        first_task = instance._finish_release_task
+        assert first_task is not None
+
+        instance._schedule_finish_release("second.3mf", delay=10.0)
+        second_task = instance._finish_release_task
+
+        assert second_task is not first_task
+        # Give the loop one tick so the cancelled task settles.
+        await asyncio.sleep(0)
+        assert first_task.cancelled() or first_task.done()
+        # Clean up the still-pending second task so the test doesn't leak it.
+        second_task.cancel()
+        try:
+            await second_task
+        except asyncio.CancelledError:
+            pass
+
+    @pytest.mark.asyncio
+    async def test_delayed_finish_release_sets_finish_state(self, instance):
+        """End-to-end: after the delay elapses, set_gcode_state is called with
+        FINISH and prepare_percent=100, matching the wire-format the slicer's
+        Print flow consumes to release "Downloading"."""
+        instance._mqtt = MagicMock()
+        instance._mqtt.set_gcode_state = MagicMock()
+
+        await instance._delayed_finish_release("queued.3mf", delay=0.0)
+
+        instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="queued.3mf", prepare_percent="100")
+
+    @pytest.mark.asyncio
+    async def test_on_print_command_no_filename_does_not_schedule(self, instance):
+        """A project_file command without a subtask_name (defensive — real
+        slicers always send one) must not schedule a no-op FINISH that would
+        carry an empty filename on the next 1 Hz push."""
+        instance.mode = "queue"
+        instance._mqtt = MagicMock()
+
+        with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
+            await instance.on_print_command("", {"command": "project_file"})
+
+        mock_delayed.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_archive_file_skips_non_3mf(self, instance):
     async def test_archive_file_skips_non_3mf(self, instance):
         """Verify non-3MF files are skipped and cleaned up."""
         """Verify non-3MF files are skipped and cleaned up."""

+ 133 - 0
backend/tests/unit/services/test_vp_ftp_port_slicing.py

@@ -0,0 +1,133 @@
+"""Tests for the per-VP FTP passive-port slice helper (#1646).
+
+Each VP is allocated a non-overlapping 10-port slice from the
+PASSIVE_PORT_BASE pool. Bridge-mode Docker users only have to expose
+`PASSIVE_SLICE_SIZE * N_vps` ports instead of the historical 1001-port
+pool that spawned ~2000 docker-proxy host processes (~3.5 GB RAM).
+
+Slicing properties pinned here:
+  - Slice 0 covers PASSIVE_PORT_BASE..+SLICE_SIZE-1 (the only slice that
+    aligns with the compose file's narrowest default exposure).
+  - Each subsequent vp_id advances by exactly SLICE_SIZE — no overlap, no
+    gap.
+  - vp_ids beyond MAX_SLOTS wrap around (modulo) so installs that have
+    churned through many VPs over time still produce a valid in-range
+    slice; same-slot collisions fall back to the per-session 10-attempt
+    random retry, which is the same behaviour as pre-#1646.
+  - Defensive: a non-positive vp_id (shouldn't occur, but DBs are
+    surprising) clamps to slot 0 rather than producing a negative port.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.services.virtual_printer.ftp_server import (
+    PASSIVE_MAX_SLOTS,
+    PASSIVE_PORT_BASE,
+    PASSIVE_SLICE_SIZE,
+    compute_passive_port_slice,
+)
+
+
+class TestComputePassivePortSlice:
+    def test_vp_id_one_starts_at_base(self):
+        port_min, port_max = compute_passive_port_slice(1)
+        assert port_min == PASSIVE_PORT_BASE
+        assert port_max == PASSIVE_PORT_BASE + PASSIVE_SLICE_SIZE - 1
+
+    def test_consecutive_vp_ids_get_adjacent_non_overlapping_slices(self):
+        slice1_min, slice1_max = compute_passive_port_slice(1)
+        slice2_min, slice2_max = compute_passive_port_slice(2)
+        slice3_min, slice3_max = compute_passive_port_slice(3)
+        assert slice1_max + 1 == slice2_min  # no gap
+        assert slice2_max + 1 == slice3_min  # no gap
+        # Slice width matches the constant — no off-by-one.
+        assert slice1_max - slice1_min + 1 == PASSIVE_SLICE_SIZE
+        assert slice2_max - slice2_min + 1 == PASSIVE_SLICE_SIZE
+        assert slice3_max - slice3_min + 1 == PASSIVE_SLICE_SIZE
+
+    def test_no_two_distinct_vp_ids_within_max_slots_share_a_port(self):
+        seen: dict[int, int] = {}
+        for vp_id in range(1, PASSIVE_MAX_SLOTS + 1):
+            lo, hi = compute_passive_port_slice(vp_id)
+            for port in range(lo, hi + 1):
+                assert port not in seen, f"VP {vp_id} clashes with VP {seen[port]} on port {port}"
+                seen[port] = vp_id
+
+    def test_wraps_modulo_max_slots(self):
+        """VP id past MAX_SLOTS lands on the same slice as its modulo-N
+        neighbour — the per-session retry handles the rare collision."""
+        first = compute_passive_port_slice(1)
+        wrapped = compute_passive_port_slice(PASSIVE_MAX_SLOTS + 1)
+        assert wrapped == first
+
+    def test_top_slot_is_within_base_pool(self):
+        """The last valid slot must stay below PASSIVE_PORT_BASE +
+        MAX_SLOTS*SLICE_SIZE so the slice never escapes the pool that
+        the docker-compose comments document for users."""
+        _, hi = compute_passive_port_slice(PASSIVE_MAX_SLOTS)
+        assert hi < PASSIVE_PORT_BASE + PASSIVE_MAX_SLOTS * PASSIVE_SLICE_SIZE
+
+    @pytest.mark.parametrize("bad_id", [0, -1, -999])
+    def test_non_positive_ids_clamp_to_slot_zero(self, bad_id):
+        """Defensive: a bad vp_id from a corrupted row mustn't produce a
+        negative port and crash asyncio.start_server."""
+        assert compute_passive_port_slice(bad_id) == compute_passive_port_slice(1)
+
+
+class TestFTPServerHonoursPerInstanceRange:
+    """`VirtualPrinterFTPServer` now stores the slice on `self`. Two
+    instances constructed with different slices must hand each
+    `FTPSession` the right per-instance range — no class-level leak."""
+
+    def test_two_instances_independent_ranges(self, tmp_path):
+        from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
+
+        cert = tmp_path / "cert.pem"
+        cert.write_text("not a real cert")
+        key = tmp_path / "key.pem"
+        key.write_text("not a real key")
+
+        a = VirtualPrinterFTPServer(
+            upload_dir=tmp_path,
+            access_code="x",
+            cert_path=cert,
+            key_path=key,
+            passive_port_min=50000,
+            passive_port_max=50009,
+        )
+        b = VirtualPrinterFTPServer(
+            upload_dir=tmp_path,
+            access_code="x",
+            cert_path=cert,
+            key_path=key,
+            passive_port_min=50050,
+            passive_port_max=50059,
+        )
+
+        assert (a.passive_port_min, a.passive_port_max) == (50000, 50009)
+        assert (b.passive_port_min, b.passive_port_max) == (50050, 50059)
+        # Mutating one must not affect the other (regression guard against
+        # the pre-fix class-constant layout).
+        b.passive_port_min = 60000
+        assert a.passive_port_min == 50000
+
+    def test_default_construction_gives_a_one_slice_window(self, tmp_path):
+        """A consumer that doesn't pass passive_port_min/max should still
+        get a valid, minimal range — handy for tests and direct callers."""
+        from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
+
+        cert = tmp_path / "cert.pem"
+        cert.write_text("x")
+        key = tmp_path / "key.pem"
+        key.write_text("x")
+
+        server = VirtualPrinterFTPServer(
+            upload_dir=tmp_path,
+            access_code="x",
+            cert_path=cert,
+            key_path=key,
+        )
+        assert server.passive_port_min == PASSIVE_PORT_BASE
+        assert server.passive_port_max - server.passive_port_min + 1 == PASSIVE_SLICE_SIZE

+ 82 - 0
backend/tests/unit/test_ams_alarm_gating.py

@@ -0,0 +1,82 @@
+"""Tests for the empty-AMS alarm gate (#1619).
+
+Empty AMS units still emit humidity/temperature sensor readings, but those
+readings are ambient and not actionable — there's no filament to dry. Without
+the gate every empty AMS spammed an hourly alarm. ``_ams_has_filament``
+inspects the firmware-reported ``tray_exist_bits`` bitmap (fallback: ``tray``
+array's ``tray_type`` strings) so the alarm dispatch in ``record_ams_history``
+can skip empty units while still alarming on loaded ones in the same printer.
+"""
+
+from backend.app.main import _ams_has_filament
+
+
+class TestAmsHasFilament:
+    def test_tray_exist_bits_zero_means_empty(self):
+        assert _ams_has_filament({"tray_exist_bits": "0"}) is False
+        # Real firmware sometimes pads with extra zeros or prefixes; all
+        # parseable forms of zero should resolve to "empty".
+        assert _ams_has_filament({"tray_exist_bits": "00"}) is False
+        assert _ams_has_filament({"tray_exist_bits": "0x0"}) is False
+
+    def test_tray_exist_bits_nonzero_means_loaded(self):
+        # Single tray loaded — e.g. AMS-Lite or AMS-HT.
+        assert _ams_has_filament({"tray_exist_bits": "1"}) is True
+        # Four-slot AMS with all slots full (bitmap 0xf == 0b1111).
+        assert _ams_has_filament({"tray_exist_bits": "f"}) is True
+        # Mixed — 0xa == 0b1010, two slots loaded.
+        assert _ams_has_filament({"tray_exist_bits": "a"}) is True
+        # The exact bitmap seen in #1622 / #1602 logs.
+        assert _ams_has_filament({"tray_exist_bits": "ed"}) is True
+
+    def test_falls_back_to_tray_array_when_bits_missing(self):
+        # Empty tray_type strings across the whole tray array → empty AMS.
+        ams_empty = {
+            "tray": [
+                {"id": 0, "tray_type": ""},
+                {"id": 1, "tray_type": ""},
+            ]
+        }
+        assert _ams_has_filament(ams_empty) is False
+        # Any non-empty tray_type → loaded AMS.
+        ams_loaded = {
+            "tray": [
+                {"id": 0, "tray_type": ""},
+                {"id": 1, "tray_type": "PLA"},
+            ]
+        }
+        assert _ams_has_filament(ams_loaded) is True
+
+    def test_missing_both_signals_returns_false(self):
+        # No tray_exist_bits AND no tray array — early-pushall shape; we
+        # treat it as "no info → don't alarm" rather than guessing loaded.
+        assert _ams_has_filament({}) is False
+
+    def test_unparseable_bitmap_falls_back_to_tray_array(self):
+        # Garbage in tray_exist_bits — must not raise and must fall through
+        # to the tray array check.
+        loaded = {"tray_exist_bits": "garbage", "tray": [{"id": 0, "tray_type": "PETG"}]}
+        assert _ams_has_filament(loaded) is True
+        empty = {"tray_exist_bits": "garbage", "tray": []}
+        assert _ams_has_filament(empty) is False
+
+    def test_empty_bits_string_falls_back_to_tray_array(self):
+        # Some pre-handshake pushall shapes set the field but leave it blank.
+        loaded = {"tray_exist_bits": "", "tray": [{"id": 0, "tray_type": "ABS"}]}
+        assert _ams_has_filament(loaded) is True
+
+    def test_whitespace_tray_type_is_not_loaded(self):
+        # A tray_type that's all whitespace doesn't count as a real material.
+        assert _ams_has_filament({"tray": [{"id": 0, "tray_type": "   "}]}) is False
+
+    def test_non_dict_tray_entries_are_skipped(self):
+        # Defensive: malformed tray array shouldn't crash the helper.
+        assert _ams_has_filament({"tray": [None, "junk", 42]}) is False
+
+    def test_non_string_bits_falls_back(self):
+        # Some MQTT shapes send tray_exist_bits as int; we only parse strings,
+        # so an int falls through to the tray array.
+        loaded = {"tray_exist_bits": 0xED, "tray": [{"id": 0, "tray_type": "PLA"}]}
+        assert _ams_has_filament(loaded) is True
+        empty_int = {"tray_exist_bits": 0xED}  # no tray array, int ignored
+        assert _ams_has_filament(empty_int) is False

+ 282 - 0
backend/tests/unit/test_cancellation_cascade_recovery_migration.py

@@ -0,0 +1,282 @@
+"""Regression test for the cancellation-cascade recovery migration (#1667).
+
+Pre-fix: the scheduler's `_check_previous_success` lookback included
+`skipped` and excluded `cancelled`, so a single user-cancelled print
+poisoned every downstream item with `require_previous_success=True`
+indefinitely (reporter saw 18 items blocked over 3 days from one
+cancellation).
+
+This migration reverses the bug surgically: ONLY skipped items whose
+immediate real predecessor (by `completed_at` desc, excluding skipped
+items themselves) was `cancelled` get reset to `pending`. Items whose
+true predecessor was `failed` or `aborted` stay skipped — those were
+legitimate failure-gated skips.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    """run_migrations touches multiple tables; the full schema must exist."""
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+BASE_TIME = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone.utc)
+
+
+async def _insert_queue_item(
+    engine, *, id: int, printer_id: int, status: str, minutes_offset: int, error_message: str | None = None
+) -> None:
+    """Insert a print_queue row via the ORM so Python-side defaults
+    (manual_start, position, bed_levelling, …) all apply without us having
+    to mirror every NOT NULL column."""
+    from backend.app.models.print_queue import PrintQueueItem
+
+    async with AsyncSession(engine) as session:
+        session.add(
+            PrintQueueItem(
+                id=id,
+                printer_id=printer_id,
+                status=status,
+                error_message=error_message,
+                completed_at=BASE_TIME + timedelta(minutes=minutes_offset),
+                require_previous_success=True,
+                position=id,
+            )
+        )
+        await session.commit()
+
+
+async def _get_status(engine, item_id: int) -> tuple[str, str | None]:
+    async with engine.connect() as conn:
+        row = (
+            await conn.execute(text("SELECT status, error_message FROM print_queue WHERE id = :id"), {"id": item_id})
+        ).first()
+    return row.status, row.error_message
+
+
+@pytest.mark.asyncio
+async def test_skipped_after_cancelled_resets_to_pending(engine):
+    """Bug A + B: cancelled → skipped → migration resets the skipped item."""
+    await _insert_queue_item(engine, id=10, printer_id=1, status="cancelled", minutes_offset=1)
+    await _insert_queue_item(
+        engine,
+        id=11,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=2,
+        error_message="Previous print failed or was aborted",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    status, error_message = await _get_status(engine, 11)
+    assert status == "pending"
+    assert error_message is None
+
+
+@pytest.mark.asyncio
+async def test_skipped_after_failed_stays_skipped(engine):
+    """Genuine failure-gated skip must NOT be reset — the user really did
+    have a failure they need to deal with before downstream items run."""
+    await _insert_queue_item(engine, id=20, printer_id=1, status="failed", minutes_offset=1)
+    await _insert_queue_item(
+        engine,
+        id=21,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=2,
+        error_message="Previous print failed or was aborted",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    status, _ = await _get_status(engine, 21)
+    assert status == "skipped"
+
+
+@pytest.mark.asyncio
+async def test_skipped_after_aborted_stays_skipped(engine):
+    """Printer-detected abort is a real failure too — gate stays in place."""
+    await _insert_queue_item(engine, id=30, printer_id=1, status="aborted", minutes_offset=1)
+    await _insert_queue_item(
+        engine,
+        id=31,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=2,
+        error_message="Previous print failed or was aborted",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    status, _ = await _get_status(engine, 31)
+    assert status == "skipped"
+
+
+@pytest.mark.asyncio
+async def test_skipped_with_other_error_message_untouched(engine):
+    """Migration narrows on the exact buggy error string. A skipped item
+    written by some other code path (different error_message) is left alone."""
+    await _insert_queue_item(engine, id=40, printer_id=1, status="cancelled", minutes_offset=1)
+    await _insert_queue_item(
+        engine,
+        id=41,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=2,
+        error_message="Some other reason",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    status, error_message = await _get_status(engine, 41)
+    assert status == "skipped"
+    assert error_message == "Some other reason"
+
+
+@pytest.mark.asyncio
+async def test_reporter_exact_cascade_resets_all_three(engine):
+    """The reporter's exact pattern: failed → cancelled → skipped → skipped.
+    Predecessors (by completed_at desc, skipped excluded) are cancelled for
+    both stuck items, so both reset."""
+    await _insert_queue_item(engine, id=50, printer_id=1, status="failed", minutes_offset=1)
+    await _insert_queue_item(engine, id=51, printer_id=1, status="cancelled", minutes_offset=2)
+    await _insert_queue_item(
+        engine,
+        id=52,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=3,
+        error_message="Previous print failed or was aborted",
+    )
+    await _insert_queue_item(
+        engine,
+        id=53,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=4,
+        error_message="Previous print failed or was aborted",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    assert (await _get_status(engine, 52))[0] == "pending"
+    assert (await _get_status(engine, 53))[0] == "pending"
+    # The original failed/cancelled items are untouched
+    assert (await _get_status(engine, 50))[0] == "failed"
+    assert (await _get_status(engine, 51))[0] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_migration_is_idempotent(engine):
+    """Running the migration twice doesn't re-touch already-reset rows."""
+    await _insert_queue_item(engine, id=60, printer_id=1, status="cancelled", minutes_offset=1)
+    await _insert_queue_item(
+        engine,
+        id=61,
+        printer_id=1,
+        status="skipped",
+        minutes_offset=2,
+        error_message="Previous print failed or was aborted",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    async with engine.begin() as conn:
+        await run_migrations(conn)  # second pass should be a no-op
+
+    status, _ = await _get_status(engine, 61)
+    assert status == "pending"
+
+
+@pytest.mark.asyncio
+async def test_per_printer_isolation(engine):
+    """A cancelled item on printer A must not affect a skipped item on
+    printer B (different printer queues are independent)."""
+    await _insert_queue_item(engine, id=70, printer_id=1, status="cancelled", minutes_offset=1)
+    await _insert_queue_item(engine, id=71, printer_id=2, status="failed", minutes_offset=1)
+    await _insert_queue_item(
+        engine,
+        id=72,
+        printer_id=2,
+        status="skipped",
+        minutes_offset=2,
+        error_message="Previous print failed or was aborted",
+    )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    # printer 2's skipped item had a failed predecessor → stays skipped
+    status, _ = await _get_status(engine, 72)
+    assert status == "skipped"

+ 172 - 0
backend/tests/unit/test_check_previous_success.py

@@ -0,0 +1,172 @@
+"""Tests for `PrintScheduler._check_previous_success` (#1667).
+
+Pre-fix behaviour: the lookback `.in_([...])` list excluded `cancelled` and
+included `skipped`, so a single user-cancelled print blocked every downstream
+item with `require_previous_success=True` permanently (the reporter saw 18
+items blocked over 3 days from one cancellation, because each new skip
+became the next skip's "failed predecessor").
+
+Post-fix behaviour:
+- `cancelled` is a neutral outcome → returns True (a deliberate user action
+  is not a print failure)
+- `skipped` is excluded from the lookback → an already-skipped item never
+  counts as a predecessor; the query walks back to the most recent real
+  print attempt
+- `failed` and `aborted` still gate as before
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+@pytest.fixture
+def queue_factory(db_session, printer_factory):
+    """Helper to drop completed/failed/cancelled/skipped queue items in order.
+
+    Each call assigns a monotonically increasing `completed_at` so the
+    scheduler's `ORDER BY completed_at DESC` reliably picks the latest as
+    the predecessor. `printer_id` is shared so all items count.
+    """
+    base_time = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone.utc)
+    counter = {"n": 0}
+    printer_holder: dict = {}
+
+    async def _make_printer():
+        if "p" not in printer_holder:
+            printer_holder["p"] = await printer_factory()
+        return printer_holder["p"]
+
+    async def _add(status: str, error_message: str | None = None) -> PrintQueueItem:
+        printer = await _make_printer()
+        counter["n"] += 1
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            status=status,
+            error_message=error_message,
+            completed_at=base_time + timedelta(minutes=counter["n"]),
+            require_previous_success=True,
+        )
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    async def _add_pending() -> PrintQueueItem:
+        printer = await _make_printer()
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            status="pending",
+            require_previous_success=True,
+        )
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    return {"add": _add, "add_pending": _add_pending}
+
+
+@pytest.mark.asyncio
+async def test_no_previous_item_returns_true(scheduler, db_session, queue_factory):
+    """First item in the queue has no predecessor → always passes."""
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_previous_completed_returns_true(scheduler, db_session, queue_factory):
+    await queue_factory["add"]("completed")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_previous_failed_returns_false(scheduler, db_session, queue_factory):
+    await queue_factory["add"]("failed")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is False
+
+
+@pytest.mark.asyncio
+async def test_previous_aborted_returns_false(scheduler, db_session, queue_factory):
+    """A printer-detected abort (e.g. clogged nozzle) is a real failure → blocks."""
+    await queue_factory["add"]("aborted")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is False
+
+
+@pytest.mark.asyncio
+async def test_previous_cancelled_returns_true_bug_a(scheduler, db_session, queue_factory):
+    """#1667 bug A: user cancellation is deliberate, not a failure → passes."""
+    await queue_factory["add"]("cancelled")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_skipped_predecessor_is_walked_past_bug_b(scheduler, db_session, queue_factory):
+    """#1667 bug B: a skipped item is not an attempt — query walks back to the
+    most recent real outcome instead of treating skipped as failed."""
+    await queue_factory["add"]("completed")  # real predecessor that should be found
+    await queue_factory["add"]("skipped", "Previous print failed or was aborted")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_only_skipped_history_returns_true(scheduler, db_session, queue_factory):
+    """Edge case: every prior item is skipped → no real predecessor found,
+    returns True (first-in-queue semantics)."""
+    await queue_factory["add"]("skipped", "Previous print failed or was aborted")
+    await queue_factory["add"]("skipped", "Previous print failed or was aborted")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_cascade_reporters_scenario(scheduler, db_session, queue_factory):
+    """The exact #1667 reporter scenario: failed → cancelled → skipped → pending.
+
+    Pre-fix: pending blocked because the buggy lookback walked past the
+    cancelled item (excluded) and the prior skipped item (included), found
+    the failed item, and returned False.
+    Post-fix: cancelled is the predecessor (skipped is excluded; cancelled
+    is included and passes), pending dispatches.
+    """
+    await queue_factory["add"]("failed")
+    await queue_factory["add"]("cancelled")
+    await queue_factory["add"]("skipped", "Previous print failed or was aborted")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_failed_then_cancelled_still_passes(scheduler, db_session, queue_factory):
+    """User cancelled after a failure → most recent action wins. The cancellation
+    is the user explicitly choosing to move on, so dispatching the next item
+    respects their intent."""
+    await queue_factory["add"]("failed")
+    await queue_factory["add"]("cancelled")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory):
+    """Regression guard: a real failure after a previously-successful print
+    still gates downstream items. Only the MOST RECENT outcome matters."""
+    await queue_factory["add"]("completed")
+    await queue_factory["add"]("failed")
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is False

+ 205 - 0
backend/tests/unit/test_cleanup_forced_timelapse.py

@@ -0,0 +1,205 @@
+"""Tests for _cleanup_forced_timelapse (#1397).
+
+When Bambuddy forced timelapse on for the finish-photo path, this helper
+runs after the extractor (success OR failure — we never leave debris).
+It deletes:
+  - the locally-attached file (clears archive.timelapse_path)
+  - the printer-side file via FTP DELE, walking the four scanner dirs
+
+These tests pin the four branches:
+
+  1. archive doesn't exist → no-op
+  2. archive exists but bambuddy_forced_timelapse=False → no-op (user wanted
+     the timelapse)
+  3. archive exists, forced=True, local file present → delete local + DB
+     update + FTP DELE on the first directory that succeeds
+  4. archive exists, forced=True, but FTP DELE fails on every dir → local
+     side still cleaned up; warn log emitted (best-effort)
+"""
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.main import _cleanup_forced_timelapse
+
+
+def _fake_session_factory(rows: dict):
+    """Return an async_session() replacement that yields the given rows.
+
+    `rows` is a mapping of model -> object that the test wants returned
+    from `db.execute(select(...)).scalar_one_or_none()`. The select
+    target is detected by walking the column descriptions — for these
+    tests we just look at the model class name.
+    """
+    from contextlib import asynccontextmanager
+
+    @asynccontextmanager
+    async def fake_session():
+        async def execute(stmt):
+            # The select(...) statement carries the target entity in
+            # `stmt.column_descriptions[0]["entity"]`. Match by class name.
+            target_name = stmt.column_descriptions[0]["entity"].__name__
+            row = rows.get(target_name)
+            return SimpleNamespace(scalar_one_or_none=lambda: row)
+
+        commits: list[None] = []
+
+        async def commit():
+            commits.append(None)
+
+        yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
+
+    return fake_session
+
+
+@pytest.fixture(autouse=True)
+def patch_app_settings(monkeypatch, tmp_path):
+    """Point base_dir at a tmp_path so the helper can resolve relative
+    timelapse paths against a real fs we control."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+@pytest.mark.asyncio
+async def test_no_archive_is_noop(monkeypatch):
+    """Archive deleted between print start and cleanup? Don't crash."""
+    monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
+    delete_mock = AsyncMock()
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+    delete_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_not_forced_is_noop(monkeypatch, tmp_path):
+    """User wanted a timelapse → don't delete anything."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=False,
+        timelapse_path="archive/1/timelapse.mp4",
+    )
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": None}),
+    )
+
+    # Lay down a real file so we'd detect a stray delete.
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    delete_mock = AsyncMock(return_value=True)
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    delete_mock.assert_not_awaited()
+    assert video_path.exists()
+    # archive.timelapse_path is untouched — we still have the user's video
+    # tracked correctly.
+    assert archive.timelapse_path == "archive/1/timelapse.mp4"
+
+
+@pytest.mark.asyncio
+async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
+    """Happy path: forced=True → local file unlinked, DB row cleared, FTP
+    DELE called against /timelapse/<filename> (the first dir to succeed)."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=True,
+        timelapse_path="archive/1/myprint.mp4",
+    )
+    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
+    )
+
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    # FTP DELE succeeds on the first directory we try.
+    delete_mock = AsyncMock(return_value=True)
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    # Local side: file gone, DB cleared.
+    assert not video_path.exists()
+    assert archive.timelapse_path is None
+    # Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
+    # first dir the cleanup tries.
+    delete_mock.assert_awaited()
+    call = delete_mock.await_args
+    assert call.args[0] == "10.0.0.5"
+    assert call.args[1] == "12345678"
+    assert call.args[2] == "/timelapse/myprint.mp4"
+
+
+@pytest.mark.asyncio
+async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
+    """If /timelapse/ DELE returns False (file not there), try the other
+    scanner dirs in order."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=True,
+        timelapse_path="archive/1/myprint.mp4",
+    )
+    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
+    )
+
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    # First two attempts fail (False), third succeeds (True). Cleanup
+    # should stop after the third.
+    delete_mock = AsyncMock(side_effect=[False, False, True])
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    assert delete_mock.await_count == 3
+    paths_tried = [call.args[2] for call in delete_mock.await_args_list]
+    assert paths_tried == [
+        "/timelapse/myprint.mp4",
+        "/timelapse/video/myprint.mp4",
+        "/record/myprint.mp4",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
+    """FTP completely failing must not block local cleanup — the user's
+    archive UI should reflect that the timelapse is gone immediately,
+    even if the printer-side file lingers."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=True,
+        timelapse_path="archive/1/myprint.mp4",
+    )
+    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
+    )
+
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    # Every FTP attempt throws.
+    delete_mock = AsyncMock(side_effect=OSError("connection refused"))
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    # Local side cleaned up even though all FTP attempts threw.
+    assert not video_path.exists()
+    assert archive.timelapse_path is None
+    # All four dirs were attempted before giving up.
+    assert delete_mock.await_count == 4

+ 171 - 0
backend/tests/unit/test_extract_video_last_frame.py

@@ -0,0 +1,171 @@
+"""Tests for extract_video_last_frame (#1397).
+
+Sources the finish photo from the per-print Bambu timelapse's last frame —
+captured by firmware after the toolhead parks but before the bed-drop
+end-gcode runs, so the print is framed correctly. A live camera grab at
+gcode_state=FINISH would capture the bed already lowered.
+
+We can't ship a real Bambu timelapse fixture in the repo (~7-11 MB each),
+so the happy-path test builds a tiny synthetic MP4 with ffmpeg at runtime.
+Failure paths (missing ffmpeg, missing source, subprocess failure, timeout)
+are exercised with monkeypatching so the suite stays hermetic and fast.
+"""
+
+import asyncio
+import shutil
+import subprocess
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services.camera import extract_video_last_frame
+
+_HAS_FFMPEG = shutil.which("ffmpeg") is not None
+
+
+def _make_synthetic_mp4(dest: Path, duration_seconds: float = 1.0) -> None:
+    """Create a tiny test MP4 via ffmpeg's testsrc generator.
+
+    Smallest valid MP4 we can construct without committing binary fixtures —
+    one second of 32x32 testsrc, ultrafast encode, ~3-5 KB.
+    """
+    cmd = [
+        "ffmpeg",
+        "-y",
+        "-hide_banner",
+        "-loglevel",
+        "error",
+        "-f",
+        "lavfi",
+        "-i",
+        f"testsrc=duration={duration_seconds}:size=32x32:rate=10",
+        "-preset",
+        "ultrafast",
+        "-pix_fmt",
+        "yuv420p",
+        str(dest),
+    ]
+    result = subprocess.run(cmd, capture_output=True, check=False)
+    if result.returncode != 0:
+        pytest.fail(f"ffmpeg fixture build failed (exit {result.returncode}): {result.stderr.decode()[:300]}")
+
+
+@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
+async def test_extracts_jpeg_from_real_mp4(tmp_path: Path):
+    src = tmp_path / "synthetic.mp4"
+    _make_synthetic_mp4(src)
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is True
+    assert out.exists()
+    assert out.stat().st_size > 0
+    # JPEG starts with the SOI marker (FFD8). Lightweight sanity check —
+    # we'd otherwise depend on Pillow just to decode.
+    assert out.read_bytes()[:2] == b"\xff\xd8"
+
+
+@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
+async def test_extracts_correctly_from_sub_second_video(tmp_path: Path):
+    """Regression for #1397 round 1: small prints (few layers) produce
+    sub-second Bambu timelapses (~0.6s / 16 frames). The earlier
+    ``-sseof -1.0`` approach seeked 1 second before end → before the
+    start of the file → ffmpeg silently returned frame 0. Verify the
+    write-every-frame-overwrite approach grabs a real frame regardless
+    of duration."""
+    src = tmp_path / "short.mp4"
+    _make_synthetic_mp4(src, duration_seconds=0.5)  # 5 frames at 10fps
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is True
+    assert out.exists()
+    assert out.stat().st_size > 0
+    assert out.read_bytes()[:2] == b"\xff\xd8"
+
+
+async def test_returns_false_when_source_missing(tmp_path: Path):
+    src = tmp_path / "does_not_exist.mp4"
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    assert not out.exists()
+
+
+async def test_returns_false_when_source_empty(tmp_path: Path):
+    src = tmp_path / "empty.mp4"
+    src.touch()
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    assert not out.exists()
+
+
+async def test_returns_false_when_ffmpeg_unavailable(tmp_path: Path):
+    src = tmp_path / "any.mp4"
+    src.write_bytes(b"\x00" * 100)
+    out = tmp_path / "out.jpg"
+
+    # Force the lookup path to return None — same shape as a host without
+    # ffmpeg installed. We don't want to be skipped on CI here; the
+    # not-installed path is a real production fallback and must be tested.
+    with patch("backend.app.services.camera.get_ffmpeg_path", return_value=None):
+        ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    assert not out.exists()
+
+
+async def test_returns_false_when_ffmpeg_exits_nonzero(tmp_path: Path):
+    """ffmpeg failures (corrupt file, codec issue, etc.) return False, not
+    raise. The caller falls through to the existing live-camera path."""
+    src = tmp_path / "garbage.mp4"
+    src.write_bytes(b"not actually an mp4" * 100)
+    out = tmp_path / "out.jpg"
+
+    # Use a real ffmpeg invocation on garbage — guaranteed to fail with a
+    # non-zero exit code without us monkey-patching subprocess.
+    if not _HAS_FFMPEG:
+        pytest.skip("ffmpeg not on PATH; cannot exercise real failure path")
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    # ffmpeg may briefly touch the output file before failing; we don't
+    # require the file to be absent, only that the function reported failure
+    # so the caller falls back.
+
+
+async def test_returns_false_on_subprocess_timeout(tmp_path: Path, monkeypatch):
+    """A hung ffmpeg (network FS, bad codec, kernel bug) must not block the
+    finish-photo task forever. Patch ffmpeg to a sleep command that never
+    finishes — confirms the timeout path kills the subprocess."""
+    src = tmp_path / "stub.mp4"
+    src.write_bytes(b"\x00" * 100)
+    out = tmp_path / "out.jpg"
+
+    sleep_path = shutil.which("sleep")
+    if not sleep_path:
+        pytest.skip("sleep binary not available")
+
+    # Point get_ffmpeg_path at a real binary that never exits in 15s.
+    monkeypatch.setattr("backend.app.services.camera.get_ffmpeg_path", lambda: sleep_path)
+    # Tighten the timeout via monkeypatch on asyncio.wait_for to keep the
+    # test fast — patch only inside the call so we don't affect the harness.
+    real_wait_for = asyncio.wait_for
+
+    async def short_wait_for(awaitable, timeout):
+        return await real_wait_for(awaitable, timeout=0.5)
+
+    monkeypatch.setattr("backend.app.services.camera.asyncio.wait_for", short_wait_for)
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False

+ 53 - 0
backend/tests/unit/test_filament_fields_options.py

@@ -0,0 +1,53 @@
+"""Regression pin for the filament_type select options exposed by
+GET /cloud/fields/filament — used by the Profiles edit modal.
+
+Issue #1686: PLA-CF and other CF/GF variants were missing from the dropdown,
+so users could not tag custom presets with the correct material type.
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+FIELDS_PATH = Path(__file__).resolve().parents[2] / "app" / "data" / "filament_fields.json"
+
+
+@pytest.fixture(scope="module")
+def filament_type_options() -> set[str]:
+    with FIELDS_PATH.open() as f:
+        data = json.load(f)
+    field = next(f for f in data["fields"] if f["key"] == "filament_type")
+    return {opt["value"] for opt in field["options"]}
+
+
+@pytest.mark.parametrize(
+    "material",
+    [
+        # Reported in #1686
+        "PLA-CF",
+        # Other Bambu CF / GF / specialty variants that share the same gap
+        "PLA-GF",
+        "PLA-AERO",
+        "PETG-CF",
+        "ABS-GF",
+        "ASA-CF",
+        "ASA-GF",
+        "PCTG",
+        "PAHT-CF",
+        "PA6-CF",
+        "PA6-GF",
+        "PPS",
+        "PPS-CF",
+        "PPS-GF",
+    ],
+)
+def test_filament_type_includes_carbon_and_glass_fiber_variants(filament_type_options: set[str], material: str) -> None:
+    assert material in filament_type_options
+
+
+def test_filament_type_keeps_baseline_materials(
+    filament_type_options: set[str],
+) -> None:
+    baseline = {"PLA", "PETG", "ABS", "ASA", "PC", "PA", "PA-CF", "PET-CF", "TPU", "PVA", "HIPS"}
+    assert baseline.issubset(filament_type_options)

+ 174 - 0
backend/tests/unit/test_finish_photo_from_timelapse.py

@@ -0,0 +1,174 @@
+"""Tests for _capture_finish_photo_from_timelapse (#1397).
+
+The polling helper runs in parallel with _scan_for_timelapse_with_retries —
+it waits for archive.timelapse_path to land in the DB, then extracts the
+last frame as the finish photo. These tests exercise the four shapes the
+helper has to handle correctly:
+
+  1. timelapse never lands within timeout → return None (caller falls back)
+  2. timelapse lands, extraction succeeds → return filename
+  3. timelapse lands, extraction fails → return None (caller falls back)
+  4. timelapse_path is set but the file doesn't exist on disk → keep polling
+
+DB access is patched at the session-maker boundary so these tests run in
+~50ms each without standing up a real engine.
+"""
+
+from contextlib import asynccontextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.main import _capture_finish_photo_from_timelapse
+
+
+@asynccontextmanager
+async def _fake_session(archive):
+    """A fake session whose execute().scalar_one_or_none() returns `archive`.
+
+    `archive` is mutated by the test mid-poll to simulate the real flow:
+    the timelapse-attach background task setting `timelapse_path` after a
+    few poll cycles.
+    """
+    result = SimpleNamespace(scalar_one_or_none=lambda: archive)
+    session = SimpleNamespace(execute=AsyncMock(return_value=result))
+    yield session
+
+
+@pytest.fixture
+def fake_archive():
+    """Mutable archive stand-in. Tests flip `.timelapse_path` to simulate
+    the timelapse-attach task writing to the DB."""
+    return SimpleNamespace(id=42, timelapse_path=None)
+
+
+@pytest.fixture(autouse=True)
+def _fast_poll(monkeypatch):
+    """Shrink poll interval + timeout so tests don't sleep for real."""
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.01)
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 0.2)
+
+
+@pytest.fixture
+def patched_session(fake_archive, monkeypatch):
+    """Patch main.async_session so the helper reads our fake archive."""
+    monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_archive))
+    return fake_archive
+
+
+async def test_returns_none_when_timelapse_never_lands(tmp_path: Path, patched_session):
+    """Print finished without a timelapse — bail after timeout so the caller
+    falls back to the live-camera grab."""
+    result = await _capture_finish_photo_from_timelapse(
+        archive_id=42,
+        archive_dir=tmp_path,
+    )
+    assert result is None
+
+
+async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch):
+    """Simulate the timelapse landing after one poll cycle and extraction
+    succeeding — should return a filename matching the finish_*.jpg pattern."""
+    # Lay down a stub timelapse file relative to base_dir so the path
+    # join works the way the helper expects.
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    video_abspath = tmp_path / video_relpath
+    video_abspath.parent.mkdir(parents=True, exist_ok=True)
+    video_abspath.write_bytes(b"x" * 100)  # non-empty so the size check passes
+
+    # Patch extraction to succeed unconditionally — the actual ffmpeg
+    # codepath has its own test file.
+    async def fake_extract(src, dst):
+        dst.write_bytes(b"\xff\xd8" + b"\x00" * 50)  # JPEG SOI
+        return True
+
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
+
+    # Flip the archive into the "timelapse landed" state before the first
+    # poll — the helper picks it up on its initial read.
+    patched_session.timelapse_path = str(video_relpath)
+
+    with patch(
+        "backend.app.services.camera.extract_video_last_frame",
+        new=fake_extract,
+    ):
+        result = await _capture_finish_photo_from_timelapse(
+            archive_id=42,
+            archive_dir=tmp_path / "archive_dir",
+        )
+
+    assert result is not None
+    assert result.startswith("finish_")
+    assert result.endswith(".jpg")
+    assert (tmp_path / "archive_dir" / "photos" / result).exists()
+
+
+async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch):
+    """Timelapse landed but ffmpeg said no — we don't keep retrying on the
+    same broken file; return None so the caller falls back."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    video_abspath = tmp_path / video_relpath
+    video_abspath.parent.mkdir(parents=True, exist_ok=True)
+    video_abspath.write_bytes(b"x" * 100)
+
+    async def fake_extract_fails(src, dst):
+        return False
+
+    patched_session.timelapse_path = str(video_relpath)
+
+    with patch(
+        "backend.app.services.camera.extract_video_last_frame",
+        new=fake_extract_fails,
+    ):
+        result = await _capture_finish_photo_from_timelapse(
+            archive_id=42,
+            archive_dir=tmp_path / "archive_dir",
+        )
+
+    assert result is None
+
+
+async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch):
+    """timelapse_path is set, but the file isn't on disk yet (the attach
+    background task hasn't finished writing). Should keep polling — and
+    succeed once the file materialises."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.05)
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 1.0)
+
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    patched_session.timelapse_path = str(video_relpath)
+
+    # File not present yet. Schedule it to land after ~150ms.
+    import asyncio
+
+    async def materialise_later():
+        await asyncio.sleep(0.15)
+        video_abspath = tmp_path / video_relpath
+        video_abspath.parent.mkdir(parents=True, exist_ok=True)
+        video_abspath.write_bytes(b"x" * 100)
+
+    async def fake_extract(src, dst):
+        dst.write_bytes(b"\xff\xd8")
+        return True
+
+    materialise = asyncio.create_task(materialise_later())
+    try:
+        with patch(
+            "backend.app.services.camera.extract_video_last_frame",
+            new=fake_extract,
+        ):
+            result = await _capture_finish_photo_from_timelapse(
+                archive_id=42,
+                archive_dir=tmp_path / "archive_dir",
+            )
+    finally:
+        materialise.cancel()
+
+    assert result is not None
+    assert result.startswith("finish_")

+ 77 - 29
backend/tests/unit/test_firmware_versions.py

@@ -164,9 +164,10 @@ async def test_get_available_versions_sorts_newest_first():
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_client_headers_identify_honestly_and_send_browser_accept():
 async def test_client_headers_identify_honestly_and_send_browser_accept():
     """
     """
-    The httpx client must identify as Bambuddy (no Chrome impersonation) and
-    must send Accept + Accept-Language so Cloudflare on bambulab.com doesn't
-    403 us for looking like a bare scraper (#1350).
+    The httpx client (used for the Bambu wiki and other non-CF-gated paths)
+    must identify as Bambuddy at the HTTP layer and must send Accept +
+    Accept-Language so Cloudflare on bambulab.com doesn't 403 us for
+    looking like a bare scraper (#1350).
     """
     """
     svc = FirmwareCheckService()
     svc = FirmwareCheckService()
     headers = svc._client.headers
     headers = svc._client.headers
@@ -176,17 +177,72 @@ async def test_client_headers_identify_honestly_and_send_browser_accept():
     assert "Accept-Language" in headers
     assert "Accept-Language" in headers
 
 
 
 
+@pytest.mark.asyncio
+async def test_bambulab_curl_cffi_session_keeps_honest_user_agent():
+    """
+    The curl_cffi session impersonates Chrome at the TLS layer (required to
+    pass Cloudflare's JA3 challenge on bambulab.com per #1666), but the
+    HTTP-layer User-Agent MUST stay 'Bambuddy/...'. A future refactor that
+    drops the headers= override would silently revert to curl_cffi's
+    Chrome-default UA and break our compliance commitment to identify
+    honestly at the application layer.
+
+    Skipped when curl_cffi is not installed (constrained-platform fallback
+    path — covered by the import-guard test below).
+    """
+    from backend.app.services import firmware_check as fc_module
+
+    if not fc_module._CURL_CFFI_AVAILABLE:
+        pytest.skip("curl_cffi not installed in this environment")
+
+    svc = FirmwareCheckService()
+    client = svc._get_bambulab_client()
+    assert client is not None
+
+    # curl_cffi's AsyncSession exposes the configured default headers on
+    # `.headers`. The exact attribute is part of curl_cffi's public API
+    # since 0.7.x — if a future upgrade renames it, this test will surface
+    # the rename rather than silently passing.
+    session_headers = client.headers  # type: ignore[attr-defined]
+    ua = session_headers.get("User-Agent", "")
+    assert ua.startswith("Bambuddy/"), f"curl_cffi session UA leaked Chrome default: {ua!r}"
+    assert "Chrome" not in ua
+    assert "Mozilla" not in ua
+
+
+@pytest.mark.asyncio
+async def test_bambulab_get_falls_back_to_httpx_when_curl_cffi_missing(monkeypatch):
+    """
+    When curl_cffi can't be imported (constrained platforms, alpine without
+    wheels, etc.), the service must still attempt the fetch via httpx so
+    wiki-based version detection isn't accidentally taken down with the
+    download-URL path. The httpx attempt will likely 403 against CF — that's
+    OK; the user still gets the version badge and a clear error in the UI.
+    """
+    from backend.app.services import firmware_check as fc_module
+
+    monkeypatch.setattr(fc_module, "_CURL_CFFI_AVAILABLE", False)
+    monkeypatch.setattr(fc_module, "_CurlCffiAsyncSession", None)
+
+    svc = FirmwareCheckService()
+    mock_resp = MagicMock(status_code=403, text="<html>Forbidden</html>")
+
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        status, body = await svc._bambulab_get("https://bambulab.com/whatever")
+
+    assert status == 403
+    assert "Forbidden" in body
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
 async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
     """Successful buildId fetch writes to disk so it survives restart (#1350)."""
     """Successful buildId fetch writes to disk so it survives restart (#1350)."""
     monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
     monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
 
 
     svc = FirmwareCheckService()
     svc = FirmwareCheckService()
-    mock_resp = MagicMock()
-    mock_resp.status_code = 200
-    mock_resp.text = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
+    body = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
 
 
-    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+    with patch.object(svc, "_bambulab_get", AsyncMock(return_value=(200, body))):
         build_id = await svc._get_build_id()
         build_id = await svc._get_build_id()
 
 
     assert build_id == "abc123xyz"
     assert build_id == "abc123xyz"
@@ -200,7 +256,7 @@ async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
 async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
     """
     """
-    When bambulab.com 403s (Cloudflare block reported in #1350) we must
+    When bambulab.com 403s (Cloudflare block reported in #1350, #1666) we must
     fall back to the disk-cached buildId from the previous successful fetch.
     fall back to the disk-cached buildId from the previous successful fetch.
     Without this the user's screenshots happen: wiki version is detected
     Without this the user's screenshots happen: wiki version is detected
     but the download URL stays empty forever.
     but the download URL stays empty forever.
@@ -213,11 +269,8 @@ async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
     (cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0}))
     (cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0}))
 
 
     svc = FirmwareCheckService()
     svc = FirmwareCheckService()
-    mock_resp = MagicMock()
-    mock_resp.status_code = 403
-    mock_resp.text = "<html>Access denied</html>"
 
 
-    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+    with patch.object(svc, "_bambulab_get", AsyncMock(return_value=(403, "<html>Access denied</html>"))):
         build_id = await svc._get_build_id()
         build_id = await svc._get_build_id()
 
 
     assert build_id == "cached_id_42"
     assert build_id == "cached_id_42"
@@ -233,11 +286,7 @@ async def test_download_page_unreachable_flag_set_on_403_json(tmp_path, monkeypa
     svc._build_id = "stale_id"
     svc._build_id = "stale_id"
     svc._build_id_time = 9999999999.0  # never expires for this test
     svc._build_id_time = 9999999999.0  # never expires for this test
 
 
-    mock_resp = MagicMock()
-    mock_resp.status_code = 403
-    mock_resp.text = "Forbidden"
-
-    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+    with patch.object(svc, "_bambulab_get", AsyncMock(return_value=(403, "Forbidden"))):
         result = await svc._fetch_all_versions_from_download_page("x1")
         result = await svc._fetch_all_versions_from_download_page("x1")
 
 
     assert result == []
     assert result == []
@@ -256,12 +305,8 @@ async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypat
     svc._build_id = "stale_id"
     svc._build_id = "stale_id"
     svc._build_id_time = 9999999999.0
     svc._build_id_time = 9999999999.0
 
 
-    # First call (with stale buildId) → 404
-    # Second call (with fresh buildId after refresh) → 200 with versions
-    stale_resp = MagicMock(status_code=404, text="not found")
-    fresh_resp = MagicMock(status_code=200)
-    fresh_resp.json = MagicMock(
-        return_value={
+    fresh_json_body = json.dumps(
+        {
             "pageProps": {
             "pageProps": {
                 "printerMap": {
                 "printerMap": {
                     "x1": {
                     "x1": {
@@ -278,13 +323,16 @@ async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypat
             }
             }
         }
         }
     )
     )
-    # Page fetch for the buildId refresh
-    page_resp = MagicMock(status_code=200)
-    page_resp.text = 'foo "buildId":"fresh_id" bar'
 
 
-    # Sequence: stale 404 → page refresh 200 → fresh 200
-    get = AsyncMock(side_effect=[stale_resp, page_resp, fresh_resp])
-    with patch.object(svc._client, "get", get):
+    # Sequence: stale JSON 404 → page refresh 200 (carries fresh buildId) → fresh JSON 200
+    get = AsyncMock(
+        side_effect=[
+            (404, "not found"),
+            (200, 'foo "buildId":"fresh_id" bar'),
+            (200, fresh_json_body),
+        ]
+    )
+    with patch.object(svc, "_bambulab_get", get):
         result = await svc._fetch_all_versions_from_download_page("x1")
         result = await svc._fetch_all_versions_from_download_page("x1")
 
 
     assert len(result) == 1
     assert len(result) == 1

+ 89 - 16
backend/tests/unit/test_local_backup.py

@@ -12,9 +12,15 @@ from backend.app.services.local_backup import LocalBackupService
 
 
 
 
 class TestCalculateNextRun:
 class TestCalculateNextRun:
-    """Tests for _calculate_next_run scheduling logic."""
+    """Tests for _calculate_next_run scheduling logic.
 
 
-    def test_hourly_returns_next_full_hour(self):
+    The HH:MM picker is interpreted in the container's local timezone (TZ env
+    var, UTC fallback). Each test pins TZ so the assertions don't depend on
+    the test runner's environment.
+    """
+
+    def test_hourly_returns_next_full_hour(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
@@ -24,47 +30,48 @@ class TestCalculateNextRun:
         assert result.hour == 15
         assert result.hour == 15
         assert result.minute == 0
         assert result.minute == 0
 
 
-    def test_daily_before_target_time_schedules_today(self):
+    def test_daily_before_target_time_schedules_today_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("daily", "03:00")
             result = service._calculate_next_run("daily", "03:00")
-        assert result.day == 12
-        assert result.hour == 3
+        assert result == datetime(2026, 4, 12, 3, 0, 0, tzinfo=timezone.utc)
 
 
-    def test_daily_after_target_time_schedules_tomorrow(self):
+    def test_daily_after_target_time_schedules_tomorrow_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("daily", "03:00")
             result = service._calculate_next_run("daily", "03:00")
-        assert result.day == 13
-        assert result.hour == 3
+        assert result == datetime(2026, 4, 13, 3, 0, 0, tzinfo=timezone.utc)
 
 
-    def test_weekly_adds_full_week(self):
+    def test_weekly_adds_full_week_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("weekly", "03:00")
             result = service._calculate_next_run("weekly", "03:00")
-        expected = datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
-        assert result == expected
+        assert result == datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
 
 
-    def test_weekly_after_target_time_adds_full_week_from_tomorrow(self):
+    def test_weekly_after_target_time_adds_full_week_from_tomorrow_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("weekly", "03:00")
             result = service._calculate_next_run("weekly", "03:00")
-        expected = datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
-        assert result == expected
+        assert result == datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
 
 
-    def test_invalid_time_defaults_to_0300(self):
+    def test_invalid_time_defaults_to_0300(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
@@ -74,7 +81,8 @@ class TestCalculateNextRun:
         assert result.hour == 3
         assert result.hour == 3
         assert result.minute == 0
         assert result.minute == 0
 
 
-    def test_unknown_schedule_type_defaults_to_daily(self):
+    def test_unknown_schedule_type_defaults_to_daily(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
@@ -84,6 +92,71 @@ class TestCalculateNextRun:
         # Should fall through to daily behavior (time-based)
         # Should fall through to daily behavior (time-based)
         assert result.hour == 3
         assert result.hour == 3
 
 
+    def test_daily_berlin_local_time_converts_to_utc(self, monkeypatch):
+        """User in Europe/Berlin entering 21:00 should run at 19:00 UTC (CEST/UTC+2)."""
+        monkeypatch.setenv("TZ", "Europe/Berlin")
+        service = LocalBackupService()
+        # Mid-June: Europe/Berlin is CEST (+02:00)
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)  # 12:00 Berlin
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        # 21:00 Berlin (CEST, +02:00) on 2026-06-15 == 19:00 UTC same day
+        assert result == datetime(2026, 6, 15, 19, 0, 0, tzinfo=timezone.utc)
+
+    def test_daily_istanbul_local_time_converts_to_utc(self, monkeypatch):
+        """The #1602 reporter: UTC+3 user entering 21:00 should run at 18:00 UTC."""
+        monkeypatch.setenv("TZ", "Europe/Istanbul")
+        service = LocalBackupService()
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)  # 13:00 Istanbul
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        assert result == datetime(2026, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
+
+    def test_no_tz_env_falls_back_to_utc(self, monkeypatch):
+        monkeypatch.delenv("TZ", raising=False)
+        service = LocalBackupService()
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        # No TZ → behaves as UTC: 21:00 today is in the future of 10:00, so today
+        assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
+
+    def test_unrecognised_tz_falls_back_to_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "Not/A_Real_Zone")
+        service = LocalBackupService()
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
+
+    def test_dst_spring_forward_gap_does_not_crash(self, monkeypatch):
+        """Europe/Berlin spring-forward 2026-03-29 jumps 02:00 → 03:00 local;
+        02:30 wall-clock does not exist. ``replace(hour=2, minute=30)`` should
+        still normalise to a valid UTC instant via astimezone, not raise.
+        """
+        monkeypatch.setenv("TZ", "Europe/Berlin")
+        service = LocalBackupService()
+        # 2026-03-29 00:30 UTC == 01:30 Berlin (CET, just before the gap)
+        now = datetime(2026, 3, 29, 0, 30, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            # 02:30 local is in the non-existent gap on that day.
+            result = service._calculate_next_run("daily", "02:30")
+        # Result must be a UTC-aware datetime — exact value depends on
+        # zoneinfo's gap normalisation; we just guarantee no crash and that
+        # the run is in the future of ``now``.
+        assert result.tzinfo == timezone.utc
+        assert result > now
+
 
 
 class TestPruneBackups:
 class TestPruneBackups:
     """Tests for backup retention pruning."""
     """Tests for backup retention pruning."""

+ 145 - 0
backend/tests/unit/test_makerworld_routes.py

@@ -382,6 +382,151 @@ class TestImport:
         assert resp.status_code == 200, resp.text
         assert resp.status_code == 200, resp.text
         assert resp.json()["profile_id"] == 298919107
         assert resp.json()["profile_id"] == 298919107
 
 
+    @pytest.mark.asyncio
+    async def test_import_to_writable_external_writes_bytes_to_mount(self, async_client, db_session, tmp_path):
+        """#1645: importing into a writable external folder writes the bytes to
+        ``<external_path>/<filename>`` and tags the row ``is_external=True`` —
+        same shape as the multipart-upload path (#1112). Previously the bytes
+        landed in the internal library dir under a UUID name while the row
+        showed up under the external folder in the UI, leaving a NAS/SMB user
+        unable to find their file on the mount."""
+        ext_dir = tmp_path / "nas-makerworld"
+        ext_dir.mkdir()
+        folder = LibraryFolder(
+            name="NAS Imports",
+            parent_id=None,
+            is_external=True,
+            external_path=str(ext_dir),
+            external_readonly=False,
+        )
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest("seed-starter.3mf"),
+            download_3mf=(self._FAKE_3MF_BYTES, "seed-starter.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
+            )
+        assert resp.status_code == 200, resp.text
+
+        from sqlalchemy import select
+
+        row = (
+            await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["library_file_id"]))
+        ).scalar_one()
+        assert row.folder_id == folder.id
+        assert row.is_external is True, "Row must be tagged external so re-scan can reconcile it"
+        # External rows persist the absolute mount path (matches scan + upload paths).
+        assert row.file_path == str(ext_dir / "seed-starter.3mf")
+        on_disk = ext_dir / "seed-starter.3mf"
+        assert on_disk.is_file(), "Bytes must land on the external mount, not in the internal library dir"
+        assert on_disk.read_bytes() == self._FAKE_3MF_BYTES
+
+    @pytest.mark.asyncio
+    async def test_import_to_readonly_external_rejected_at_route(self, async_client, db_session, tmp_path):
+        """The route-layer gate at ``makerworld.py:256-260`` rejects read-only
+        externals with 403 before any download happens — so MakerWorld
+        credentials and the upstream download bandwidth aren't wasted."""
+        ext_dir = tmp_path / "nas-readonly"
+        ext_dir.mkdir()
+        folder = LibraryFolder(
+            name="NAS read-only",
+            parent_id=None,
+            is_external=True,
+            external_path=str(ext_dir),
+            external_readonly=True,
+        )
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+        )
+        svc.download_3mf = AsyncMock()
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
+            )
+        assert resp.status_code == 403, resp.text
+        svc.download_3mf.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_import_to_external_with_missing_path_returns_400(self, async_client, db_session, tmp_path):
+        """If the external folder's mount has gone away (NAS unplugged, SMB
+        share down), ``_resolve_upload_destination`` returns 400 before the
+        write so we don't silently fall back to the internal library dir."""
+        missing_dir = tmp_path / "vanished-mount"  # NOTE: deliberately not created
+        folder = LibraryFolder(
+            name="NAS gone",
+            parent_id=None,
+            is_external=True,
+            external_path=str(missing_dir),
+            external_readonly=False,
+        )
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest(),
+            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
+            )
+        assert resp.status_code == 400, resp.text
+        assert "not accessible" in resp.text.lower()
+
+    @pytest.mark.asyncio
+    async def test_import_to_external_with_name_collision_returns_409(self, async_client, db_session, tmp_path):
+        """A user-visible 409 fires when the filename already exists on the
+        external mount, instead of silently overwriting a file the user put
+        there outside Bambuddy."""
+        ext_dir = tmp_path / "nas-collide"
+        ext_dir.mkdir()
+        (ext_dir / "benchy.3mf").write_bytes(b"pre-existing")
+
+        folder = LibraryFolder(
+            name="NAS collide",
+            parent_id=None,
+            is_external=True,
+            external_path=str(ext_dir),
+            external_readonly=False,
+        )
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        svc = _fake_service(
+            get_design=_default_design(),
+            get_profile_download=_default_manifest("benchy.3mf"),
+            download_3mf=(self._FAKE_3MF_BYTES, "benchy.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
+            )
+        assert resp.status_code == 409, resp.text
+        # Pre-existing file's contents must not be clobbered by the failed write.
+        assert (ext_dir / "benchy.3mf").read_bytes() == b"pre-existing"
+
 
 
 class TestRecentImports:
 class TestRecentImports:
     """GET /makerworld/recent-imports — sidebar feed on the MakerWorld page."""
     """GET /makerworld/recent-imports — sidebar feed on the MakerWorld page."""

+ 98 - 0
backend/tests/unit/test_print_start_expected_promotion.py

@@ -18,7 +18,9 @@ from backend.app.main import (
     _expected_print_creators,
     _expected_print_creators,
     _expected_print_registered_at,
     _expected_print_registered_at,
     _expected_prints,
     _expected_prints,
+    _get_start_plate_id,
     _print_ams_mappings,
     _print_ams_mappings,
+    _print_plate_ids,
     register_expected_print,
     register_expected_print,
 )
 )
 
 
@@ -30,12 +32,14 @@ def _clear_dicts():
     _expected_print_registered_at.clear()
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _print_ams_mappings.clear()
+    _print_plate_ids.clear()
     _active_prints.clear()
     _active_prints.clear()
     yield
     yield
     _expected_prints.clear()
     _expected_prints.clear()
     _expected_print_registered_at.clear()
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _print_ams_mappings.clear()
+    _print_plate_ids.clear()
     _active_prints.clear()
     _active_prints.clear()
 
 
 
 
@@ -69,6 +73,24 @@ class TestRegisterExpectedPrint:
         ts = _expected_print_registered_at[(1, "test.3mf")]
         ts = _expected_print_registered_at[(1, "test.3mf")]
         assert before <= ts <= after
         assert before <= ts <= after
 
 
+    def test_stores_plate_id(self):
+        """plate_id is registered so usage tracking can scope multi-plate 3MFs (#1697)."""
+        register_expected_print(1, "test.3mf", archive_id=10, plate_id=2)
+        assert _print_plate_ids[10] == 2
+
+    def test_no_plate_id_when_none(self):
+        """Direct-Print of a single-plate file passes plate_id=None; nothing stored."""
+        register_expected_print(1, "test.3mf", archive_id=10, plate_id=None)
+        assert 10 not in _print_plate_ids
+
+    def test_get_start_plate_id_reads_back(self):
+        register_expected_print(1, "test.3mf", archive_id=10, plate_id=3)
+        assert _get_start_plate_id(10) == 3
+
+    def test_get_start_plate_id_returns_none_for_unregistered(self):
+        assert _get_start_plate_id(10) is None
+        assert _get_start_plate_id(None) is None
+
 
 
 class TestExpectedPrintDetection:
 class TestExpectedPrintDetection:
     """Verify the expected-print detection logic used in on_print_start.
     """Verify the expected-print detection logic used in on_print_start.
@@ -339,3 +361,79 @@ class TestAMSMappingInjection:
         assert ut_session.ams_mapping == [5, 6]  # unchanged
         assert ut_session.ams_mapping == [5, 6]  # unchanged
 
 
         _active_sessions.clear()
         _active_sessions.clear()
+
+
+class TestPlateIdInjection:
+    """Verify plate_id injection into usage tracker session for direct-Print of
+    a non-first plate from a multi-plate 3MF (#1697)."""
+
+    def test_injection_into_session(self):
+        """plate_id from _print_plate_ids gets injected when session has none."""
+        from datetime import datetime, timezone
+
+        from backend.app.services.usage_tracker import PrintSession, _active_sessions
+
+        _active_sessions.clear()
+
+        # Session created by on_print_start before expected-print promotion;
+        # plate_id is None because no queue item was found (direct-Print path).
+        session = PrintSession(
+            printer_id=1,
+            print_name="Box",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={},
+            tray_now_at_start=-1,
+            spool_assignments={},
+            ams_mapping=None,
+            plate_id=None,
+        )
+        _active_sessions[1] = session
+
+        register_expected_print(1, "Box.3mf", archive_id=54, plate_id=2)
+
+        # Mirror the injection branch from main.py.
+        _stored_plate_id = _print_plate_ids.get(54)
+        assert _stored_plate_id == 2
+
+        ut_session = _active_sessions.get(1)
+        assert ut_session is not None
+        assert ut_session.plate_id is None  # before injection
+
+        ut_session.plate_id = _stored_plate_id  # injection
+        assert ut_session.plate_id == 2
+
+        _active_sessions.clear()
+
+    def test_no_injection_when_session_already_has_plate_id(self):
+        """Queue path: on_print_start already captured plate_id from queue_item;
+        don't overwrite with the dict value."""
+        from datetime import datetime, timezone
+
+        from backend.app.services.usage_tracker import PrintSession, _active_sessions
+
+        _active_sessions.clear()
+
+        session = PrintSession(
+            printer_id=1,
+            print_name="Box",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={},
+            tray_now_at_start=-1,
+            spool_assignments={},
+            ams_mapping=None,
+            plate_id=3,  # captured from queue_item by on_print_start
+        )
+        _active_sessions[1] = session
+
+        register_expected_print(1, "Box.3mf", archive_id=54, plate_id=2)
+
+        _stored_plate_id = _print_plate_ids.get(54)
+        ut_session = _active_sessions.get(1)
+
+        # Guard: don't overwrite if session already has a plate_id
+        if ut_session and ut_session.plate_id is None:
+            ut_session.plate_id = _stored_plate_id
+
+        assert ut_session.plate_id == 3  # queue value preserved
+
+        _active_sessions.clear()

+ 18 - 0
backend/tests/unit/test_reconcile_stale_active_prints.py

@@ -129,6 +129,24 @@ class TestIsActiveArchiveStale:
         assert is_stale is True
         assert is_stale is True
         assert "IDLE" in reason
         assert "IDLE" in reason
 
 
+    # #1679: defensive pre-push guard. Even if reconcile gets called against
+    # a PrinterState that's still on construction defaults (state="unknown"
+    # / empty / None, subtask_name=""), the function must NOT report stale —
+    # otherwise the reactive PRINT COMPLETE later creates a duplicate
+    # archive and filament gets double-counted. The on_printer_status_change
+    # caller is the primary fix (gates the reconcile spawn on real state),
+    # but this guard is belt-and-braces for any future caller.
+    @pytest.mark.parametrize("degenerate_state", ["unknown", "UNKNOWN", "Unknown", "", None])
+    def test_pre_push_state_returns_not_stale_even_with_empty_subtask(self, degenerate_state):
+        archive = _archive(subtask_id="ABC123")
+        state = _state(degenerate_state, subtask_id="", subtask_name="")
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is False, (
+            f"state.state={degenerate_state!r} means MQTT hasn't pushed real data yet; "
+            "treating an in-flight archive as stale here causes the #1679 "
+            "duplicate-archive + filament-double-count regression"
+        )
+
 
 
 class TestReconcileStaleActivePrints:
 class TestReconcileStaleActivePrints:
     """Orchestrator-level tests — mock the printer manager + DB session so
     """Orchestrator-level tests — mock the printer manager + DB session so

+ 38 - 0
backend/tests/unit/test_scheduler_filament_deficit.py

@@ -117,3 +117,41 @@ async def test_helper_exception_does_not_wedge_dispatch(scheduler, db_session, q
     assert blocked is False
     assert blocked is False
     await db_session.refresh(item)
     await db_session.refresh(item)
     assert item.filament_short is False
     assert item.filament_short is False
+
+
+@pytest.mark.asyncio
+async def test_skip_filament_check_short_circuits_without_compute(scheduler, db_session, queue_item):
+    """User clicked Print Anyway (skip_filament_check=True): no compute, no flag (#1698-followup).
+
+    Pre-fix the scheduler re-ran the deficit check on every tick, re-set
+    manual_start/filament_short to True, and the item bounced between
+    "user said anyway" (route clears flags) and "scheduler re-blocked"
+    forever. With the persistent acknowledgement flag the scheduler bails
+    early without even touching the deficit helper.
+    """
+    item = await queue_item(skip_filament_check=True)
+    compute_mock = AsyncMock(
+        return_value=[
+            FilamentDeficit(
+                slot_id=1,
+                ams_id=0,
+                tray_id=0,
+                filament_type="PLA",
+                required_grams=270.0,
+                remaining_grams=200.0,
+            ),
+        ]
+    )
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        compute_mock,
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    compute_mock.assert_not_awaited()
+    await db_session.refresh(item)
+    # Flags must not get re-set by the scheduler now that the user has
+    # acknowledged the deficit.
+    assert item.filament_short is False
+    assert item.manual_start is False

+ 71 - 0
backend/tests/unit/test_scheduler_force_timelapse_wiring.py

@@ -0,0 +1,71 @@
+"""Regression test for the print-queue path of the #1397 force-timelapse fix.
+
+The first round of #1397 only wired the override into ``background_dispatch.py``,
+which covers Print Now / Reprint Now flows. The print *queue* uses a separate
+scheduler at ``print_scheduler.py:_start_print`` that calls
+``printer_manager.start_print`` directly — and the first attempt skipped that
+call site, so queued prints' timelapse setting passed through unchanged and
+the finish-photo path had nothing to draw from. Field-test caught this when
+Martin queued two prints (H2D + X1C); neither got a forced timelapse and
+``archive.bambuddy_forced_timelapse`` stayed False on both.
+
+This test pins the wiring at the source level: the helper is imported AND
+its return value is what ``start_print(timelapse=...)`` receives. We can't
+exercise the full ``_start_print`` method without standing up a real DB +
+printer_manager + ams_assignment fixture stack, but the structural assert
+is enough to catch regression at the dispatch hook.
+"""
+
+import ast
+from pathlib import Path
+
+SCHEDULER_PATH = Path(__file__).resolve().parent.parent.parent / "app" / "services" / "print_scheduler.py"
+
+
+def _find_call_to_start_print(tree: ast.AST) -> ast.Call:
+    """Walk the AST and return the printer_manager.start_print(...) Call node
+    inside _start_print. Should be exactly one."""
+    for node in ast.walk(tree):
+        if not isinstance(node, ast.Call):
+            continue
+        func = node.func
+        if not isinstance(func, ast.Attribute):
+            continue
+        if func.attr != "start_print":
+            continue
+        value = func.value
+        if not isinstance(value, ast.Name) or value.id != "printer_manager":
+            continue
+        return node
+    raise AssertionError("Could not find printer_manager.start_print(...) call in print_scheduler.py")
+
+
+def test_start_print_timelapse_kwarg_uses_resolved_value():
+    """``timelapse=`` kwarg passed to start_print must reference
+    ``effective_timelapse`` (the resolved value) — not ``item.timelapse``
+    (the user's raw choice). If a refactor drops the resolver call and
+    restores ``item.timelapse``, this test fails."""
+    source = SCHEDULER_PATH.read_text()
+    tree = ast.parse(source)
+
+    call = _find_call_to_start_print(tree)
+    timelapse_kw = next((kw for kw in call.keywords if kw.arg == "timelapse"), None)
+    assert timelapse_kw is not None, "start_print(timelapse=...) kwarg is missing"
+
+    # The value must be the resolved variable, not item.timelapse.
+    value = timelapse_kw.value
+    assert isinstance(value, ast.Name) and value.id == "effective_timelapse", (
+        f"timelapse= must be the resolver's return value (effective_timelapse), "
+        f"got {ast.dump(value)}. The queue path must apply the same #1397 "
+        f"override as background_dispatch.py — otherwise queued prints' "
+        f"finish-photo extractor has nothing to pull from."
+    )
+
+
+def test_scheduler_imports_resolve_effective_timelapse():
+    """The import must exist somewhere in print_scheduler.py — guards against
+    a future refactor removing it and falling back to item.timelapse."""
+    source = SCHEDULER_PATH.read_text()
+    assert "from backend.app.services.background_dispatch import resolve_effective_timelapse" in source, (
+        "print_scheduler.py must import resolve_effective_timelapse from background_dispatch"
+    )

+ 82 - 8
backend/tests/unit/test_scheduler_watchdog.py

@@ -76,11 +76,25 @@ class TestWatchdogExitsEarlyOnPickup:
             assert item.status == "printing"
             assert item.status == "printing"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_exits_on_subtask_id_change_even_if_state_still_finish(self, db_session):
-        """Regression for #1078: H2D keeps state=FINISH for ~50 s after accepting
-        project_file, but subtask_id flips to our new submission_id almost
-        immediately. That must short-circuit the revert."""
-        get_status = MagicMock(return_value=_status("FINISH", "NEW_SUBTASK_12345"))
+    async def test_h2d_finish_to_running_via_subtask_id_then_active_state(self, db_session):
+        """Regression for #1078 (preserved through the two-phase rewrite for #1678):
+
+        H2D keeps state=FINISH for ~50 s after accepting project_file, but
+        subtask_id flips to our new submission_id almost immediately. The
+        watchdog must NOT revert on the basis of state staying at FINISH —
+        Phase A exits on the subtask_id advance, Phase B then keeps watching
+        and exits SUCCESS as soon as the printer transitions to PREPARE /
+        RUNNING within the longer Phase B window.
+        """
+        # First poll: state still FINISH, subtask_id advanced (Phase A → B).
+        # Second poll: state has flipped to RUNNING (Phase B success).
+        get_status = MagicMock(
+            side_effect=[
+                _status("FINISH", "NEW_SUBTASK_12345"),
+                _status("RUNNING", "NEW_SUBTASK_12345"),
+            ]
+            + [_status("RUNNING", "NEW_SUBTASK_12345")] * 10,
+        )
         with (
         with (
             patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
             patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
             patch("backend.app.services.print_scheduler.async_session", db_session),
             patch("backend.app.services.print_scheduler.async_session", db_session),
@@ -91,15 +105,16 @@ class TestWatchdogExitsEarlyOnPickup:
                 pre_state="FINISH",
                 pre_state="FINISH",
                 pre_subtask_id="OLD_SUBTASK_99999",
                 pre_subtask_id="OLD_SUBTASK_99999",
                 timeout=0.3,
                 timeout=0.3,
+                phase_b_timeout=0.3,
                 poll_interval=0.05,
                 poll_interval=0.05,
             )
             )
 
 
         async with db_session() as db:
         async with db_session() as db:
             item = await db.get(PrintQueueItem, 1)
             item = await db.get(PrintQueueItem, 1)
             assert item.status == "printing", (
             assert item.status == "printing", (
-                "subtask_id advanced past pre_subtask_id — the printer accepted our "
-                "project_file and the watchdog must not revert the queue item even "
-                "though state is still FINISH (#1078)"
+                "Phase A exit on subtask_id advance + Phase B observing the "
+                "active-state transition is the H2D success path — watchdog "
+                "must keep the item 'printing' (#1078)"
             )
             )
 
 
 
 
@@ -228,6 +243,65 @@ class TestWatchdogRevertsWhenStuck:
         sig = inspect.signature(PrintScheduler._watchdog_print_start)
         sig = inspect.signature(PrintScheduler._watchdog_print_start)
         assert sig.parameters["timeout"].default == 90.0
         assert sig.parameters["timeout"].default == 90.0
 
 
+    @pytest.mark.asyncio
+    async def test_default_phase_b_timeout_is_180_seconds(self):
+        """Phase B (subtask_id advanced, waiting for active state) must
+        comfortably exceed the H2D FINISH→PREPARE delay (~50 s observed)
+        before declaring a printer-side wedge. 180 s gives ~3.5× headroom
+        and reverts the queue item in well under the previous 2-hour
+        expected_print TTL (#1678)."""
+        import inspect
+
+        sig = inspect.signature(PrintScheduler._watchdog_print_start)
+        assert sig.parameters["phase_b_timeout"].default == 180.0
+
+    @pytest.mark.asyncio
+    async def test_reverts_when_subtask_advanced_but_state_never_active(self, db_session):
+        """Regression for #1678: P1S on old firmware, power-cycled mid-print,
+        cloud+LAN re-auth dance in flight. Printer accepts project_file
+        (gcode_file updates, subtask_id advances to our submission id) but
+        never transitions from IDLE/FINISH to PREPARE/RUNNING. The pre-fix
+        watchdog returned SUCCESS as soon as subtask_id advanced and the
+        queue item stayed in 'printing' until container restart. Phase B now
+        keeps watching; if the active-state transition never arrives, the
+        item reverts to 'pending' so the user can retry without restarting.
+        """
+        get_status = MagicMock(
+            return_value=_status("IDLE", "NEW_SUBTASK_12345", gcode_file="/new.3mf"),
+        )
+        client = MagicMock()  # NOT None — must verify reconnect isn't called
+        get_client = MagicMock(return_value=client)
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+            patch("backend.app.core.database.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="IDLE",
+                pre_subtask_id="OLD_SUBTASK_99999",
+                pre_gcode_file="/old.3mf",
+                timeout=0.2,
+                phase_b_timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending", (
+                "subtask_id advanced (Phase A → B) but state never reached an "
+                "active value — printer-side wedge; the queue item must be "
+                "reverted to 'pending' (#1678)"
+            )
+            assert item.started_at is None
+
+        # File landed (subtask_id advance proves this), so a forced reconnect
+        # would trigger 0500_4003 mid-parse (#1150) — skip.
+        client.force_reconnect_stale_session.assert_not_called()
+
 
 
 class TestWatchdogFallbackBehaviour:
 class TestWatchdogFallbackBehaviour:
     """Backwards-compat and defensive behaviour around missing data."""
     """Backwards-compat and defensive behaviour around missing data."""

+ 132 - 5
backend/tests/unit/test_slicer_presets.py

@@ -36,7 +36,7 @@ class TestDedupeByName:
         local = _slot([("lid1", "Bambu PLA Basic", "local")])
         local = _slot([("lid1", "Bambu PLA Basic", "local")])
         standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
         standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
 
 
-        c, l_, s = sp._dedupe_by_name(cloud, local, standard)
+        _oc, c, l_, s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
 
 
         assert [p.source for p in c["printer"]] == ["cloud"]
         assert [p.source for p in c["printer"]] == ["cloud"]
         assert l_["printer"] == []
         assert l_["printer"] == []
@@ -52,7 +52,7 @@ class TestDedupeByName:
         )
         )
         standard = _slot([])
         standard = _slot([])
 
 
-        _c, l_, _s = sp._dedupe_by_name(cloud, local, standard)
+        _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
         assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"]
         assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"]
 
 
     def test_standard_filtered_against_both_higher_tiers(self):
     def test_standard_filtered_against_both_higher_tiers(self):
@@ -66,7 +66,7 @@ class TestDedupeByName:
             ]
             ]
         )
         )
 
 
-        _c, _l, s = sp._dedupe_by_name(cloud, local, standard)
+        _oc, _c, _l, s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
         assert [p.name for p in s["printer"]] == ["C"]
         assert [p.name for p in s["printer"]] == ["C"]
 
 
     def test_preserves_order_within_tier(self):
     def test_preserves_order_within_tier(self):
@@ -79,7 +79,7 @@ class TestDedupeByName:
                 ("c3", "M-Third", "cloud"),
                 ("c3", "M-Third", "cloud"),
             ]
             ]
         )
         )
-        c, _l, _s = sp._dedupe_by_name(cloud, _slot([]), _slot([]))
+        _oc, c, _l, _s = sp._dedupe_by_name(_slot([]), cloud, _slot([]), _slot([]))
         assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
         assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
 
 
     def test_dedupe_is_per_slot(self):
     def test_dedupe_is_per_slot(self):
@@ -95,7 +95,7 @@ class TestDedupeByName:
             "process": [],
             "process": [],
             "filament": [],
             "filament": [],
         }
         }
-        _c, l_, _s = sp._dedupe_by_name(cloud, local, _slot([]))
+        _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, _slot([]))
         # The filament-tier collision must NOT remove the printer-tier "Custom".
         # The filament-tier collision must NOT remove the printer-tier "Custom".
         assert [p.name for p in l_["printer"]] == ["Custom"]
         assert [p.name for p in l_["printer"]] == ["Custom"]
 
 
@@ -111,6 +111,133 @@ def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
     return user
     return user
 
 
 
 
+class TestFetchOrcaCloudPresets:
+    """``_fetch_orca_cloud_presets`` mirrors the Bambu Cloud fetcher's status
+    vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``)
+    and the same permission-shortcut + caching behaviour. Tests pin the
+    contract so a future bug in either fetcher doesn't silently desync them."""
+
+    def _orca_creds(self, token: str | None = "tok") -> MagicMock:
+        creds = MagicMock()
+        creds.token = token
+        return creds
+
+    @pytest.mark.asyncio
+    async def test_no_token_returns_not_authenticated(self):
+        sp._orca_cloud_cache.clear()
+        with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds(None))):
+            user = MagicMock(id=1)
+            user.has_permission = MagicMock(return_value=True)
+            slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+        assert status == "not_authenticated"
+        assert slots == {"printer": [], "process": [], "filament": []}
+
+    @pytest.mark.asyncio
+    async def test_user_without_orca_cloud_auth_returns_not_authenticated(self):
+        """Defence-in-depth — a user lacking ORCA_CLOUD_AUTH must not see Orca
+        presets even if their User row carries a stale token. Credentials
+        lookup must short-circuit ahead of the token read."""
+        sp._orca_cloud_cache.clear()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=False)
+        with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))) as load:
+            slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+        assert status == "not_authenticated"
+        assert slots["printer"] == []
+        load.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_auth_error_returns_expired(self):
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudAuthError("expired"))
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+        assert status == "expired"
+        svc_mock.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_orca_error_returns_unreachable(self):
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudError("net down"))
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+        assert status == "unreachable"
+
+    @pytest.mark.asyncio
+    async def test_happy_path_shapes_grouped_by_type(self):
+        """Orca content.type values map onto Bambu Cloud's preset type vocab
+        (``printer`` / ``print`` → ``process`` / ``filament``). Verify the
+        full mapping by feeding one of each shape."""
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
+                {"id": "p1", "name": "Orca 0.20mm", "content": {"type": "print"}},
+                {
+                    "id": "f1",
+                    "name": "Orca PLA",
+                    "content": {
+                        "type": "filament",
+                        "filament_type": ["PLA"],
+                        "default_filament_colour": ["#000000"],
+                    },
+                },
+            ]
+        )
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+        assert status == "ok"
+        assert [p.name for p in slots["printer"]] == ["Orca X1C"]
+        assert [p.name for p in slots["process"]] == ["Orca 0.20mm"]
+        filament = slots["filament"]
+        assert [p.name for p in filament] == ["Orca PLA"]
+        # Inline metadata extracted from the content blob (Orca's sync_pull
+        # returns full content, so unlike Bambu Cloud we don't need a second
+        # per-preset fetch to enrich filament_type / filament_colour).
+        assert filament[0].filament_type == "PLA"
+        assert filament[0].filament_colour == "#000000"
+
+    @pytest.mark.asyncio
+    async def test_cache_hit_skips_orca_call(self):
+        """A second call within TTL must reuse the cached slots and NOT
+        hit the Orca service again — same TTL as Bambu Cloud (5 min)."""
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(return_value=[])
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build,
+        ):
+            await sp._fetch_orca_cloud_presets(MagicMock(), user)
+            await sp._fetch_orca_cloud_presets(MagicMock(), user)
+        # Build is the cache miss signal — second call reused the cache.
+        build.assert_awaited_once()
+
+
 class TestFetchCloudPresets:
 class TestFetchCloudPresets:
     """`_fetch_cloud_presets` translates token state and cloud errors into
     """`_fetch_cloud_presets` translates token state and cloud errors into
     the four ``cloud_status`` values the SliceModal banner consumes."""
     the four ``cloud_status`` values the SliceModal banner consumes."""

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor