Parcourir la source

Merge pull request #1029 from maziggy/0.2.3

**Bambuddy v0.2.3**

## ⚠ Upgrade Notes — Read Before Updating

The in-app **Update** button does not reliably perform the one-time migration from 0.2.2.x to 0.2.3. Please do this one upgrade from the command line using one of the paths below. Once you're on 0.2.3, the in-app Update button works normally again for all future releases. Full guide: [UPDATING.md](https://github.com/maziggy/bambuddy/blob/main/UPDATING.md).

**Docker**

First make sure your `docker-compose.yml` `image:` line points at `:latest` or `:0.2.3` — if it pins an older tag (e.g. `:0.2.2.2`), `docker compose pull` will just re-fetch that tag. If your compose file is older than 0.2.3, also refresh it from the repo; recent releases added `cap_add: NET_BIND_SERVICE`, extra virtual-printer ports for bridge mode, and an optional PostgreSQL block.

```bash
docker compose pull
docker compose up -d
```

**Native install (`install.sh` or manual `git clone`)**

The bundled `update.sh` now stops the service, snapshots the database via the built-in backup API, fast-forwards to `origin/main`, installs Python dependencies, rebuilds the frontend, restarts the service, and rolls back automatically if any step fails.

```bash
sudo /opt/bambuddy/install/update.sh
```

**Installed from a GitHub ZIP/tarball?** Those installs have no `.git` directory and cannot be upgraded in place. See the [UPDATING.md recovery procedure](https://github.com/maziggy/bambuddy/blob/main/UPDATING.md#installed-from-a-github-zip-or-tarball-download) for backup-and-reinstall steps.

**Take a backup first.** `update.sh` takes one automatically; Docker and fully-manual paths do not. Settings → Backup → **Create Backup** produces a full ZIP.

---

**Highlights**

0.2.3 is the largest Bambuddy release yet — the cumulative result of four beta cycles (b1 → b4) and post-b3 work on `dev`. Big-ticket additions since 0.2.2.2: **Two-Factor Authentication and OIDC/SSO**, **LDAP / Active Directory**, **Obico AI print-failure detection**, **optional PostgreSQL database support**, **scheduled local backups**, **X2D printer support**, and the first official **SpoolBuddy** beta — a 7" touchscreen kiosk with NFC tag writing and scale-based spool tracking. The core app gains shortest-job-first queue scheduling, auto-print G-code injection for bed-clearing systems, printer search and filters, direct printing from the project view, firmware rollback, and a lot of polish.

**New Features**

- **Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO (#933)** — Full 2FA implementation with admin UI. Supports TOTP authenticator apps and email-delivered OTP. OIDC/SSO works alongside local accounts and LDAP. Contributed by @netscout2001.
- **Obico AI Print-Failure Detection (#172)** — Self-hosted Obico ML API integration for spaghetti/failure detection across your whole printer fleet. Captures snapshots locally and serves them via nonce URLs so the ML API can reach them without exposing auth tokens.
- **X2D Printer Support (#988, #989)** — Full support for the Bambu Lab X2D including dual-nozzle, camera, K-profile, and maintenance tracking. Seeded by @legend813's #989 (rod-type classification and registry scaffolding) with dual-nozzle / K-profile / `is_h2d` gaps filled in on top. Thanks to @krautech for the report and debug bundle.
- **Firmware List with Rollback (#568)** — Settings → Firmware now lists every announced firmware version with a usable / unavailable indicator and supports rolling back to an older version.
- **Build-Plate Z-Jog Control (#791)** — Jog the build plate directly from the printer card.
- **Airduct Mode + Status Badges on Printer Card** — Airduct mode control and live status badges on each printer card, with a force-refresh button.
- **Collapsible Folders for Printer Filters (#968)** — Collapse / expand folder groups in the printers sidebar. Contributed by @cadtoolbox.
- **China Region for Cloud Token-Based Login (#1013)** — Token-based login now supports Bambu's China region. Contributed by @Minidoracat.
- **Traditional Chinese (zh-TW) Locale (#1017)** — Full zh-TW translation and a sync of 74 missing keys into zh-CN. Contributed by @Minidoracat.
- **Spoolman Link Modal Shows Vendor Name (#958)** — Vendor name displayed alongside filament in the link modal. Contributed by @shrunbr.
- **Auto-Link Existing Accounts Toggle for OIDC (#973)** — Optional setting to auto-link an OIDC login to an existing local account by matching email address. Contributed by @netscout2001.
- **SpoolBuddy Device Control Buttons in Settings Card** — Restart daemon, restart browser, reboot, and shutdown controls for each registered SpoolBuddy device from the Settings card.
- **Expanded Settings Search** — Module-level registry makes Settings search cover every sub-page, not just the top-level nodes.
- **Support Bundle Includes Settings + SpoolBuddy Devices** — Support bundles now contain all settings (with sensitive values redacted) and the list of registered SpoolBuddy devices, so bug reports have more context out of the box.
- **Scheduled Local Backups (#884)** — Settings → Backup now includes a "Scheduled Backups" card that automatically creates complete backup snapshots (database + all data directories) on an hourly, daily, or weekly schedule with configurable time-of-day and retention count. Backups are written as ZIP files to a configurable output directory (defaults to DATA_DIR/backups/), which Docker users can mount as a volume to their NAS or external storage. Each backup in the list can be downloaded, restored directly from the UI, or deleted individually. The manual backup download endpoint has also been optimized to stream directly from disk instead of loading the entire ZIP into memory. Works with both SQLite and PostgreSQL. Fully localized across all 7 UI languages.
- **Optional PostgreSQL Database Support** — Bambuddy can now use an external PostgreSQL database instead of the built-in SQLite. Set the DATABASE_URL environment variable to connect to Postgres. SQLite remains the default. All features work with both backends including full-text archive search, backup/restore, health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling).
- **LDAP Authentication (#794)** — Users can now authenticate against an LDAP / Active Directory server. Configure the LDAP server URL, bind DN, search base, and user filter in Settings → Authentication → LDAP. Supports StartTLS, LDAPS (SSL), and plaintext connections. LDAP groups can be mapped to BamBuddy groups (Administrators, Operators, Viewers) for automatic role assignment. Auto-provisioning creates BamBuddy accounts on first LDAP login. Local admin accounts remain as fallback when the LDAP server is unreachable.
- **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions.
- **Shortest Job First Queue Scheduling (#879)** — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again. Print duration is cached on queue items at creation time from the 3MF metadata.
- **Auto-Print G-code Injection (#422)** — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable "Inject G-code" to have the scheduler inject the configured snippets into the 3MF before uploading. The original file is never modified — injection creates a temporary copy for upload only.
- **Print Files Directly from Project View (#930)** — The project detail page now lists printable files from every linked library folder inline, with Print Now and Add to Queue action buttons on each sliced file. Prints triggered from the project view are automatically associated with the originating project. Contributed by @legend813.
- **Printers Page Search and Filters (#852)** — The Printers page now has a live search bar and two filter dropdowns (status and location). Search matches printer name, model, location, and serial number. The status filter is reactive to WebSocket status updates. Contributed by @legend813.
- **Queue Timeline View (#823)** — Production schedule view showing estimated print completion times, grouped by hour, with live progress bars. Filter by All/Printing/Queued and navigate between days.
- **Staggered Batch Start for Multi-Printer Jobs (#752)** — Stagger print starts across multiple printers to avoid simultaneous bed heating power spikes. Configure group size and interval in the Print/Schedule dialog or set defaults in Settings → Queue.
- **Plate-Clear Confirmation Setting (#752)** — New toggle in Settings → Queue to skip plate-clear confirmation for farm workflows where plates are verified physically.
- **Per-User Statistics Filtering (#730)** — Admins can filter the Statistics page by user to see individual prints, filament usage, and costs.
- **Bulk Printer Actions (#825)** — Select multiple printer cards and apply bulk Stop, Pause, Resume, Clear Notifications, or Clear Bed. Select by state or location.
- **Prefer Lowest Remaining Filament (#805)** — Optional setting to prefer AMS spools with the least remaining filament during auto-matching, helping consume partial spools first.
- **REST/Webhook Smart Plug Type (#472)** — New generic HTTP smart plug type for openHAB, ioBroker, FHEM, Node-RED, etc. Configure ON/OFF URLs, methods, headers, and optional status polling.
- **Configurable Default Print Options (#858)** — Set default print options (bed levelling, flow calibration, vibration calibration, timelapse, etc.) in Settings → Workflow.
- **Batch Print Quantity (#342)** — Print multiple copies of a file in one step with a quantity field in the Print/Schedule dialog.
- **GitHub Backup: Spool Inventory & Print Archives (#870)** — Optional backup of spool inventory and print archive metadata to GitHub.
- **External Folder Subfolder Preservation (#890)** — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root.
- **SpoolBuddy Quick Menu (#893)** — Swipe down from the top of the SpoolBuddy display to open a quick-access control panel. Toggle printer power via smart plugs directly from the display, and manage the system with restart daemon, restart browser, reboot, and shutdown controls.
- **SpoolBuddy Device Management Tab** — Settings → SpoolBuddy now lists every registered device with live connection status, system details, hardware health flags, and an Unregister button. A yellow warning banner flags likely crash-duplicates when more than one device is registered.

**Improved**

- **Default Plate-Clear Confirmation Off on Fresh Installs** — Fresh installs default the Plate-Clear Confirmation setting to off, matching the expectation that farm operators confirm plates physically. Existing installs keep their current preference.
- **i18n Parity Gate Extended to All Locales** — The CI gate that checks translation drift now inspects every locale file (de, fr, it, ja, pt-BR, zh-CN, zh-TW) with a strict / informational tier split, so translation regressions can't sneak in invisibly.
- **SpoolBuddy Auto-Wake on NFC/Scale (#945)** — The kiosk display now wakes automatically when a spool is placed on the scale or an NFC tag is scanned, without requiring a touch first. Thanks to @TravisWilder.
- **SpoolBuddy Kiosk LCD Now Powers Off on Idle (#937)** — The "screen blank timeout" setting now actually powers off the HDMI panel's backlight via swayidle + wlopm instead of just painting a CSS overlay. Thanks to @TravisWilder.
- **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later.
- **AMS Drying Support for H2S (#886)** — Remote AMS drying and queue auto-drying now work on H2S printers with firmware 01.02.00.00 or later.
- **Assign Spool Modal Filtering (#889)** — Improved filtering logic for faster spool selection. Contributed by @Keybored02.
- **SpoolBuddy Spool Detail Card (#866)** — Improved spool detail card UI with better layout and information density. Contributed by @Keybored02.
- **REST Smart Plug: Separate Power/Energy URLs (#472)** — REST smart plugs can now use individual URLs for power and energy data with unit multipliers for conversion.
- **Standardized Webhook Notification Payloads (#871)** — Webhooks now include structured event data fields alongside existing title/message fields for easier automation.
- **Database Engine Info on System Page** — Shows the active database engine (SQLite or PostgreSQL) and its version.
- **Plate Number in Printer View (#881)** — Printer cards now show the plate number alongside the filename for multi-plate 3MF prints.
- **Printer Name in Queue for Model-Based Jobs (#881)** — Queue items assigned to a printer type now show the actual printer name once the scheduler assigns one.
- **Developer Mode Detection for A1/P1 Printers** — Printers without the fun MQTT field now have developer mode detected via a probe command on reconnect.
- **Queue Page Visual Refresh** — Compact stats bar, color-coded left borders, collapsible history section, and condensed history rows.
- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU from ~3.3 to ~0.9 on Raspberry Pi.
- **SpoolBuddy Inventory Page** — New kiosk page with spool grid, search, filter pills, and tap-to-detail view.
- **SpoolBuddy Auto-Navigate on Tag Scan** — Automatically navigates to dashboard and wakes screen when a tag is scanned.
- **SpoolBuddy Swipe to Switch Printers** — Left/right swipe cycles through online printers on the touchscreen.
- **Settings Menu Layout** — Improved settings page menu organization.

**Fixed**

- Virtual Printer Dropping Null-Terminated MQTT Payloads from OrcaSlicer Linux (#927) — OrcaSlicer on Linux appends `\0` to MQTT payloads; the parser silently dropped them. Fixed.
- OIDC Callback Code/State Too Short (#1024) — Raised `code` and `state` max length from 512 to 2048 to match provider payload sizes. Contributed by @netscout2001.
- OIDC Issuer Trailing Slash Mismatch (#995) — Normalised trailing slash on both sides of issuer comparison so Authentik logins succeed. Contributed by @netscout2001.
- OIDC Discovery URL Trailing Slash (#985) — Strip trailing slash from issuer URL before building the discovery URL. Contributed by @netscout2001.
- Archive Reprints Colliding With Originals (#1011) — Unique per-submission IDs prevent reprints from overwriting the original archive entry.
- Obico /p/ Endpoint Incompatibility — Reverted POST-bytes approach; Obico `/p/` is GET-only.
- Obico Snapshot 401 / "Failed to get image" (#172) — The ML API couldn't fetch snapshots from Bambuddy when auth was enabled. Snapshots are now captured locally and served via nonce URLs so the ML API can reach them without a token.
- Obico Snapshot Capture PIDs Swept by Stream Cleanup (#172) — Stream cleanup no longer kills in-flight Obico snapshot captures.
- Obico POST Image Bytes to ML API (#1003) — Adjusted the ML API integration to POST image bytes directly instead of a callback URL.
- MQTT Zombie Session Detection (#887) — Detects dead-but-reconnected MQTT sessions via `ams_filament_setting` response tracking and force-closes them with probe-timeout retries.
- MQTT Self-Heal on Dispatch Timeout (#936) — Half-broken MQTT sessions self-heal instead of hanging indefinitely.
- SD Card Badge Flap on H2D — Multiple fixes: partial pushes no longer flap the badge, heartbeat bursts no longer flip it red, the badge is now hidden entirely when the printer is offline, and the overall SD badge behaviour was reverted to match pre-regression behaviour.
- SD Card and Door Badges Hidden When Printer Offline — Badges are no longer shown on offline printer cards.
- X2D Missing from Add/Edit Printer Dropdowns (#988) — The Add/Edit Printer modal now lists X2D.
- Speed Level Missing from WebSocket Status (#993) — `speed_level` is now forwarded in the websocket status payload so the printer card updates correctly.
- Large 3MF Metadata Lost on FTP Timeout (#972) — Metadata for large 3MF files is now recovered after an FTP timeout instead of being dropped.
- Archive Resume on Subtask ID / Short-Circuit 550 / Cache 3MF (#972) — Archive downloads now resume on `subtask_id`, short-circuit when the FTP server returns 550, and cache the 3MF for reuse.
- Add/Edit Printer Modal Not Scrollable on Short Viewports — Modal is now scrollable when the viewport is too short.
- Library Prints Not Attributed to User — Prints started from the library are now attributed to the authenticated user instead of the service account.
- Build-Plate Gate Bypassed by Auto Off Power Cycle (#961) — Plate-clear gate is now persisted so Auto Off power cycles can't bypass the queue confirmation.
- Stuck Queue Items on Ignored Start Command — The scheduler now reverts a queued item to `pending` when the printer ignores the start command.
- CSP Blocking Sidebar iFrames, Service Worker, and Google Fonts — Relaxed Content-Security-Policy rules so same-origin iframes, the service worker, and Google Fonts load correctly.
- X-Frame-Options Blocking Same-Origin iFrames — Relaxed to `SAMEORIGIN` so same-origin iframes load.
- New-Window Camera View Broken with Auth Enabled (#979) — The camera-in-new-window link now works when authentication is enabled.
- SpoolBuddy Kiosk Unusable on First Boot — Full-mode install now makes the kiosk immediately usable on first boot.
- SpoolBuddy API Key Not Auto-Provisioned — Full-mode install now auto-provisions the kiosk API key.
- AMS `dry_sf_reason` Not Surfaced / Filament Not Backfilled (#971) — Expose dryer state reasons and backfill filament assignments on AMS state changes.
- Toast Callback Fires After Unmount — `setToasts` is now guarded against post-unmount async callbacks.
- Backup File Name — Minor fix to the downloaded backup filename format.
- H2C Nozzle Rack Slot Numbering Off When Slot 1's Nozzle Is Mounted (#943) — Rack slots shifted by one position when slot 1's nozzle was picked up into a hotend. The rack base is now hardcoded to match the fixed H2C rack ID layout. Thanks to @netscout2001.
- Energy Snapshot Capture Crashes on PostgreSQL — The hourly energy snapshot loop failed on PostgreSQL because the tz-stripping hook didn't handle nested parameters from insertmanyvalues. Now recursively strips tzinfo at any depth.
- Wrong Filament Color Name on AMS Popup (#857) — Colors outside a hardcoded list showed wrong names. Rewrote the resolver to use the color_catalog table as the single source of truth. Thanks to @lightmaster.
- LDAP Auto-Provisioning Fails on Upgraded SQLite Installs (#794) — First LDAP login on an upgraded SQLite install hit a NOT NULL constraint. Migration now patches sqlite_master directly. Thanks to @DylanBrass.
- Energy Statistics Empty for Date Ranges in Total Consumption Mode (#941) — Added persisted energy start column and hourly snapshot loop for accurate date-range totals. Per-print energy tracking is now restart-resilient. Thanks to @TheMadMike23.
- Virtual Printer "Synchronizing device information" Times Out in Orca (#927) — MQTT command handling silently dropped requests when the slicer's cached serial didn't match. Both directions are now serial-adaptive.
- External Sidebar Link Icon Not Showing (#878) — Custom icons returned 401 because the sidebar img tag didn't use a stream token.
- SJF Toggle Disappears After Clicking (#879) — The toggle was inside the Pending section header which unmounted when the last pending item started. Moved to the page header.
- Project Breadcrumb Shows i18n Key (#931) — Breadcrumb showed the raw translation key instead of translated text. Contributed by @legend813.
- SpoolBuddy Update Fails in Docker — Multiple fixes for ssh failures under arbitrary UIDs. Entire update path is now subprocess-free (uses cryptography + asyncssh). Docker image now bakes .git/HEAD for correct branch detection.
- Camera Stream Reconnect Counter Off-by-One + ffmpeg Log Flood (#925) — Counter could show "6 of 5", and failed ffmpeg spawns logged the full banner.
- LDAP POSIX Primary Group Ignored — Users whose role came from their POSIX primary group landed without permissions.
- Support Bundle Leaks Virtual Printer IP Address — Added `_ip` to the sensitive key filter for redaction.
- "Build Plate Cleared" Button Unclickable After Second Print (#912) — React Query mutation state from the first confirmation persisted, blocking subsequent clicks.
- Spoolman Location Not Cleared on Spool Removal from AMS (#921) — Auto-sync set locations for new spools but never cleared stale ones, causing double-booked slots.
- Spool Weight Not Updated After Print (#839) — Filament usage tracking failed silently in five scenarios: fallback archives without 3MF, external/VT tray spools, notifications showing "Unknown", auto-archive disabled, and queue/reprint prints with auto-archive disabled. All five paths are now fixed.
- Ghost Jobs From SQLite Lock on Print Completion (#897) — Queue status update could fail silently on SQLite lock, leaving jobs permanently stuck in "printing". Now retries with backoff.
- Multi-Plug Automation Only Working for First Plug (#903) — When multiple smart plugs were assigned to a printer, only the first was automated. All automation paths now control every assigned plug.
- SpoolBuddy Inventory Not Updating on Spool Changes (#905) — Spool CRUD endpoints now broadcast websocket events for instant updates.
- AMS Slot Changes Fail Until Reconnect (#887) — After a keep-alive timeout, paho-mqtt auto-reconnects but silently ignores commands. Added probe timeout with retry and force-close.
- Spool Manager Deducts Double Filament (#880) — Two independent deduction paths ran in the same event loop cycle. AMS weight sync now skips updates while a print session is active.
- File Manager Stale UI After Deleting Folders/Files — Delete endpoints relied on auto-commit after response. Added explicit commit before returning.
- Thumbnails Broken After Backend Restart — Stream tokens lost on restart. Frontend now auto-refreshes failed token-protected image loads.
- SpoolBuddy Kiosk Screen Blanks on Boot — Added consoleblank=0 to kernel cmdline and immediate anti-blank loop.
- Queue Widget Ignores Plate-Clear Setting (#752) — Button showed even when plate-clear confirmation was disabled.
- WebSocket Crash on Printers Without fun Field (#873) — Race condition in developer mode probe caused repeating crashes on A1, P1, X1Plus.
- Filament Hover Card Behind Sidebar (#900) — Hover card z-index conflicted with mobile sidebar.
- Docker Install Script (#915) — Removed deprecated `--bind` flag from `docker_install.sh`.
- Bed Cooled Notification Never Firing (#872) — Replaced polling-based monitor with event-driven approach.
- Filament Color and Subtype Inconsistencies (#857) — Fixed generic color names, missing Silk+/Tough+ subtypes, and misclassified gradient/dual-color filaments.
- External Spool Print Fails on Printers With AMS (#854, #859) — Fixed use_ams flag and ams_id mapping.
- Wrong Filament Mapping (#851) — Contributed by @behrinml.
- External Folder Scan 500 on 3MF Files (#846) — Crash from raw thumbnail bytes in JSON serialization.
- Archives Capped at 50 Items (#843) — Removed hardcoded limit, added pagination.
- Filament Usage Not Recorded When Auto-Archive Disabled — Tracking now runs before the archive check.
- AMS History Cleanup Crash — Fixed naive/aware datetime mismatch.
- SpoolBuddy NFC Write Fails on NTAG Tags — Multiple PN5180 state machine and CRC fixes for NTAG 213/215/216.
- SpoolBuddy Scale First Reading Always Wrong — NAU7802 ADC stale first reading polluted the moving average.
- Print Fails on Files With Spaces in Name (#824) — Spaces in MQTT url field caused firmware to ignore the print command.
- Virtual Printer Proxy A1 Printing Fails (#757) — Ports 2024-2026 weren't proxied.
- H2D External Spool Print Fails (#797) — "Failed to get AMS mapping table" on H2D external spool prints.
- Spool Assignment on Empty AMS Slots (#784) — Assigning spools to truly empty slots created a stuck state.
- Log Flood: "State is FINISH but completion NOT triggered" (#790) — Diagnostic message fired on every MQTT update in terminal state.
- ffmpeg Process Leak Causing Memory Growth (#776) — Camera processes accumulated over time, consuming GB of RAM.
- Database Connection Pool Exhaustion on Large Farms — Increased pool from 30 to 220 connections.
- Sidebar Bottom Icons Cut Off With Smart Plugs (#862) — Fixed footer overflow.
- Native Install Misdetected as Docker in LXC Containers — Fixed detection logic.
- P1S/P1P Printer Card Showing "Printing" When Idle (#813) — Stale MQTT connection not triggering reconnect.
- MQTT Connected/Disconnected UI Bouncing — Stale reconnect timer.
- Rapid MQTT Disconnect/Reconnect Bouncing (#813) — Fixed rapid reconnect cycling.
- SpoolBuddy Various Kiosk Fixes — Virtual keyboard layout, boot splash, settings layout, read tag diagnostics, assign spool modal clipping, low filament warning slot number, status bar updates, dashboard crash on null fields.

**Security**

- Webhook Tokens Leaked in httpx Debug Logs — Debug logging could write webhook tokens to disk. Filtered so secrets don't hit the log.
- Path Traversal in File Upload Endpoints — Archive upload endpoints used client-supplied filenames directly without stripping directory components. All upload endpoints now sanitize filenames. Reported by Sacha Vaudey.
- Unauthenticated Bug Report Endpoints — Bug report endpoints had no authentication. Now require appropriate permissions. Reported by Sacha Vaudey.
- API Key Empty Printer List Grants Full Access — An API key with `[]` was treated as global access. Now `null` = global, `[]` = no access. Reported by Sacha Vaudey.
- Missing HTTP Security Headers — Added X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. Reported by Sacha Vaudey.
- Camera Snapshot Temp Files World-Readable — Switched to mkstemp with 0600 permissions. Reported by Sacha Vaudey.
- Token-Based Auth for Media Endpoints — Camera streams, snapshots, thumbnails, and timelapse videos now require a stream token when auth is enabled.
- Dependency updates — Pillow 12.1.1 → 12.2.0 (CVE-2026-40192), pytest 9.0.2 → 9.0.3 (CVE-2025-71176), python-multipart 0.0.22 → 0.0.26, dompurify 3.3.3 → 3.4.0, vite 7.3.1 → 7.3.2 (#909), plus aiohttp, cryptography, and Pygments CVE fixes.

**Contributors**

Thank you to the contributors who helped make this release possible:

- **@netscout2001** — Two-Factor Authentication + OIDC/SSO implementation, OIDC callback length, OIDC issuer trailing-slash fixes (×2), auto-link existing accounts toggle, H2C nozzle rack slot report (#933, #973, #985, #995, #1024, #943)
- **@legend813** — Printer search filters, project view printing, project breadcrumb i18n fix, X2D seed PR (#852, #920, #930, #931, #932, #989)
- **@Keybored02** — Spool detail card, assign modal filtering, SpoolBuddy init improvements, missing spool notifications, mid-print reassignment (#866, #889, #787, #789, #814)
- **@Minidoracat** — China region for cloud token-based login, traditional Chinese (zh-TW) locale, zh-CN sync (#1013, #1017, #1025)
- **@cadtoolbox** — Collapsible folders for printer filters (#968)
- **@shrunbr** — Vendor name in Spoolman Link Modal (#958)
- **@TheMadMike23** — Energy statistics date-range report (#941)
- **@behrinml** — Filament mapping fix (#851)
- **Sacha Vaudey** — Responsible disclosure of five security vulnerabilities
MartinNYHC il y a 1 mois
Parent
commit
361041d0c4
100 fichiers modifiés avec 11374 ajouts et 2850 suppressions
  1. 16 0
      .codeql/codeql-config.yml
  2. 8 1
      .dockerignore
  3. 21 4
      .github/ISSUE_TEMPLATE/bug_report.yml
  4. 8 0
      .github/ISSUE_TEMPLATE/feature_request.yml
  5. 5 3
      .gitignore
  6. 25 0
      CHANGELOG.md
  7. 20 0
      Dockerfile
  8. 42 15
      README.md
  9. 100 0
      UPDATING.md
  10. 272 89
      backend/app/api/routes/archives.py
  11. 641 79
      backend/app/api/routes/auth.py
  12. 15 4
      backend/app/api/routes/bug_report.py
  13. 70 10
      backend/app/api/routes/camera.py
  14. 135 104
      backend/app/api/routes/cloud.py
  15. 3 2
      backend/app/api/routes/external_links.py
  16. 19 3
      backend/app/api/routes/firmware.py
  17. 6 0
      backend/app/api/routes/github_backup.py
  18. 80 30
      backend/app/api/routes/inventory.py
  19. 3 2
      backend/app/api/routes/kprofiles.py
  20. 169 22
      backend/app/api/routes/library.py
  21. 104 0
      backend/app/api/routes/local_backup.py
  22. 1693 0
      backend/app/api/routes/mfa.py
  23. 69 0
      backend/app/api/routes/obico.py
  24. 3 2
      backend/app/api/routes/print_log.py
  25. 218 32
      backend/app/api/routes/print_queue.py
  26. 215 40
      backend/app/api/routes/printers.py
  27. 325 65
      backend/app/api/routes/settings.py
  28. 15 0
      backend/app/api/routes/smart_plugs.py
  29. 75 0
      backend/app/api/routes/spoolbuddy.py
  30. 6 2
      backend/app/api/routes/spoolman.py
  31. 90 33
      backend/app/api/routes/support.py
  32. 38 3
      backend/app/api/routes/system.py
  33. 10 2
      backend/app/api/routes/updates.py
  34. 59 5
      backend/app/api/routes/users.py
  35. 1 1
      backend/app/api/routes/webhook.py
  36. 128 0
      backend/app/cli.py
  37. 296 44
      backend/app/core/auth.py
  38. 0 317
      backend/app/core/bambu_colors.py
  39. 7 4
      backend/app/core/config.py
  40. 838 1081
      backend/app/core/database.py
  41. 48 0
      backend/app/core/db_dialect.py
  42. 88 0
      backend/app/core/encryption.py
  43. 2 0
      backend/app/core/permissions.py
  44. 463 267
      backend/app/main.py
  45. 14 0
      backend/app/models/__init__.py
  46. 2 2
      backend/app/models/api_key.py
  47. 10 1
      backend/app/models/archive.py
  48. 199 0
      backend/app/models/auth_ephemeral.py
  49. 2 0
      backend/app/models/github_backup.py
  50. 93 0
      backend/app/models/oidc_provider.py
  51. 45 0
      backend/app/models/print_batch.py
  52. 10 0
      backend/app/models/print_queue.py
  53. 3 0
      backend/app/models/printer.py
  54. 25 3
      backend/app/models/smart_plug.py
  55. 22 0
      backend/app/models/smart_plug_energy_snapshot.py
  56. 8 1
      backend/app/models/user.py
  57. 55 0
      backend/app/models/user_otp_code.py
  58. 84 0
      backend/app/models/user_totp.py
  59. 3 1
      backend/app/models/virtual_printer.py
  60. 4 0
      backend/app/schemas/archive.py
  61. 345 16
      backend/app/schemas/auth.py
  62. 8 1
      backend/app/schemas/cloud.py
  63. 6 0
      backend/app/schemas/github_backup.py
  64. 2 0
      backend/app/schemas/library.py
  65. 2 2
      backend/app/schemas/notification_template.py
  66. 43 0
      backend/app/schemas/print_queue.py
  67. 4 2
      backend/app/schemas/printer.py
  68. 184 0
      backend/app/schemas/settings.py
  69. 53 2
      backend/app/schemas/smart_plug.py
  70. 4 0
      backend/app/schemas/spoolbuddy.py
  71. 24 7
      backend/app/services/archive.py
  72. 14 0
      backend/app/services/background_dispatch.py
  73. 41 16
      backend/app/services/bambu_cloud.py
  74. 148 26
      backend/app/services/bambu_ftp.py
  75. 309 32
      backend/app/services/bambu_mqtt.py
  76. 20 8
      backend/app/services/camera.py
  77. 69 12
      backend/app/services/email_service.py
  78. 3 0
      backend/app/services/export.py
  79. 6 0
      backend/app/services/failure_analysis.py
  80. 167 41
      backend/app/services/firmware_check.py
  81. 24 9
      backend/app/services/firmware_update.py
  82. 148 11
      backend/app/services/github_backup.py
  83. 293 0
      backend/app/services/ldap_service.py
  84. 270 0
      backend/app/services/local_backup.py
  85. 146 25
      backend/app/services/notification_service.py
  86. 83 0
      backend/app/services/obico_actions.py
  87. 327 0
      backend/app/services/obico_detection.py
  88. 105 0
      backend/app/services/obico_smoothing.py
  89. 5 2
      backend/app/services/plate_detection.py
  90. 248 42
      backend/app/services/print_scheduler.py
  91. 80 18
      backend/app/services/printer_manager.py
  92. 274 0
      backend/app/services/rest_smart_plug.py
  93. 162 69
      backend/app/services/smart_plug_manager.py
  94. 46 15
      backend/app/services/spool_tag_matcher.py
  95. 103 62
      backend/app/services/spoolbuddy_ssh.py
  96. 321 91
      backend/app/services/usage_tracker.py
  97. 4 0
      backend/app/services/virtual_printer/manager.py
  98. 149 65
      backend/app/services/virtual_printer/mqtt_server.py
  99. 9 2
      backend/app/utils/printer_models.py
  100. 97 0
      backend/app/utils/threemf_tools.py

+ 16 - 0
.codeql/codeql-config.yml

@@ -75,6 +75,22 @@ query-filters:
   - exclude:
       id: py/catch-base-exception
 
+  # LDAP injection: All user input is RFC 4515 escaped via
+  # _ldap_escape() (ldap_service.py:282) before interpolation
+  # into search filters. CodeQL does not trace through the
+  # escape replace-loop and reports false positives on lines
+  # 131 / 183 / 198 where escaped values are reused.
+  - exclude:
+      id: py/ldap-injection
+
+  # Incomplete URL substring sanitization: Only triggers in
+  # test assertions (test_cloud_auth.py) that verify the
+  # mocked HTTP client saw the right hostname
+  # (e.g. `"api.bambulab.cn" in captured_url`). URLs come
+  # from a mock's captured_urls list, not user input.
+  - exclude:
+      id: py/incomplete-url-substring-sanitization
+
   # ── JavaScript Accepted Risk ─────────────────────────────────
 
   # XSS through DOM: False positives —

+ 8 - 1
.dockerignore

@@ -1,5 +1,12 @@
 # Git
-.git
+# Exclude all .git contents EXCEPT HEAD. HEAD is a tiny text file (under
+# 100 bytes) containing e.g. `ref: refs/heads/dev`, which the Dockerfile
+# copies into the image so detect_current_branch() in spoolbuddy_ssh.py
+# can report the correct branch for SpoolBuddy remote updates. Without
+# this, the production image has no git metadata at all and always falls
+# back to "main" regardless of which branch the operator built from.
+.git/*
+!.git/HEAD
 .gitignore
 
 # Python

+ 21 - 4
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -8,6 +8,18 @@ body:
       value: |
         Thanks for taking the time to report a bug! Please fill out the form below.
 
+  - type: dropdown
+    id: component
+    attributes:
+      label: Component
+      description: Which part of the project is affected?
+      options:
+        - Bambuddy
+        - SpoolBuddy
+        - Both
+    validations:
+      required: true
+
   - type: textarea
     id: description
     attributes:
@@ -59,7 +71,7 @@ body:
         - Multiple printers
         - Not printer-related
     validations:
-      required: true
+      required: false
 
   - type: input
     id: version
@@ -70,14 +82,19 @@ body:
     validations:
       required: true
 
+  - type: input
+    id: spoolbuddy_version
+    attributes:
+      label: SpoolBuddy Version
+      description: If SpoolBuddy-related, which version is running? (Check SpoolBuddy Settings → Updates)
+      placeholder: e.g., 0.1.0
+
   - type: input
     id: firmware
     attributes:
       label: Printer Firmware Version
-      description: Which firmware version is your printer running? (Check printer screen or Bambu Handy app)
+      description: Which firmware version is your printer running? (Check printer screen or Bambu Handy app). Leave blank if not printer-related.
       placeholder: e.g., 01.08.00.00
-    validations:
-      required: true
 
   - type: dropdown
     id: installation

+ 8 - 0
.github/ISSUE_TEMPLATE/feature_request.yml

@@ -44,11 +44,19 @@ body:
         - Print Queue & Scheduling
         - Smart Plugs
         - Notifications
+        - Spool Inventory
         - Spoolman Integration
         - Cloud Profiles
         - K-Profiles
         - Maintenance Tracking
         - File Manager
+        - SpoolBuddy - Dashboard
+        - SpoolBuddy - AMS Management
+        - SpoolBuddy - NFC / Tag Writing
+        - SpoolBuddy - Scale / Calibration
+        - SpoolBuddy - Inventory
+        - SpoolBuddy - Settings / Updates
+        - SpoolBuddy - Hardware / Installation
         - UI/UX
         - API
         - Other

+ 5 - 3
.gitignore

@@ -16,6 +16,7 @@ venv/
 .venv/
 env/
 .env
+docker-compose.override.yml
 *.egg-info/
 dist/
 build/
@@ -71,6 +72,7 @@ spoolbuddy/ssh/
 *.sarif
 
 debug_logs/
-
-# SSH keys Spoolbuddy
-spoolbuddy/ssh/
+db_backup/
+support-packages/
+backups/
+bin/

Fichier diff supprimé car celui-ci est trop grand
+ 25 - 0
CHANGELOG.md


+ 20 - 0
Dockerfile

@@ -41,6 +41,15 @@ RUN --mount=type=cache,target=/root/.cache/pip \
 # Copy backend
 COPY backend/ ./backend/
 
+# Capture the current git branch at build time. `.git/HEAD` is the only
+# .git metadata the build context lets through (see .dockerignore); it
+# contains `ref: refs/heads/<branch>`, which the SpoolBuddy remote-update
+# flow reads at runtime via detect_current_branch() in spoolbuddy_ssh.py.
+# Without this, the production image has no git metadata at all and would
+# always pull `main` on the remote device regardless of which branch
+# Bambuddy itself was built from.
+COPY .git/HEAD ./.git/HEAD
+
 # Copy built frontend from builder stage
 COPY --from=frontend-builder /app/static ./static
 
@@ -53,6 +62,17 @@ ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
 ENV PORT=8000
+# Provide a local username + home for tools that call getpass.getuser() /
+# os.path.expanduser() under arbitrary PUIDs. With `user: "1001:1001"` the
+# stock python:3.13-slim image has no /etc/passwd entry for that UID, so
+# pwd.getpwuid() raises and breaks libraries that do host-level user lookups
+# (notably asyncssh, which uses the local username for ~/.ssh/config host
+# matching during the SpoolBuddy remote-update flow). Setting LOGNAME/USER
+# makes getpass.getuser() resolve via env vars instead of the passwd db;
+# HOME=/app gives a writable home that is guaranteed to exist.
+ENV HOME=/app
+ENV USER=bambuddy
+ENV LOGNAME=bambuddy
 
 EXPOSE 322
 EXPOSE 990

+ 42 - 15
README.md

@@ -17,6 +17,7 @@
   <a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
   <a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
   <a href="https://discord.gg/aFS3ZfScHM"><img src="https://img.shields.io/discord/1461241694715645994?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2" alt="Discord"></a>
+  <a href="https://forum.bambuddy.cool"><img src="https://img.shields.io/badge/Forum-bambuddy.cool-00adef?style=flat-square&logo=discourse&logoColor=white" alt="Forum"></a>
   <a href="https://ko-fi.com/maziggy"><img src="https://img.shields.io/badge/Ko--fi-Support-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" target=_blank></a>
 </p>
 
@@ -25,6 +26,7 @@
   <a href="#-screenshots">Screenshots</a> •
   <a href="#-quick-start">Quick Start</a> •
   <a href="http://wiki.bambuddy.cool">Documentation</a> •
+  <a href="https://forum.bambuddy.cool">Forum</a> •
   <a href="https://discord.gg/aFS3ZfScHM">Discord</a> •
   <a href="#-contributing">Contributing</a>
 </p>
@@ -36,12 +38,13 @@
 Bambuddy is a community-driven project and I'm **actively looking for contributors** — especially for two areas I can't cover alone:
 
 - 📝 **Documentation writers** — help improve the wiki, guides, and feature docs so new users have a smooth onboarding
-- ⚙️ **Discourse admin** — we already have a **Discourse** instance running but it still needs to be configured, themed, and tuned (categories, permissions, SSO, email, plugins, backups). If you know Discourse or want to dig in, I'd love your help.
-- 💬 **Forum moderators** — once the forum opens, we need people to welcome newcomers, answer questions, and keep discussions healthy
+- ⚙️ **Discourse admin** — our **Discourse forum** is now live at [forum.bambuddy.cool](https://forum.bambuddy.cool) but still needs to be configured, themed, and tuned (categories, permissions, SSO, email, plugins, backups). If you know Discourse or want to dig in, I'd love your help.
+- 💬 **Forum moderators** — help welcome newcomers, answer questions, and keep discussions healthy on the new forum
 
 You don't need to be a developer for the docs or moderator roles. If you enjoy writing, helping others, or keeping a community friendly, you're exactly who we're looking for.
 
 **Get in touch:**
+- 🗣️ [Forum](https://forum.bambuddy.cool) — chats, longer discussions, guides, and community Q&A
 - 💬 [Discord](https://discord.gg/aFS3ZfScHM) — fastest way to chat
 - 🐙 [GitHub Discussions](https://github.com/maziggy/bambuddy/discussions) — open a thread
 - 📧 **martin@bambuddy.cool** — email Martin directly (no GitHub or Discord needed)
@@ -102,7 +105,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume, chamber light, print speed)
+- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **build-plate Z-jog** with Studio-style not-homed warning)
+- **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
+- **Force Refresh** menu item — request a full status push from the printer without reconnecting
+- Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
+- Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
@@ -117,22 +124,32 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Print success rates & trends
 - Filament usage tracking
 - Cost analytics & failure analysis
+- **AI print-failure detection** — Optional integration with a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API: watches each running print's camera feed, smooths scores over time (30-frame warmup + EWM + rolling means), and fires a configurable action once per print (notify / pause / pause-and-off)
+- Per-user statistics filtering (admin permission gated)
 - CSV/Excel export
 
 ### ⏰ Scheduling & Automation
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
-- Print queue with drag-and-drop
+- Print queue with drag-and-drop and timeline schedule view
 - Multi-printer selection (send to multiple printers at once)
+- Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued)
+- Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
+- Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament override for model-based queue (swap filament colors/types before scheduling)
 - Filament validation (only assign to printers with required filaments)
+- Prefer lowest remaining filament (consume partial spools first when multiple match)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
+- Shortest Job First scheduling (SJF toggle on queue page — scheduler picks shorter prints first, with starvation guard)
 - Queue Only mode (stage without auto-start)
-- Clear plate confirmation between queued prints
-- Smart plug integration (Tasmota, Home Assistant, MQTT)
+- Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
+- Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)
+- Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
+- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
-- Energy consumption tracking (per-print kWh and cost)
+- Energy consumption tracking (per-print kWh and cost) — restart-resilient: mid-print backend restarts no longer lose per-print energy
+- Energy statistics by date range (Today / Week / Month / …) in total-consumption mode via hourly lifetime-counter snapshots
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print
 - Auto power-off after cooldown
@@ -158,6 +175,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
 - Import/Export projects as ZIP (includes files) or JSON
+- Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project)
 
 </td>
 <td width="50%" valign="top">
@@ -195,6 +213,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - K-profiles (pressure advance)
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
+- **Scheduled local backups** - Automatic backup snapshots on hourly/daily/weekly schedule with retention management and NAS-mountable output
 - External sidebar links
 - Webhooks & API keys
 - Interactive API browser with live testing
@@ -214,7 +233,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Interval reminders (hours/days)
 - Print time accuracy stats
 - File manager for printer storage
-- Firmware update helper with version badge (LAN-only printers)
+- Firmware update helper with version badge (LAN-only printers) — lists all announced versions with Usable/Unavailable/Installed badges and supports rollback to older firmware
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
@@ -233,6 +252,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Admin creates users with email — system sends secure random password automatically
 - Users can reset their own password from the login screen (no admin needed)
 - Customizable email templates (welcome email, password reset)
+- **Two-Factor Authentication (TOTP + Email OTP)** — Per-user opt-in 2FA compatible with Google Authenticator, Authy, 2FAS and any standard TOTP app, or a 6-digit code delivered by email. Each user gets 10 single-use backup codes. Brute-force-protected (per-user + per-IP rate limits), replay-protected (same code cannot be accepted twice in the same 30 s window), and the pre-auth token is a single-use DB-backed challenge bound to the browser session via an HttpOnly cookie.
+- **Single Sign-On (OIDC / SSO)** — Log in via PocketID, Authentik, Keycloak, or any standards-compliant OIDC provider. PKCE (S256) for public clients, `email_verified` gating, issuer & `aud`/`nonce` validation, opt-in account linking via verified email, optional auto-provisioning of new BamBuddy accounts, and strict SSRF hardening on every URL pulled from the OIDC discovery document (scheme + private/loopback/link-local IP checks).
 - **Per-user email notifications** — Users receive email alerts for their own print jobs (start, complete, failed, stopped) with individual toggle controls
 
 </td>
@@ -433,7 +454,7 @@ Open **http://localhost:8000** in your browser.
 
 | Volume | Purpose |
 |--------|---------|
-| `bambuddy.db` | SQLite database with all your print data |
+| `bambuddy.db` | SQLite database with all your print data (not used with PostgreSQL) |
 | `archive/` | Archived 3MF files and thumbnails |
 | `logs/` | Application logs |
 
@@ -579,6 +600,7 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
 | Series | Models |
 |--------|--------|
 | X1 | X1, X1 Carbon, X1E |
+| X2 | X2D |
 | H2 | H2D, H2D Pro, H2C, H2S |
 | P1 | P1P, P1S |
 | P2 | P2S |
@@ -592,7 +614,7 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
 |-----------|------------|
 | Backend | Python, FastAPI, SQLAlchemy |
 | Frontend | React, TypeScript, Tailwind CSS |
-| Database | SQLite |
+| Database | SQLite (default) or PostgreSQL |
 | 3D Viewer | Three.js |
 | Communication | MQTT (TLS), FTPS |
 
@@ -600,12 +622,16 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
 
 ## 🤝 Contributing
 
-Contributions welcome! Here's how to help:
+Contributions welcome! **I'm especially looking for help with documentation and our new [Discourse forum](https://forum.bambuddy.cool)** — see [Contributors Wanted](#-contributors-wanted--help-shape-bambuddy) above. Other ways to help:
 
-1. **Test** — Report issues with your printer model
-2. **Translate** — Add new languages
-3. **Code** — Submit PRs for bugs or features
-4. **Document** — Improve wiki and guides
+1. **📝 Document** — Improve the wiki and guides *(urgently needed!)*
+2. **⚙️ Admin Discourse** — Help configure and tune the [forum](https://forum.bambuddy.cool) *(urgently needed!)*
+3. **💬 Moderate** — Welcome newcomers and keep [forum](https://forum.bambuddy.cool) discussions healthy *(urgently needed!)*
+4. **Test** — Report issues with your printer model
+5. **Translate** — Add new languages
+6. **Code** — Submit PRs for bugs or features
+
+Not sure where to start? Reach out on [Discord](https://discord.gg/aFS3ZfScHM), post on the [forum](https://forum.bambuddy.cool), or email **martin@bambuddy.cool** — I'll help you find something that fits.
 
 ```bash
 # Development setup
@@ -647,6 +673,7 @@ If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/
 <p align="center">
   Made with ❤️ for the 3D printing community
   <br><br>
+  <a href="https://forum.bambuddy.cool">Forum</a> •
   <a href="https://discord.gg/aFS3ZfScHM">Join our Discord</a> •
   <a href="https://github.com/maziggy/bambuddy/issues">Report Bug</a> •
   <a href="https://github.com/maziggy/bambuddy/issues">Request Feature</a> •

+ 100 - 0
UPDATING.md

@@ -0,0 +1,100 @@
+# Updating Bambuddy
+
+> **One-time note for 0.2.2.x → 0.2.3:** the in-app **Update** button does not
+> reliably perform this specific migration. Do this one upgrade from the
+> command line using the steps below. Once you're on 0.2.3, the in-app Update
+> button works normally again for all future releases.
+
+Pick the section that matches how Bambuddy was installed.
+
+---
+
+## Docker
+
+```bash
+# 1. Make sure your compose file isn't pinned to an old version.
+#    The image line should read one of:
+#      image: ghcr.io/maziggy/bambuddy:latest
+#      image: ghcr.io/maziggy/bambuddy:0.2.3
+#    If it pins an older tag (e.g. :0.2.2.2), edit it first.
+
+# 2. Pull and restart
+docker compose pull
+docker compose up -d
+```
+
+**If your `docker-compose.yml` is older than 0.2.3,** also refresh it from the
+repo — recent releases added `cap_add: NET_BIND_SERVICE`, extra virtual-printer
+ports for bridge mode, and an optional Postgres block:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml \
+  -o docker-compose.yml.new
+# Diff against yours, merge by hand, then:
+docker compose up -d
+```
+
+---
+
+## Native install (`install.sh` or manual `git clone`)
+
+Both paths produce a git working tree at the install directory, so the update
+is the same. Preferred:
+
+```bash
+sudo /opt/bambuddy/install/update.sh
+```
+
+`update.sh` stops the service, snapshots the database via the built-in backup
+API, fast-forwards to `origin/main`, installs Python deps, rebuilds the
+frontend, and restarts the service. It rolls back automatically if any step
+fails.
+
+### Manual equivalent
+
+If you'd rather run the steps yourself:
+
+```bash
+cd /opt/bambuddy
+sudo systemctl stop bambuddy
+sudo -u bambuddy git fetch origin
+sudo -u bambuddy git reset --hard origin/main
+sudo -u bambuddy venv/bin/pip install -r requirements.txt
+sudo systemctl start bambuddy
+```
+
+Replace `/opt/bambuddy` with your install path if different. Database schema
+migrations run automatically on startup — no Alembic step is required.
+
+---
+
+## Installed from a GitHub ZIP or tarball download
+
+These installs have no `.git` directory, so neither `update.sh` nor a plain
+`git pull` will work. Reinstall cleanly:
+
+```bash
+# 1. Back up your stateful data
+sudo systemctl stop bambuddy
+sudo tar czf ~/bambuddy-backup.tgz -C /opt/bambuddy \
+  data bambuddy.db bambuddy.db-shm bambuddy.db-wal \
+  virtual_printer archive projects icons .env 2>/dev/null || true
+
+# 2. Remove the old install and reinstall via install.sh
+sudo rm -rf /opt/bambuddy
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh \
+  -o /tmp/install.sh && sudo bash /tmp/install.sh --path /opt/bambuddy
+
+# 3. Restore your data
+sudo systemctl stop bambuddy
+sudo tar xzf ~/bambuddy-backup.tgz -C /opt/bambuddy
+sudo systemctl start bambuddy
+```
+
+---
+
+## Before you upgrade
+
+Take a backup. Settings → Backup → **Create Backup** downloads a ZIP containing
+the database and all stateful directories. Any bare-metal update via
+`update.sh` does this automatically; Docker and manual upgrades do not.

+ 272 - 89
backend/app/api/routes/archives.py

@@ -13,6 +13,7 @@ from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     require_ownership_permission,
 )
@@ -32,6 +33,34 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/archives", tags=["archives"])
 
 
+def _safe_filename(filename: str) -> str:
+    """Extract basename from a client-supplied filename, preventing path traversal.
+
+    Normalizes backslashes (Windows paths) before extracting so that
+    '..\\\\..\\\\evil.3mf' is correctly stripped to 'evil.3mf' on Linux.
+    """
+    return Path(filename.replace("\\", "/")).name
+
+
+def _validate_user_filter_permission(current_user: User | None, created_by_id: int | None):
+    """Raise 403 if created_by_id filter is used without stats:filter_by_user permission."""
+    if created_by_id is None or current_user is None:
+        return
+    if current_user.is_admin:
+        return
+    if not current_user.has_permission(Permission.STATS_FILTER_BY_USER.value):
+        raise HTTPException(status_code=403, detail="Permission stats:filter_by_user required")
+
+
+def _apply_user_filter(conditions: list, created_by_id: int | None):
+    """Append created_by_id filter to conditions list if specified."""
+    if created_by_id is not None:
+        if created_by_id == -1:
+            conditions.append(PrintArchive.created_by_id.is_(None))
+        else:
+            conditions.append(PrintArchive.created_by_id == created_by_id)
+
+
 def compute_time_accuracy(archive: PrintArchive) -> dict:
     """Compute actual print time and accuracy for an archive.
 
@@ -246,16 +275,18 @@ async def list_archives(
 async def list_archives_slim(
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     limit: int = Query(default=10000, le=50000),
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Lightweight archive listing for stats/dashboard widgets.
 
     Returns only the fields needed for client-side aggregation,
     skipping duplicate detection, file paths, and extra_data.
     """
+    _validate_user_filter_permission(current_user, created_by_id)
     filters = []
     if date_from:
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
@@ -263,6 +294,7 @@ async def list_archives_slim(
     if date_to:
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         filters.append(PrintArchive.created_at <= dt_to)
+    _apply_user_filter(filters, created_by_id)
 
     query = (
         select(
@@ -333,19 +365,37 @@ async def search_archives(
     from sqlalchemy import text
     from sqlalchemy.orm import selectinload
 
-    # Prepare search query - add wildcard for partial matches
+    from backend.app.core.db_dialect import is_sqlite
+
     search_term = q.strip()
-    if not search_term.endswith("*"):
-        search_term = f"{search_term}*"
-
-    # Build the FTS query
-    # Using MATCH for FTS5 full-text search
-    fts_query = text("""
-        SELECT rowid FROM archive_fts
-        WHERE archive_fts MATCH :search_term
-        ORDER BY rank
-        LIMIT :limit OFFSET :offset
-    """)
+
+    # Build dialect-specific full-text search query
+    if is_sqlite():
+        # SQLite FTS5: wildcard suffix for partial matches
+        if not search_term.endswith("*"):
+            search_term = f"{search_term}*"
+        fts_query = text("""
+            SELECT rowid FROM archive_fts
+            WHERE archive_fts MATCH :search_term
+            ORDER BY rank
+            LIMIT :limit OFFSET :offset
+        """)
+    else:
+        # PostgreSQL: tsvector + plainto_tsquery with prefix matching
+        fts_query = text("""
+            SELECT id FROM print_archives
+            WHERE to_tsvector('simple',
+                COALESCE(print_name, '') || ' ' ||
+                COALESCE(filename, '') || ' ' ||
+                COALESCE(tags, '') || ' ' ||
+                COALESCE(notes, '') || ' ' ||
+                COALESCE(designer, '') || ' ' ||
+                COALESCE(filament_type, '')
+            ) @@ to_tsquery('simple', :search_term)
+            LIMIT :limit OFFSET :offset
+        """)
+        # Convert "benchy" to "benchy:*" for prefix matching in tsquery
+        search_term = " & ".join(f"{word}:*" for word in search_term.split() if word)
 
     try:
         result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
@@ -415,24 +465,30 @@ async def rebuild_search_index(
     """
     from sqlalchemy import text
 
+    from backend.app.core.db_dialect import is_sqlite
+
     try:
-        # Clear and rebuild the FTS index
-        await db.execute(text("DELETE FROM archive_fts"))
-
-        # Repopulate from print_archives
-        await db.execute(
-            text("""
-            INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
-            SELECT id, print_name, filename, tags, notes, designer, filament_type
-            FROM print_archives
-        """)
-        )
+        if is_sqlite():
+            # SQLite: rebuild FTS5 virtual table
+            await db.execute(text("DELETE FROM archive_fts"))
+            await db.execute(
+                text("""
+                INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+                SELECT id, print_name, filename, tags, notes, designer, filament_type
+                FROM print_archives
+            """)
+            )
+            await db.commit()
 
-        await db.commit()
+            result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
+            count = result.scalar() or 0
+        else:
+            # PostgreSQL: GIN index is auto-maintained, just reindex
+            await db.execute(text("REINDEX INDEX idx_archives_fulltext"))
+            await db.commit()
 
-        # Count entries
-        result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
-        count = result.scalar() or 0
+            result = await db.execute(text("SELECT COUNT(*) FROM print_archives"))
+            count = result.scalar() or 0
 
         return {"message": f"Search index rebuilt with {count} entries"}
     except Exception as e:
@@ -447,8 +503,9 @@ async def analyze_failures(
     date_to: date | None = Query(None),
     printer_id: int | None = None,
     project_id: int | None = None,
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Analyze failure patterns across prints.
 
@@ -459,6 +516,8 @@ async def analyze_failures(
     - Recent failures
     - Weekly trend
     """
+    _validate_user_filter_permission(current_user, created_by_id)
+
     from backend.app.services.failure_analysis import FailureAnalysisService
 
     service = FailureAnalysisService(db)
@@ -468,6 +527,7 @@ async def analyze_failures(
         date_to=date_to,
         printer_id=printer_id,
         project_id=project_id,
+        created_by_id=created_by_id,
     )
 
 
@@ -578,10 +638,13 @@ async def export_stats(
     days: int = 30,
     printer_id: int | None = None,
     project_id: int | None = None,
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Export statistics summary to CSV or Excel format."""
+    _validate_user_filter_permission(current_user, created_by_id)
+
     from fastapi.responses import StreamingResponse
 
     from backend.app.services.export import ExportService
@@ -596,6 +659,7 @@ async def export_stats(
             days=days,
             printer_id=printer_id,
             project_id=project_id,
+            created_by_id=created_by_id,
         )
     except ImportError as e:
         raise HTTPException(500, str(e))
@@ -611,10 +675,13 @@ async def export_stats(
 async def get_archive_stats(
     date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
     date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Get statistics across all archives."""
+    _validate_user_filter_permission(current_user, created_by_id)
+
     # Build date filter conditions
     base_conditions = []
     if date_from:
@@ -623,6 +690,7 @@ async def get_archive_stats(
     if date_to:
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         base_conditions.append(PrintArchive.created_at <= dt_to)
+    _apply_user_filter(base_conditions, created_by_id)
 
     # Total counts
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
@@ -730,43 +798,25 @@ async def get_archive_stats(
     energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh")
     energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15
 
-    # When date filters are active, smart plug lifetime totals can't be
-    # filtered by date range — fall back to per-print archive data instead.
+    total_energy_kwh: float = 0.0
+    total_energy_cost: float = 0.0
+    energy_data_warming_up = False
+
     if energy_tracking_mode == "total" and not date_from and not date_to:
-        # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
-        from backend.app.models.smart_plug import SmartPlug
-        from backend.app.services.homeassistant import homeassistant_service
-        from backend.app.services.mqtt_relay import mqtt_relay
-        from backend.app.services.tasmota import tasmota_service
-
-        plugs_result = await db.execute(select(SmartPlug))
-        plugs = list(plugs_result.scalars().all())
-
-        # Configure HA service once (needed for homeassistant-type plugs)
-        ha_url = await get_setting(db, "ha_url") or ""
-        ha_token = await get_setting(db, "ha_token") or ""
-        homeassistant_service.configure(ha_url, ha_token)
-
-        total_energy_kwh = 0.0
-        for plug in plugs:
-            if plug.plug_type == "tasmota":
-                energy = await tasmota_service.get_energy(plug)
-                if energy and energy.get("total") is not None:
-                    total_energy_kwh += energy["total"]
-            elif plug.plug_type == "homeassistant":
-                energy = await homeassistant_service.get_energy(plug)
-                if energy and energy.get("total") is not None:
-                    total_energy_kwh += energy["total"]
-            elif plug.plug_type == "mqtt":
-                # MQTT plugs report "today" energy, not lifetime total
-                mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
-                if mqtt_data and mqtt_data.energy is not None:
-                    total_energy_kwh += mqtt_data.energy
-
-        total_energy_kwh = round(total_energy_kwh, 3)
-        total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
+        # All-time total consumption — read live lifetime counters.
+        total_energy_kwh = await _sum_live_plug_totals(db)
+        total_energy_cost = total_energy_kwh * energy_cost_per_kwh
+    elif energy_tracking_mode == "total":
+        # Total consumption mode with a date filter (#941): use hourly snapshots
+        # to compute per-plug (endpoint - baseline) deltas.
+        total_energy_kwh, energy_data_warming_up = await _sum_snapshot_deltas(
+            db,
+            dt_from=(datetime.combine(date_from, time.min, tzinfo=timezone.utc) if date_from else None),
+            dt_to=(datetime.combine(date_to, time.max, tzinfo=timezone.utc) if date_to else None),
+        )
+        total_energy_cost = total_energy_kwh * energy_cost_per_kwh
     else:
-        # Print mode: sum up per-print energy from archives
+        # Per-print mode: sum the per-print energy column directly.
         energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
@@ -786,9 +836,130 @@ async def get_archive_stats(
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
         total_energy_kwh=round(total_energy_kwh, 3),
         total_energy_cost=round(total_energy_cost, 3),
+        energy_data_warming_up=energy_data_warming_up,
     )
 
 
+async def _sum_live_plug_totals(db: AsyncSession) -> float:
+    """Sum the live lifetime counter from every smart plug.
+
+    Used for all-time "total consumption" mode. Only the current value is
+    available so this can't be date-filtered — use `_sum_snapshot_deltas` for
+    that case.
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.smart_plug import SmartPlug
+    from backend.app.services.homeassistant import homeassistant_service
+    from backend.app.services.mqtt_relay import mqtt_relay
+    from backend.app.services.rest_smart_plug import rest_smart_plug_service
+    from backend.app.services.tasmota import tasmota_service
+
+    plugs_result = await db.execute(select(SmartPlug))
+    plugs = list(plugs_result.scalars().all())
+
+    ha_url = await get_setting(db, "ha_url") or ""
+    ha_token = await get_setting(db, "ha_token") or ""
+    homeassistant_service.configure(ha_url, ha_token)
+
+    total = 0.0
+    for plug in plugs:
+        if plug.plug_type == "tasmota":
+            energy = await tasmota_service.get_energy(plug)
+            if energy and energy.get("total") is not None:
+                total += energy["total"]
+        elif plug.plug_type == "homeassistant":
+            energy = await homeassistant_service.get_energy(plug)
+            if energy and energy.get("total") is not None:
+                total += energy["total"]
+        elif plug.plug_type == "mqtt":
+            # MQTT plugs only expose today's counter, not lifetime.
+            mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+            if mqtt_data and mqtt_data.energy is not None:
+                total += mqtt_data.energy
+        elif plug.plug_type == "rest":
+            energy = await rest_smart_plug_service.get_energy(plug)
+            if energy and energy.get("today") is not None:
+                total += energy["today"]
+    return total
+
+
+async def _sum_snapshot_deltas(
+    db: AsyncSession,
+    *,
+    dt_from: datetime | None,
+    dt_to: datetime | None,
+) -> tuple[float, bool]:
+    """Sum per-plug energy consumption over a date range using hourly snapshots.
+
+    For each plug:
+      * baseline  = last snapshot at or before `dt_from` (ideal)
+                    — if missing, fall back to the earliest snapshot ever
+                      recorded for the plug and flag the result as warming up.
+      * endpoint  = last snapshot at or before `dt_to` (or most recent overall)
+      * delta     = max(0, endpoint - baseline)  — clamp counter resets to 0.
+
+    Returns (total_kwh, warming_up). `warming_up = True` means at least one plug
+    had no baseline before `dt_from` (fresh install or fresh upgrade), so the
+    result undercounts the beginning of the range.
+    """
+    from backend.app.models.smart_plug import SmartPlug
+    from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+
+    plug_ids_result = await db.execute(select(SmartPlug.id))
+    plug_ids = [row[0] for row in plug_ids_result.all()]
+    if not plug_ids:
+        return 0.0, False
+
+    total = 0.0
+    warming_up = False
+    for plug_id in plug_ids:
+        baseline: float | None = None
+        if dt_from is not None:
+            baseline_q = await db.execute(
+                select(SmartPlugEnergySnapshot.lifetime_kwh)
+                .where(
+                    SmartPlugEnergySnapshot.plug_id == plug_id,
+                    SmartPlugEnergySnapshot.recorded_at <= dt_from,
+                )
+                .order_by(SmartPlugEnergySnapshot.recorded_at.desc())
+                .limit(1)
+            )
+            baseline = baseline_q.scalar()
+        if baseline is None:
+            # No snapshot before range start — fall back to the earliest
+            # snapshot ever recorded. Result undercounts the pre-first-snapshot
+            # portion of the range; signal that to the frontend.
+            earliest_q = await db.execute(
+                select(SmartPlugEnergySnapshot.lifetime_kwh)
+                .where(SmartPlugEnergySnapshot.plug_id == plug_id)
+                .order_by(SmartPlugEnergySnapshot.recorded_at.asc())
+                .limit(1)
+            )
+            baseline = earliest_q.scalar()
+            if baseline is None:
+                # No snapshots at all for this plug yet.
+                warming_up = True
+                continue
+            warming_up = True
+
+        endpoint_conditions = [SmartPlugEnergySnapshot.plug_id == plug_id]
+        if dt_to is not None:
+            endpoint_conditions.append(SmartPlugEnergySnapshot.recorded_at <= dt_to)
+        endpoint_q = await db.execute(
+            select(SmartPlugEnergySnapshot.lifetime_kwh)
+            .where(*endpoint_conditions)
+            .order_by(SmartPlugEnergySnapshot.recorded_at.desc())
+            .limit(1)
+        )
+        endpoint = endpoint_q.scalar()
+        if endpoint is None:
+            continue
+
+        total += max(0.0, endpoint - baseline)
+
+    return total, warming_up
+
+
 @router.get("/tags")
 async def get_all_tags(
     db: AsyncSession = Depends(get_db),
@@ -1343,7 +1514,7 @@ async def create_archive_slicer_token(
     if not archive:
         raise HTTPException(404, "Archive not found")
 
-    token = create_slicer_download_token("archive", archive_id)
+    token = await create_slicer_download_token("archive", archive_id)
     return {"token": token}
 
 
@@ -1362,7 +1533,7 @@ async def download_archive_for_slicer(
     """
     from backend.app.core.auth import verify_slicer_download_token
 
-    if not verify_slicer_download_token(token, "archive", archive_id):
+    if not await verify_slicer_download_token(token, "archive", archive_id):
         raise HTTPException(403, "Invalid or expired download token")
 
     service = ArchiveService(db)
@@ -1385,10 +1556,11 @@ async def download_archive_for_slicer(
 async def get_thumbnail(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail image.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1416,10 +1588,11 @@ async def get_thumbnail(
 async def get_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the timelapse video.
 
-    Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1812,12 +1985,13 @@ async def upload_timelapse(
         raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)")
 
     content = await file.read()
-    success = await service.attach_timelapse(archive_id, content, file.filename)
+    safe_filename = _safe_filename(file.filename)
+    success = await service.attach_timelapse(archive_id, content, safe_filename)
 
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
 
-    return {"status": "attached", "filename": file.filename}
+    return {"status": "attached", "filename": safe_filename}
 
 
 @router.get("/{archive_id}/timelapse/info")
@@ -2047,10 +2221,11 @@ async def get_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get a specific photo.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
@@ -2118,10 +2293,11 @@ async def get_qrcode(
     request: Request,
     size: int = 200,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Generate a QR code that links to this archive.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     try:
         import qrcode
@@ -2430,13 +2606,14 @@ async def get_gcode(
 async def get_plate_preview(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the plate preview image from the 3MF file.
 
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2505,8 +2682,9 @@ async def upload_archive(
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
 
-    # Save uploaded file temporarily
-    temp_path = settings.archive_dir / "temp" / file.filename
+    # Save uploaded file temporarily — strip directory components to prevent path traversal
+    safe_filename = _safe_filename(file.filename)
+    temp_path = settings.archive_dir / "temp" / safe_filename
     temp_path.parent.mkdir(parents=True, exist_ok=True)
 
     try:
@@ -2545,7 +2723,8 @@ async def upload_archives_bulk(
             errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
             continue
 
-        temp_path = settings.archive_dir / "temp" / file.filename
+        safe_filename = _safe_filename(file.filename)
+        temp_path = settings.archive_dir / "temp" / safe_filename
         temp_path.parent.mkdir(parents=True, exist_ok=True)
 
         try:
@@ -2862,10 +3041,11 @@ async def get_plate_thumbnail(
     archive_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail image for a specific plate.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -3176,10 +3356,11 @@ async def get_project_image(
     archive_id: int,
     image_path: str,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get an image from the 3MF project page.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     from backend.app.services.archive import ProjectPageParser
 
@@ -3239,8 +3420,8 @@ async def upload_source_3mf(
         if old_source_path.exists():
             old_source_path.unlink()
 
-    # Save the source 3MF file - preserve original filename
-    source_filename = file.filename
+    # Save the source 3MF file - preserve original filename, strip directory components
+    source_filename = _safe_filename(file.filename)
     source_path = source_dir / source_filename
 
     content = await file.read()
@@ -3331,7 +3512,7 @@ async def create_source_slicer_token(
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
 
-    token = create_slicer_download_token("source", archive_id)
+    token = await create_slicer_download_token("source", archive_id)
     return {"token": token}
 
 
@@ -3349,7 +3530,7 @@ async def download_source_3mf_for_slicer_with_token(
     """
     from backend.app.core.auth import verify_slicer_download_token
 
-    if not verify_slicer_download_token(token, "source", archive_id):
+    if not await verify_slicer_download_token(token, "source", archive_id):
         raise HTTPException(403, "Invalid or expired download token")
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3386,10 +3567,12 @@ async def upload_source_3mf_by_name(
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
 
+    safe_filename = _safe_filename(file.filename)
+
     # Derive print name from filename if not provided
     if not print_name:
         # Remove .3mf extension and common suffixes
-        print_name = file.filename.rsplit(".3mf", 1)[0]
+        print_name = safe_filename.rsplit(".3mf", 1)[0]
         # Remove _source suffix if present
         if print_name.endswith("_source"):
             print_name = print_name[:-7]
@@ -3438,8 +3621,8 @@ async def upload_source_3mf_by_name(
         if old_source_path.exists():
             old_source_path.unlink()
 
-    # Save the source 3MF file - preserve original filename
-    source_filename = file.filename
+    # Save the source 3MF file - preserve original filename, strip directory components
+    source_filename = safe_filename
     source_path = source_dir / source_filename
 
     content = await file.read()
@@ -3519,8 +3702,8 @@ async def upload_f3d(
         if old_f3d_path.exists():
             old_f3d_path.unlink()
 
-    # Save the F3D file - preserve original filename
-    f3d_filename = file.filename
+    # Save the F3D file - preserve original filename, strip directory components
+    f3d_filename = _safe_filename(file.filename)
     f3d_path = f3d_dir / f3d_filename
 
     content = await file.read()

+ 641 - 79
backend/app/api/routes/auth.py

@@ -1,9 +1,14 @@
-from datetime import timedelta
+import logging
+import os
+import secrets
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
-from fastapi import APIRouter, Depends, Header, HTTPException, status
+import jwt as _jwt
+from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import select
+from jwt.exceptions import PyJWTError
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -14,6 +19,7 @@ from backend.app.core.auth import (
     SECRET_KEY,
     Permission,
     RequirePermissionIfAuthEnabled,
+    _is_token_fresh,
     _validate_api_key,
     authenticate_user,
     authenticate_user_by_email,
@@ -22,14 +28,18 @@ from backend.app.core.auth import (
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
+    is_jti_revoked,
+    revoke_jti,
     security,
 )
-from backend.app.core.database import get_db
+from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import ALL_PERMISSIONS
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.auth import (
+    ForgotPasswordConfirmRequest,
     ForgotPasswordRequest,
     ForgotPasswordResponse,
     GroupBrief,
@@ -45,13 +55,14 @@ from backend.app.schemas.auth import (
     UserResponse,
 )
 from backend.app.services.email_service import (
-    create_password_reset_email_from_template,
-    generate_secure_password,
+    create_password_reset_link_email_from_template,
     get_smtp_settings,
     save_smtp_settings,
     send_email,
 )
 
+_logger = logging.getLogger(__name__)
+
 
 def _user_to_response(user: User) -> UserResponse:
     """Convert a User model to UserResponse schema."""
@@ -62,6 +73,7 @@ def _user_to_response(user: User) -> UserResponse:
         role=user.role,
         is_active=user.is_active,
         is_admin=user.is_admin,
+        auth_source=getattr(user, "auth_source", "local"),
         groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
         permissions=sorted(user.get_permissions()),
         created_at=user.created_at.isoformat(),
@@ -83,6 +95,50 @@ def _api_key_to_user_response(api_key) -> UserResponse:
     )
 
 
+# ---------------------------------------------------------------------------
+# M-R9-A: Real client IP resolution for rate limiting behind reverse proxies.
+# Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust.
+# Without this env var client.host is used directly (safe default).
+# ---------------------------------------------------------------------------
+_TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
+    ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()
+)
+
+
+def _get_client_ip(request: Request) -> str:
+    """Return the real client IP for rate-limiting purposes.
+
+    When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted
+    proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is
+    NOT itself a trusted proxy is the true client address (M-R10-A fix).
+
+    Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so
+    the rightmost entry is always the one added by the last trusted proxy —
+    i.e. the real client. Walking right-to-left and skipping known proxies is
+    safe for multi-hop chains as well.
+
+    Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct
+    deployment without a reverse proxy).
+    """
+    # I5: Use a per-request unique token instead of "unknown" when the transport
+    # layer provides no client address.  This prevents all such requests from
+    # sharing one rate-limit bucket, and avoids collision with a literal username
+    # "unknown".  The token is not stable across requests, which is intentional:
+    # we cannot track the IP so we also cannot rate-limit by it meaningfully.
+    direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__"
+    if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS:
+        forwarded_for = request.headers.get("X-Forwarded-For", "")
+        ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
+        # Walk right-to-left; skip IPs that belong to trusted proxies.
+        for ip in reversed(ips):
+            if ip not in _TRUSTED_PROXY_IPS:
+                return ip
+        # Edge case: every entry is a trusted proxy — fall back to leftmost.
+        if ips:
+            return ips[0]
+    return direct_ip
+
+
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
@@ -106,26 +162,16 @@ async def is_advanced_auth_enabled(db: AsyncSession) -> bool:
 
 async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
     """Set advanced authentication enabled status."""
-    from sqlalchemy import func
-    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+    from backend.app.core.db_dialect import upsert_setting
 
-    stmt = sqlite_insert(Settings).values(key="advanced_auth_enabled", value="true" if enabled else "false")
-    stmt = stmt.on_conflict_do_update(
-        index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
-    )
-    await db.execute(stmt)
+    await upsert_setting(db, Settings, "advanced_auth_enabled", "true" if enabled else "false")
 
 
 async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
     """Set authentication enabled status."""
-    from sqlalchemy import func
-    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+    from backend.app.core.db_dialect import upsert_setting
 
-    stmt = sqlite_insert(Settings).values(key="auth_enabled", value="true" if enabled else "false")
-    stmt = stmt.on_conflict_do_update(
-        index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
-    )
-    await db.execute(stmt)
+    await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false")
     # Note: Don't commit here - let get_db handle it or commit explicitly in the route
 
 
@@ -138,14 +184,9 @@ async def is_setup_completed(db: AsyncSession) -> bool:
 
 async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
     """Set setup completed status."""
-    from sqlalchemy import func
-    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+    from backend.app.core.db_dialect import upsert_setting
 
-    stmt = sqlite_insert(Settings).values(key="setup_completed", value="true" if completed else "false")
-    stmt = stmt.on_conflict_do_update(
-        index_elements=["key"], set_={"value": "true" if completed else "false", "updated_at": func.now()}
-    )
-    await db.execute(stmt)
+    await upsert_setting(db, Settings, "setup_completed", "true" if completed else "false")
     # Note: Don't commit here - let get_db handle it or commit explicitly in the route
 
 
@@ -220,7 +261,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                     logger.error("Failed to create admin user: %s", e, exc_info=True)
                     raise HTTPException(
                         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-                        detail=f"Failed to create admin user: {str(e)}",
+                        detail="Failed to create admin user",
                     )
 
         # Set auth enabled and mark setup as completed
@@ -241,7 +282,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         await db.rollback()
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Setup failed: {str(e)}",
+            detail="Setup failed",
         )
 
 
@@ -286,15 +327,20 @@ async def disable_auth(
         logger.error("Failed to disable authentication: %s", e, exc_info=True)
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to disable authentication: {str(e)}",
+            detail="Failed to disable authentication",
         )
 
 
 @router.post("/login", response_model=LoginResponse)
-async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
+async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
     """Login and get access token.
 
     Supports username or email-based login. Username lookup is case-insensitive.
+
+    When 2FA is enabled for the user the response contains ``requires_2fa=True``
+    and a short-lived ``pre_auth_token`` instead of the final JWT.  The client
+    must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send``
+    to trigger an email OTP) to obtain the real access token.
     """
     # Check if auth is enabled
     auth_enabled = await is_auth_enabled(db)
@@ -304,16 +350,66 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             detail="Authentication is not enabled",
         )
 
-    # Try username-based authentication first
-    user = await authenticate_user(db, request.username, request.password)
+    # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A):
+    #   1. Per-username (10/15 min): prevents password brute-force on a known account.
+    #   2. Per-IP     (20/15 min): prevents an attacker from locking out arbitrary accounts
+    #      (DoS) by sending failures for many usernames from a single address.
+    from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt
+
+    await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS)
+    client_ip = _get_client_ip(raw_request)
+    await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
+
+    # Check if LDAP is enabled
+    ldap_user = None
+    ldap_settings = await _get_ldap_settings(db)
+    if ldap_settings:
+        try:
+            from backend.app.services.ldap_service import (
+                authenticate_ldap_user,
+                parse_ldap_config,
+            )
+
+            ldap_config = parse_ldap_config(ldap_settings)
+            if ldap_config:
+                ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password)
+                if ldap_user:
+                    # LDAP auth succeeded — find or create local user
+                    user = await get_user_by_username(db, ldap_user.username)
+                    if user and user.auth_source != "ldap":
+                        # Username exists as local user — don't override
+                        user = None
+                        ldap_user = None
+                    elif not user:
+                        if not ldap_config.auto_provision:
+                            # User doesn't exist and auto-provision is off
+                            ldap_user = None
+                        else:
+                            # Auto-provision LDAP user
+                            user = await _provision_ldap_user(db, ldap_user, ldap_config)
+
+                    if user and ldap_user:
+                        # Update email and group mappings on each login
+                        await _sync_ldap_user(db, user, ldap_user, ldap_config)
+        except Exception as e:
+            import logging
+
+            logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
+            ldap_user = None
+
+    # Try username-based authentication (skip if already authenticated via LDAP)
+    if not ldap_user:
+        user = await authenticate_user(db, request.username, request.password)
 
     # If username auth failed and advanced auth is enabled, try email-based authentication
-    if not user:
+    if not user and not ldap_user:
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
 
     if not user:
+        await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
+        await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Incorrect username or password",
@@ -324,6 +420,64 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
     result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
     user = result.scalar_one()
 
+    # L-R6-A: Password was correct — reset login failure counters for both buckets
+    from backend.app.api.routes.mfa import clear_failed_attempts
+
+    await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT)
+    await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP)
+
+    # --- 2FA check ---
+    # Determine which 2FA methods are active for this user.
+
+    from backend.app.models.settings import Settings as _Settings
+    from backend.app.models.user_totp import UserTOTP
+
+    totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+    user_totp = totp_result.scalar_one_or_none()
+    totp_enabled = user_totp is not None and user_totp.is_enabled
+
+    email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled"))
+    email_2fa_setting = email_2fa_result.scalar_one_or_none()
+    email_otp_enabled = (
+        email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None
+    )
+
+    if totp_enabled or email_otp_enabled:
+        # Import here to avoid circular imports
+        from backend.app.api.routes.mfa import create_pre_auth_token
+
+        # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the
+        # token from JS memory and complete 2FA from a different client.
+        challenge_id = secrets.token_urlsafe(32)
+        pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
+        response.set_cookie(
+            key="2fa_challenge",
+            value=challenge_id,
+            httponly=True,
+            # H-1: only transmit over HTTPS so the binding cookie can't be intercepted
+            # on mixed-content deployments.  Falls back to False on plain HTTP so tests
+            # and local development still work (the client wouldn't send it otherwise).
+            secure=raw_request.url.scheme == "https",
+            samesite="lax",
+            max_age=300,
+            path="/api/v1/auth/2fa",
+        )
+        methods: list[str] = []
+        if totp_enabled:
+            methods.append("totp")
+        if email_otp_enabled:
+            methods.append("email")
+        # Backup codes are always available when TOTP is set up
+        if totp_enabled:
+            methods.append("backup")
+
+        return LoginResponse(
+            requires_2fa=True,
+            pre_auth_token=pre_auth_token,
+            two_fa_methods=methods,
+        )
+
+    # No 2FA — issue full token immediately
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
@@ -379,6 +533,14 @@ async def get_current_user_info(
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                 )
+            jti: str | None = payload.get("jti")
+            if not jti or await is_jti_revoked(jti):  # B1: logout bypass fix
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            iat: int | float | None = payload.get("iat")
         except JWTError:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
@@ -396,6 +558,13 @@ async def get_current_user_info(
         # Reload with groups for proper permission calculation
         result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
         user = result.scalar_one()
+        # L-R8-A: reject tokens issued before the last password change
+        if not _is_token_fresh(iat, user):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
         return _user_to_response(user)
 
     # No credentials provided
@@ -407,8 +576,44 @@ async def get_current_user_info(
 
 
 @router.post("/logout")
-async def logout():
-    """Logout (client should discard token)."""
+async def logout(
+    raw_request: Request,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+):
+    """Logout — revokes the current JWT so it cannot be reused after logout."""
+    if credentials is not None:
+        raw_token = credentials.credentials
+        # Nit2: Verify signature before revoking to prevent DoS-revoke attacks
+        # (an attacker crafting a token with an arbitrary jti cannot force
+        # revocation of a legitimate token because the signature check rejects it).
+        # Expired tokens are still accepted — the user is logging out and their
+        # token may have just expired; we still want to record the revocation.
+        try:
+            verified = _jwt.decode(
+                raw_token,
+                SECRET_KEY,
+                algorithms=[ALGORITHM],
+                options={"verify_exp": False},  # allow expired tokens at logout
+            )
+            jti: str | None = verified.get("jti")
+            exp = verified.get("exp")
+            username: str | None = verified.get("sub")
+            if jti and exp:
+                expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
+                try:
+                    await revoke_jti(jti, expires_at, username)
+                except Exception as exc:
+                    _logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
+        except PyJWTError:
+            client_ip = _get_client_ip(raw_request)
+            ua = raw_request.headers.get("user-agent", "<unknown>")
+            _logger.error(
+                "Logout received token that failed signature verification — skipping revocation "
+                "(possible tamper attempt; ip=%s ua=%s)",
+                client_ip,
+                ua,
+            )
+
     return {"message": "Logged out successfully"}
 
 
@@ -443,8 +648,8 @@ async def test_smtp_connection(
         logger.info(f"Test email sent successfully to {test_request.test_recipient}")
         return TestSMTPResponse(success=True, message="Test email sent successfully")
     except Exception as e:
-        logger.error(f"Failed to send test email: {e}")
-        return TestSMTPResponse(success=False, message=f"Failed to send test email: {str(e)}")
+        logger.error("Failed to send test email: %s", e)
+        return TestSMTPResponse(success=False, message="Failed to send test email")
 
 
 @router.get("/smtp", response_model=SMTPSettings | None)
@@ -478,10 +683,10 @@ async def save_smtp_config(
         return {"message": "SMTP settings saved successfully"}
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to save SMTP settings: {e}")
+        logger.error("Failed to save SMTP settings: %s", e)
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to save SMTP settings: {str(e)}",
+            detail="Failed to save SMTP settings",
         )
 
 
@@ -523,10 +728,10 @@ async def enable_advanced_auth(
         return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to enable advanced authentication: {e}")
+        logger.error("Failed to enable advanced authentication: %s", e)
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to enable advanced authentication: {str(e)}",
+            detail="Failed to enable advanced authentication",
         )
 
 
@@ -557,10 +762,10 @@ async def disable_advanced_auth(
         return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to disable advanced authentication: {e}")
+        logger.error("Failed to disable advanced authentication: %s", e)
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to disable advanced authentication: {str(e)}",
+            detail="Failed to disable advanced authentication",
         )
 
 
@@ -575,13 +780,68 @@ async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
     }
 
 
-@router.post("/forgot-password", response_model=ForgotPasswordResponse)
-async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
-    """Request password reset via email (advanced auth only)."""
-    import logging
+# TTL for password-reset tokens (H-6)
+_RESET_TOKEN_TTL = timedelta(hours=1)
 
-    logger = logging.getLogger(__name__)
+# Rate-limit for password-reset email sends per identifier (M-A)
+_MAX_PWD_RESET_SENDS = 3
+_PWD_RESET_SEND_WINDOW = timedelta(minutes=15)
+# L-NEW-6: per-IP cap to prevent mass-reset flooding across many addresses
+_MAX_PWD_RESET_SENDS_PER_IP = 10
 
+
+async def _send_reset_email_or_delete_token(
+    reset_token: str,
+    smtp_settings,
+    to_email: str,
+    subject: str,
+    text_body: str,
+    html_body: str,
+    log_label: str,
+) -> None:
+    """Background task: send a password-reset email and delete the token on failure.
+
+    C1: FastAPI silently swallows BackgroundTask exceptions.  This wrapper
+    catches send failures, deletes the single-use token so it cannot be used
+    (user is not locked out forever — they can request a new link), and logs at
+    ERROR so operators are alerted without leaking details to the caller.
+    """
+    try:
+        send_email(smtp_settings, to_email, subject, text_body, html_body)
+        _logger.info("Password reset email sent (%s) to %s", log_label, to_email)
+    except Exception as exc:
+        _logger.error(
+            "Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
+            log_label,
+            to_email,
+            exc,
+        )
+        try:
+            async with async_session() as db:
+                await db.execute(
+                    delete(AuthEphemeralToken).where(
+                        AuthEphemeralToken.token == reset_token,
+                        AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+                    )
+                )
+                await db.commit()
+        except Exception as db_exc:
+            _logger.error("Failed to delete reset token after send failure: %s", db_exc)
+
+
+@router.post("/forgot-password", response_model=ForgotPasswordResponse)
+async def forgot_password(
+    request: ForgotPasswordRequest,
+    background_tasks: BackgroundTasks,
+    raw_request: Request,
+    db: AsyncSession = Depends(get_db),
+):
+    """Request password reset via email (advanced auth only).
+
+    H-6: Issues a short-lived single-use reset token and emails the user a
+    secure link instead of a plaintext temporary password.  The new password is
+    set only when the user clicks the link and POSTs to /forgot-password/confirm.
+    """
     # Check if advanced auth is enabled
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:
@@ -590,6 +850,47 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             detail="Advanced authentication is not enabled",
         )
 
+    # M-A: Rate-limit by normalised email to prevent reset-email flooding.
+    # Apply unconditionally (before the user lookup) so unknown emails are also
+    # throttled — this prevents both flooding and timing-based enumeration.
+    identifier = request.email.lower()
+    cutoff = datetime.now(timezone.utc) - _PWD_RESET_SEND_WINDOW
+    rate_result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == identifier,
+            AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_SEND,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    if len(rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
+        )
+
+    # L-NEW-6: per-IP rate limit — prevents mass-reset flooding across many
+    # different email addresses from a single source IP.
+    client_ip = _get_client_ip(raw_request)
+    ip_rate_result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == client_ip,
+            AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_IP,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    if len(ip_rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS_PER_IP:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
+        )
+
+    # Nit7: Always record the IP-level event (prevents spray attacks across many
+    # different email addresses from one IP).  The email-level event is only
+    # recorded when we actually send an email to a local user — LDAP/OIDC users
+    # do not consume a slot because this flow is a no-op for them.
+    db.add(AuthRateLimitEvent(username=client_ip, event_type=EventType.PASSWORD_RESET_IP))
+    await db.commit()
+
     # Get SMTP settings
     smtp_settings = await get_smtp_settings(db)
     if not smtp_settings:
@@ -598,47 +899,116 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             detail="Email service is not configured",
         )
 
-    # Find user by email
+    # Find user by email — always return success to prevent email enumeration.
     user = await get_user_by_email(db, request.email)
 
-    # Always return success message to prevent email enumeration
-    # but only send email if user exists
-    if user and user.is_active:
+    # M-1: exclude LDAP and OIDC users — they must use their respective provider.
+    if user and user.is_active and user.auth_source not in ("ldap", "oidc"):
         try:
-            # Generate new password
-            new_password = generate_secure_password()
-            user.password_hash = get_password_hash(new_password)
+            # Record email-level slot only for local users who will actually receive
+            # the reset email (Nit7: don't waste the user's quota for LDAP/OIDC no-ops).
+            db.add(AuthRateLimitEvent(username=identifier, event_type=EventType.PASSWORD_RESET_SEND))
+
+            now = datetime.now(timezone.utc)
+            # Prune any outstanding reset tokens for this user before issuing a new one.
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+                    AuthEphemeralToken.username == user.username,
+                )
+            )
+            reset_token = secrets.token_urlsafe(32)
+            db.add(
+                AuthEphemeralToken(
+                    token=reset_token,
+                    token_type=TokenType.PASSWORD_RESET,
+                    username=user.username,
+                    expires_at=now + _RESET_TOKEN_TTL,
+                )
+            )
             await db.commit()
 
             login_url = await get_external_login_url(db)
+            # M-B: Deliver token in the URL fragment so it never reaches the server
+            # in access-logs or Referer headers (mirrors H-4 for the OIDC token).
+            reset_url = f"{login_url}#reset_token={reset_token}"
 
-            # Send password reset email
-            subject, text_body, html_body = await create_password_reset_email_from_template(
-                db, user.username, new_password, login_url
+            subject, text_body, html_body = await create_password_reset_link_email_from_template(
+                db, user.username, reset_url
             )
-            send_email(smtp_settings, user.email, subject, text_body, html_body)
-
-            logger.info(f"Password reset email sent to {user.email}")
+            # L-R9-B: send asynchronously so response time is independent of
+            # whether the user exists (prevents email-existence timing oracle).
+            # C1: wrapper deletes the token if SMTP fails so the user can re-request.
+            background_tasks.add_task(
+                _send_reset_email_or_delete_token,
+                reset_token,
+                smtp_settings,
+                user.email,
+                subject,
+                text_body,
+                html_body,
+                "forgot_password",
+            )
+            _logger.info("Password reset email queued for %s", user.email)
         except Exception as e:
-            logger.error(f"Failed to send password reset email: {e}")
-            # Don't reveal error to user for security
+            _logger.error("Failed to send password reset email: %s", e)
+            # Don't reveal error to caller for security
 
     return ForgotPasswordResponse(
         message="If the email address is associated with an account, a password reset email has been sent."
     )
 
 
+@router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
+async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
+    """Complete a password reset by supplying the token from the reset email.
+
+    H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
+    the new password.  Expired or already-used tokens are silently rejected with
+    the same response to prevent oracle attacks.
+    """
+    now = datetime.now(timezone.utc)
+    result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == request.token,
+            AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+        )
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)
+    )
+    row = result.one_or_none()
+    await db.commit()
+    if row is None:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+
+    username, expires_at = row
+    # SQLite returns naive datetimes; treat them as UTC.
+    if expires_at.tzinfo is None:
+        expires_at = expires_at.replace(tzinfo=timezone.utc)
+    if now > expires_at:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+
+    user = await get_user_by_username(db, username)
+    # M-1: block LDAP/OIDC users — they authenticate via their provider, not local password.
+    if not user or not user.is_active or user.auth_source in ("ldap", "oidc"):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+
+    user.password_hash = get_password_hash(request.new_password)
+    user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs
+    await db.commit()
+    _logger.info("Password reset completed for user '%s'", username)
+
+    return ForgotPasswordResponse(message="Password has been reset successfully.")
+
+
 @router.post("/reset-password", response_model=ResetPasswordResponse)
 async def reset_user_password(
     request: ResetPasswordRequest,
+    background_tasks: BackgroundTasks,
     current_user: User = Depends(get_current_active_user),
     db: AsyncSession = Depends(get_db),
 ):
     """Reset a user's password and send them an email (admin only, advanced auth only)."""
-    import logging
-
-    logger = logging.getLogger(__name__)
-
     # Reload user with groups for proper is_admin check
     result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
     admin_user = result.scalar_one()
@@ -674,6 +1044,13 @@ async def reset_user_password(
             detail="User not found",
         )
 
+    # M-1: block LDAP/OIDC users — passwords are managed by their respective providers.
+    if user.auth_source in ("ldap", "oidc"):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot reset password for LDAP/OIDC users — authentication is managed by their provider",
+        )
+
     if not user.email:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
@@ -681,25 +1058,210 @@ async def reset_user_password(
         )
 
     try:
-        # Generate new password
-        new_password = generate_secure_password()
-        user.password_hash = get_password_hash(new_password)
+        # H-B: Issue a single-use reset link instead of generating a plaintext password.
+        # The admin never sees the credential — the user sets their own password.
+        now = datetime.now(timezone.utc)
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+                AuthEphemeralToken.username == user.username,
+            )
+        )
+        reset_token = secrets.token_urlsafe(32)
+        db.add(
+            AuthEphemeralToken(
+                token=reset_token,
+                token_type=TokenType.PASSWORD_RESET,
+                username=user.username,
+                expires_at=now + _RESET_TOKEN_TTL,
+            )
+        )
         await db.commit()
 
         login_url = await get_external_login_url(db)
+        reset_url = f"{login_url}#reset_token={reset_token}"
 
-        # Send password reset email
-        subject, text_body, html_body = await create_password_reset_email_from_template(
-            db, user.username, new_password, login_url
+        subject, text_body, html_body = await create_password_reset_link_email_from_template(
+            db, user.username, reset_url
+        )
+        background_tasks.add_task(
+            _send_reset_email_or_delete_token,
+            reset_token,
+            smtp_settings,
+            user.email,
+            subject,
+            text_body,
+            html_body,
+            "admin_reset",
         )
-        send_email(smtp_settings, user.email, subject, text_body, html_body)
 
-        logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")
-        return ResetPasswordResponse(message=f"Password reset email sent to {user.email}")
+        _logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
+        return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to reset password for user {user.username}: {e}")
+        _logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to reset password: {str(e)}",
+            detail="Failed to send password reset link. Check server logs.",  # L-R7-B: no internal details
+        )
+
+
+# LDAP Authentication Helpers
+
+
+async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
+    """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
+    ldap_keys = [
+        "ldap_enabled",
+        "ldap_server_url",
+        "ldap_bind_dn",
+        "ldap_bind_password",
+        "ldap_search_base",
+        "ldap_user_filter",
+        "ldap_security",
+        "ldap_group_mapping",
+        "ldap_auto_provision",
+        "ldap_ca_cert_path",
+        "ldap_default_group",
+    ]
+    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    settings = {s.key: s.value for s in result.scalars().all()}
+    if settings.get("ldap_enabled", "false").lower() != "true":
+        return None
+    return settings
+
+
+async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User:
+    """Create a new local user from LDAP authentication."""
+    import logging
+
+    from backend.app.services.ldap_service import resolve_group_mapping
+
+    logger = logging.getLogger(__name__)
+
+    new_user = User(
+        username=ldap_user.username,
+        email=ldap_user.email,
+        password_hash=None,
+        role="user",
+        auth_source="ldap",
+        is_active=True,
+    )
+
+    # Map LDAP groups to BamBuddy groups, falling back to the configured default group
+    # when the user is authenticated but has no matching group mapping (#921-follow-up).
+    mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
+    if not mapped_group_names and ldap_config.default_group:
+        mapped_group_names = [ldap_config.default_group]
+        logger.warning(
+            "LDAP user %s has no mapped groups — assigning configured default group '%s'",
+            ldap_user.username,
+            ldap_config.default_group,
         )
+    if mapped_group_names:
+        groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
+        new_user.groups = list(groups_result.scalars().all())
+
+    db.add(new_user)
+    await db.commit()
+    await db.refresh(new_user)
+    logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)
+    return new_user
+
+
+async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
+    """Sync LDAP user attributes (email, groups) on each login."""
+    import logging
+
+    from backend.app.services.ldap_service import resolve_group_mapping
+
+    logger = logging.getLogger(__name__)
+
+    changed = False
+
+    # Update email if changed
+    if ldap_user.email and ldap_user.email != user.email:
+        user.email = ldap_user.email
+        changed = True
+
+    # Sync group mappings — always update to match LDAP state (including revocation).
+    # Fall back to the configured default group when the user has no mapped groups,
+    # so authenticated LDAP users are never left permission-less.
+    mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
+    if not mapped_group_names and ldap_config.default_group:
+        mapped_group_names = [ldap_config.default_group]
+        logger.warning(
+            "LDAP user %s has no mapped groups — assigning configured default group '%s'",
+            user.username,
+            ldap_config.default_group,
+        )
+    if mapped_group_names:
+        groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
+        new_groups = list(groups_result.scalars().all())
+    else:
+        new_groups = []
+    current_group_ids = {g.id for g in user.groups}
+    new_group_ids = {g.id for g in new_groups}
+    if current_group_ids != new_group_ids:
+        user.groups = new_groups
+        changed = True
+
+    if changed:
+        await db.commit()
+        logger.info("Synced LDAP user attributes: %s", user.username)
+
+
+@router.post("/ldap/test")
+async def test_ldap(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Test LDAP connection using saved settings (admin only when auth enabled)."""
+    import logging
+
+    from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
+
+    logger = logging.getLogger(__name__)
+
+    ldap_settings = await _get_ldap_settings(db)
+    if not ldap_settings:
+        # LDAP might not be enabled yet but settings might still exist — read all keys
+        ldap_keys = [
+            "ldap_enabled",
+            "ldap_server_url",
+            "ldap_bind_dn",
+            "ldap_bind_password",
+            "ldap_search_base",
+            "ldap_user_filter",
+            "ldap_security",
+            "ldap_group_mapping",
+            "ldap_auto_provision",
+        ]
+        result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+        ldap_settings = {s.key: s.value for s in result.scalars().all()}
+        # Force enabled for test
+        ldap_settings["ldap_enabled"] = "true"
+
+    config = parse_ldap_config(ldap_settings)
+    if not config:
+        return {"success": False, "message": "LDAP server URL is not configured"}
+
+    success, message = test_ldap_connection(config)
+    if success:
+        logger.info("LDAP connection test successful")
+    else:
+        logger.warning("LDAP connection test failed: %s", message)
+    return {"success": success, "message": message}
+
+
+@router.get("/ldap/status")
+async def get_ldap_status(db: AsyncSession = Depends(get_db)):
+    """Get LDAP authentication status."""
+    # Only fetch the minimum keys needed — never load secrets
+    ldap_keys = ["ldap_enabled", "ldap_server_url"]
+    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    settings = {s.key: s.value for s in result.scalars().all()}
+    return {
+        "ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
+        "ldap_configured": bool(settings.get("ldap_server_url")),
+    }

+ 15 - 4
backend/app/api/routes/bug_report.py

@@ -12,7 +12,10 @@ from backend.app.api.routes.support import (
     _get_recent_sanitized_logs,
     _set_debug_setting,
 )
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import async_session
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
 from backend.app.services.bug_report import submit_report
 from backend.app.services.printer_manager import printer_manager
 
@@ -45,7 +48,9 @@ class StopLoggingResponse(BaseModel):
 
 
 @router.post("/start-logging", response_model=StartLoggingResponse)
-async def start_logging():
+async def start_logging(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Enable debug logging and push all printers for fresh data."""
     async with async_session() as db:
         was_debug, _ = await _get_debug_setting(db)
@@ -66,7 +71,10 @@ async def start_logging():
 
 
 @router.post("/stop-logging", response_model=StopLoggingResponse)
-async def stop_logging(was_debug: bool = Query(default=False)):
+async def stop_logging(
+    was_debug: bool = Query(default=False),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Collect logs and restore previous log level."""
     logs = await _get_recent_sanitized_logs()
 
@@ -80,8 +88,11 @@ async def stop_logging(was_debug: bool = Query(default=False)):
 
 
 @router.post("/submit", response_model=BugReportResponse)
-async def submit_bug_report(report: BugReportRequest):
-    """Submit a bug report. No auth required — anyone should be able to report bugs."""
+async def submit_bug_report(
+    report: BugReportRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Submit a bug report. Requires auth when authentication is enabled."""
     support_info = None
     if report.include_support_info:
         try:

+ 70 - 10
backend/app/api/routes/camera.py

@@ -2,6 +2,7 @@
 
 import asyncio
 import logging
+import os
 import subprocess
 import sys
 from collections.abc import AsyncGenerator
@@ -11,7 +12,11 @@ from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
+    RequirePermissionIfAuthEnabled,
+    create_camera_stream_token,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
@@ -196,13 +201,46 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
     _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
+def _summarize_ffmpeg_stderr(text: str | None) -> str:
+    """Strip ffmpeg's boilerplate banner and keep only actionable lines.
+
+    ffmpeg prints ~20 lines of version/build/configuration/lib headers before
+    any actual error message. Logging the full banner on every retry floods
+    the log (hundreds of lines per failed stream). This filter drops the
+    banner and caps output at the last 10 meaningful lines.
+    """
+    if not text:
+        return ""
+    banner_prefixes = (
+        "ffmpeg version ",
+        "  built with ",
+        "  configuration:",
+        "  libavutil ",
+        "  libavcodec ",
+        "  libavformat ",
+        "  libavdevice ",
+        "  libavfilter ",
+        "  libswscale ",
+        "  libswresample ",
+        "  libpostproc ",
+    )
+    meaningful = [ln for ln in text.splitlines() if ln.strip() and not ln.startswith(banner_prefixes)]
+    return "\n".join(meaningful[-10:])
+
+
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
-    """Read ffmpeg stderr for diagnostics (best-effort, non-blocking)."""
+    """Read ffmpeg stderr for diagnostics (best-effort, non-blocking).
+
+    Returns the stderr content with ffmpeg's boilerplate banner stripped,
+    so log output stays focused on the actual error.
+    """
     if not process or not process.stderr:
         return None
     try:
         data = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
-        return data.decode(errors="replace") if data else None
+        if not data:
+            return None
+        return _summarize_ffmpeg_stderr(data.decode(errors="replace")) or None
     except (TimeoutError, Exception):
         return None
 
@@ -333,7 +371,7 @@ async def generate_rtsp_mjpeg_stream(
             await asyncio.sleep(0.1)
             if process.returncode is not None:
                 stderr = await process.stderr.read()
-                stderr_text = stderr.decode(errors="replace")
+                stderr_text = _summarize_ffmpeg_stderr(stderr.decode(errors="replace"))
                 logger.error("ffmpeg failed immediately (attempt %d): %s", reconnect_count + 1, stderr_text)
                 _spawned_ffmpeg_pids.pop(process.pid, None)
                 if not got_any_frames and reconnect_count == 0:
@@ -478,19 +516,32 @@ async def generate_rtsp_mjpeg_stream(
         await proxy_server.wait_closed()
 
 
+@router.post("/camera/stream-token")
+async def create_stream_token(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
+    """Create a reusable token for camera stream/snapshot access.
+
+    Returns a token valid for 60 minutes that can be appended as ?token=xxx
+    to camera stream/snapshot URLs loaded via <img> tags.
+    """
+    return {"token": await create_camera_stream_token()}
+
+
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
     printer_id: int,
     request: Request,
     fps: int = 10,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Stream live video from printer camera as MJPEG.
 
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
 
     Uses external camera if configured, otherwise uses built-in camera:
     - External: MJPEG, RTSP, or HTTP snapshot
@@ -720,12 +771,13 @@ async def stop_camera_stream(
 async def camera_snapshot(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Capture a single frame from the printer camera.
 
     Returns a JPEG image.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     import tempfile
     from pathlib import Path
@@ -751,9 +803,11 @@ async def camera_snapshot(
             },
         )
 
-    # Create temporary file for the snapshot
-    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
-        temp_path = Path(f.name)
+    # Create temporary file for the snapshot (0600 so only the app user can read it)
+    fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
+    os.close(fd)
+    temp_path = Path(tmp_name)
+    temp_path.chmod(0o600)
 
     try:
         success = await capture_camera_frame(
@@ -1198,10 +1252,11 @@ async def get_reference_thumbnail(
     printer_id: int,
     index: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get thumbnail image for a calibration reference.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     from fastapi.responses import Response
 
@@ -1321,6 +1376,11 @@ async def cleanup_orphaned_streams():
     # Collect PIDs that are legitimately in-use (active stream, process alive)
     active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
 
+    # Also exclude PIDs from one-shot snapshot captures (Obico detection, finish photos, etc.)
+    from backend.app.services.camera import _active_capture_pids
+
+    active_pids |= _active_capture_pids
+
     # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
     #    Any ffmpeg with rtsp(s)://bblp: that is NOT in an active stream is orphaned.
     for pid in _scan_bambu_ffmpeg_pids():

+ 135 - 104
backend/app/api/routes/cloud.py

@@ -36,7 +36,7 @@ from backend.app.schemas.cloud import (
 from backend.app.services.bambu_cloud import (
     BambuCloudAuthError,
     BambuCloudError,
-    get_cloud_service,
+    BambuCloudService,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 
@@ -48,40 +48,57 @@ router = APIRouter(prefix="/cloud", tags=["cloud"])
 # Keys for storing cloud credentials in settings
 CLOUD_TOKEN_KEY = "bambu_cloud_token"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
+CLOUD_REGION_KEY = "bambu_cloud_region"
 
 
-async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None]:
-    """Get stored cloud token and email.
+def _normalise_region(region: str | None) -> str:
+    """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
+    return region if region in ("global", "china") else "global"
+
+
+async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
+    """Get stored cloud token, email, and region.
 
     When a user is provided (auth enabled), returns that user's per-user credentials.
     When user is None (auth disabled), falls back to global Settings table.
+    Region defaults to ``"global"`` when unset (including for rows that predate
+    the ``cloud_region`` column).
     """
     if user is not None:
-        return user.cloud_token, user.cloud_email
+        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
 
     # Fallback: global storage (auth disabled)
-    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
     settings = {s.key: s.value for s in result.scalars().all()}
-    return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
+    return (
+        settings.get(CLOUD_TOKEN_KEY),
+        settings.get(CLOUD_EMAIL_KEY),
+        _normalise_region(settings.get(CLOUD_REGION_KEY)),
+    )
 
 
-async def store_token(db: AsyncSession, token: str, email: str, user: User | None = None) -> None:
-    """Store cloud token and email.
+async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
+    """Store cloud token, email, and region.
 
     When a user is provided (auth enabled), stores on the user record.
     When user is None (auth disabled), stores in global Settings table.
     """
+    region = _normalise_region(region)
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
         from sqlalchemy import update
 
-        await db.execute(update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email))
+        await db.execute(
+            update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
+        )
         await db.commit()
         return
 
     # Fallback: global storage (auth disabled)
-    for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
+    for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email), (CLOUD_REGION_KEY, region)]:
         result = await db.execute(select(Settings).where(Settings.key == key))
         setting = result.scalar_one_or_none()
         if setting:
@@ -92,7 +109,7 @@ async def store_token(db: AsyncSession, token: str, email: str, user: User | Non
 
 
 async def clear_token(db: AsyncSession, user: User | None = None) -> None:
-    """Clear stored cloud token and email.
+    """Clear stored cloud token, email, and region.
 
     When a user is provided (auth enabled), clears that user's credentials.
     When user is None (auth disabled), clears from global Settings table.
@@ -100,33 +117,62 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
     if user is not None:
         from sqlalchemy import update
 
-        await db.execute(update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None))
+        await db.execute(
+            update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
+        )
         await db.commit()
         return
 
     # Fallback: global storage (auth disabled)
-    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
     for setting in result.scalars().all():
         await db.delete(setting)
     await db.commit()
 
 
+async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
+    """Build a per-request cloud service seeded with the caller's stored token + region.
+
+    Returns ``None`` when no token is stored, so callers can 401 without constructing
+    (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
+    """
+    token, _email, region = await get_stored_token(db, user)
+    if not token:
+        return None
+    cloud = BambuCloudService(region=region)
+    cloud.set_token(token)
+    return cloud
+
+
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
-    """Get current cloud authentication status."""
-    token, email = await get_stored_token(db, current_user)
-    cloud = get_cloud_service()
+    """Get current cloud authentication status.
 
-    if token:
-        cloud.set_token(token)
+    Reads the stored credentials in one DB round-trip (we used to call
+    ``get_stored_token`` twice — once here and once inside
+    ``build_authenticated_cloud``). ``region`` is exposed so the frontend can
+    show "Connected (China)" after a reload without relying on local state.
+    """
+    token, email, region = await get_stored_token(db, current_user)
+    if not token:
+        return CloudAuthStatus(is_authenticated=False, email=None, region=None)
 
-    return CloudAuthStatus(
-        is_authenticated=cloud.is_authenticated,
-        email=email if cloud.is_authenticated else None,
-    )
+    cloud = BambuCloudService(region=region)
+    cloud.set_token(token)
+    try:
+        authenticated = cloud.is_authenticated
+        return CloudAuthStatus(
+            is_authenticated=authenticated,
+            email=email if authenticated else None,
+            region=region if authenticated else None,
+        )
+    finally:
+        await cloud.close()
 
 
 @router.post("/login", response_model=CloudLoginResponse)
@@ -145,14 +191,14 @@ async def login(
     After receiving/generating the code, call /cloud/verify to complete the login.
     For TOTP, include the tfa_key from this response in the verify request.
     """
-    cloud = get_cloud_service()
+    cloud = BambuCloudService(region=request.region)
 
     try:
         result = await cloud.login_request(request.email, request.password)
 
         if result.get("success") and cloud.access_token:
             # Direct login succeeded (rare)
-            await store_token(db, cloud.access_token, request.email, current_user)
+            await store_token(db, cloud.access_token, request.email, request.region, current_user)
 
         return CloudLoginResponse(
             success=result.get("success", False),
@@ -165,6 +211,8 @@ async def login(
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.post("/verify", response_model=CloudLoginResponse)
@@ -183,8 +231,11 @@ async def verify_code(
     For TOTP verification:
     - The user enters the 6-digit code from their authenticator app
     - Include the tfa_key from the /cloud/login response
+
+    ``request.region`` must match the region used in /cloud/login so that the
+    TOTP call hits the correct TFA endpoint (bambulab.com vs bambulab.cn).
     """
-    cloud = get_cloud_service()
+    cloud = BambuCloudService(region=request.region)
 
     try:
         # Use TOTP verification if tfa_key is provided
@@ -194,7 +245,7 @@ async def verify_code(
             result = await cloud.verify_code(request.email, request.code)
 
         if result.get("success") and cloud.access_token:
-            await store_token(db, cloud.access_token, request.email, current_user)
+            await store_token(db, cloud.access_token, request.email, request.region, current_user)
 
         return CloudLoginResponse(
             success=result.get("success", False),
@@ -205,6 +256,8 @@ async def verify_code(
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.post("/token", response_model=CloudAuthStatus)
@@ -216,19 +269,22 @@ async def set_token(
     """
     Set access token directly.
 
-    For users who already have a token (e.g., from Bambu Studio).
+    For users who already have a token (e.g., from Bambu Studio). The
+    selected ``region`` is persisted alongside the token so every subsequent
+    request hits the right Bambu API endpoint, including after a restart.
     """
-    cloud = get_cloud_service()
+    cloud = BambuCloudService(region=request.region)
     cloud.set_token(request.access_token)
 
-    # Verify token works by trying to get profile
     try:
+        # Verify token works by trying to get profile
         await cloud.get_user_profile()
-        await store_token(db, request.access_token, "token-auth", current_user)
+        await store_token(db, request.access_token, "token-auth", request.region, current_user)
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
     except BambuCloudError:
-        cloud.logout()
         raise HTTPException(status_code=401, detail="Invalid token")
+    finally:
+        await cloud.close()
 
 
 @router.post("/logout")
@@ -237,8 +293,6 @@ async def logout(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """Log out of Bambu Cloud."""
-    cloud = get_cloud_service()
-    cloud.logout()
     await clear_token(db, current_user)
     return {"success": True}
 
@@ -254,14 +308,8 @@ async def get_slicer_settings(
 
     Requires authentication.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -316,6 +364,8 @@ async def get_slicer_settings(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.get("/settings/{setting_id}")
@@ -329,14 +379,8 @@ async def get_setting_detail(
 
     Returns the full preset configuration.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -347,6 +391,8 @@ async def get_setting_detail(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.get("/filaments", response_model=list[SlicerSetting])
@@ -486,11 +532,16 @@ async def _enrich_from_local_presets(
 
     try:
         # Query filament presets that have a setting_id matching any of our IDs
-        # json_extract is supported in SQLite >= 3.9 and all modern Python builds
+        from backend.app.core.db_dialect import is_sqlite
+
+        if is_sqlite():
+            json_filter = text("json_extract(setting, '$.setting_id') IS NOT NULL")
+        else:
+            json_filter = text("(setting::jsonb->>'setting_id') IS NOT NULL")
         candidates = await db.execute(
             select(LocalPreset).where(
                 LocalPreset.preset_type == "filament",
-                text("json_extract(setting, '$.setting_id') IS NOT NULL"),
+                json_filter,
             )
         )
         for preset in candidates.scalars().all():
@@ -576,12 +627,9 @@ async def get_filament_info(
 
     # Phase 2: Try cloud for uncached IDs
     if unresolved_ids:
-        token, _ = await get_stored_token(db, current_user)
-        if token:
-            cloud = get_cloud_service()
-            cloud.set_token(token)
-
-            if cloud.is_authenticated:
+        cloud = await build_authenticated_cloud(db, current_user)
+        if cloud is not None and cloud.is_authenticated:
+            try:
                 still_unresolved: list[str] = []
                 for setting_id in unresolved_ids:
                     try:
@@ -610,6 +658,10 @@ async def get_filament_info(
                         still_unresolved.append(setting_id)
 
                 unresolved_ids = still_unresolved
+            finally:
+                await cloud.close()
+        elif cloud is not None:
+            await cloud.close()
 
     # Phase 3: Try local profiles for any IDs still without a name
     if unresolved_ids:
@@ -628,14 +680,8 @@ async def get_devices(
 
     Returns printers registered to the user's Bambu account.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -657,6 +703,8 @@ async def get_devices(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
@@ -675,14 +723,8 @@ async def get_firmware_updates(
 
     Requires cloud authentication.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -736,6 +778,8 @@ async def get_firmware_updates(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.post("/settings")
@@ -752,14 +796,8 @@ async def create_setting(
 
     Type should be: 'filament', 'print', or 'printer'
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -776,6 +814,8 @@ async def create_setting(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.put("/settings/{setting_id}")
@@ -790,14 +830,8 @@ async def update_setting(
 
     Updates the preset's name and/or settings on Bambu Cloud.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -812,6 +846,8 @@ async def update_setting(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
@@ -825,14 +861,8 @@ async def delete_setting(
 
     Removes the preset from Bambu Cloud. This cannot be undone.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -846,6 +876,8 @@ async def delete_setting(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 # Path to field definition files
@@ -922,13 +954,10 @@ async def get_filament_id_map(
     if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
         return _filament_id_name_cache
 
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        return _filament_id_name_cache or {}
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
+        if cloud is not None:
+            await cloud.close()
         return _filament_id_name_cache or {}
 
     try:
@@ -956,6 +985,8 @@ async def get_filament_id_map(
         return result
     except Exception:
         return _filament_id_name_cache or {}
+    finally:
+        await cloud.close()
 
 
 @router.get("/fields/{preset_type}")

+ 3 - 2
backend/app/api/routes/external_links.py

@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -239,10 +239,11 @@ async def delete_icon(
 async def get_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the custom icon for an external link.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()

+ 19 - 3
backend/app/api/routes/firmware.py

@@ -30,6 +30,16 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/firmware", tags=["firmware"])
 
 
+class AvailableFirmwareVersion(BaseModel):
+    """A single firmware version announced by Bambu Lab."""
+
+    version: str
+    file_available: bool
+    download_url: str | None = None
+    release_notes: str | None = None
+    release_time: str | None = None
+
+
 class FirmwareUpdateInfo(BaseModel):
     """Firmware update information for a printer."""
 
@@ -41,6 +51,7 @@ class FirmwareUpdateInfo(BaseModel):
     update_available: bool
     download_url: str | None = None
     release_notes: str | None = None
+    available_versions: list[AvailableFirmwareVersion] = Field(default_factory=list)
 
 
 class FirmwareUpdatesResponse(BaseModel):
@@ -106,6 +117,7 @@ async def check_firmware_updates(
                 update_available=update_info["update_available"],
                 download_url=update_info["download_url"],
                 release_notes=update_info["release_notes"],
+                available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get("available_versions", [])],
             )
         )
 
@@ -149,6 +161,7 @@ async def check_printer_firmware(
         update_available=update_info["update_available"],
         download_url=update_info["download_url"],
         release_notes=update_info["release_notes"],
+        available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get("available_versions", [])],
     )
 
 
@@ -192,6 +205,7 @@ class FirmwareUploadPrepareResponse(BaseModel):
     update_available: bool
     current_version: str | None = None
     latest_version: str | None = None
+    target_version: str | None = None
     firmware_filename: str | None = None
     errors: list[str] = Field(default_factory=list)
 
@@ -217,6 +231,7 @@ class FirmwareUploadStartResponse(BaseModel):
 @router.get("/updates/{printer_id}/prepare", response_model=FirmwareUploadPrepareResponse)
 async def prepare_firmware_upload(
     printer_id: int,
+    version: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
@@ -232,13 +247,14 @@ async def prepare_firmware_upload(
     can succeed.
     """
     update_service = get_firmware_update_service()
-    result = await update_service.prepare_update(printer_id, db)
+    result = await update_service.prepare_update(printer_id, db, target_version=version)
     return FirmwareUploadPrepareResponse(**result)
 
 
 @router.post("/updates/{printer_id}/upload", response_model=FirmwareUploadStartResponse)
 async def start_firmware_upload(
     printer_id: int,
+    version: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
@@ -257,7 +273,7 @@ async def start_firmware_upload(
     """
     # First check prerequisites
     update_service = get_firmware_update_service()
-    prepare_result = await update_service.prepare_update(printer_id, db)
+    prepare_result = await update_service.prepare_update(printer_id, db, target_version=version)
 
     if not prepare_result["can_proceed"]:
         errors = prepare_result.get("errors", ["Cannot proceed with firmware upload"])
@@ -267,7 +283,7 @@ async def start_firmware_upload(
         )
 
     # Start the upload
-    started = await update_service.start_upload(printer_id, db)
+    started = await update_service.start_upload(printer_id, db, target_version=version)
 
     if not started:
         state = get_upload_state(printer_id)

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

@@ -39,6 +39,8 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
         "backup_kprofiles": config.backup_kprofiles,
         "backup_cloud_profiles": config.backup_cloud_profiles,
         "backup_settings": config.backup_settings,
+        "backup_spools": config.backup_spools,
+        "backup_archives": config.backup_archives,
         "enabled": config.enabled,
         "last_backup_at": config.last_backup_at,
         "last_backup_status": config.last_backup_status,
@@ -89,6 +91,8 @@ async def save_config(
         config.backup_kprofiles = config_data.backup_kprofiles
         config.backup_cloud_profiles = config_data.backup_cloud_profiles
         config.backup_settings = config_data.backup_settings
+        config.backup_spools = config_data.backup_spools
+        config.backup_archives = config_data.backup_archives
         config.enabled = config_data.enabled
 
         # Calculate next scheduled run if enabled
@@ -109,6 +113,8 @@ async def save_config(
             backup_kprofiles=config_data.backup_kprofiles,
             backup_cloud_profiles=config_data.backup_cloud_profiles,
             backup_settings=config_data.backup_settings,
+            backup_spools=config_data.backup_spools,
+            backup_archives=config_data.backup_archives,
             enabled=config_data.enabled,
         )
 

+ 80 - 30
backend/app/api/routes/inventory.py

@@ -9,7 +9,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -234,6 +234,45 @@ async def get_color_catalog(
     return list(result.scalars().all())
 
 
+@router.get("/colors/map")
+async def get_color_name_map(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_auth_if_enabled),
+):
+    """Compact {hex: name} map for frontend color-name resolution.
+
+    Not gated on INVENTORY_READ — every page that renders a spool color needs
+    this, including read-only views available to users without inventory access.
+    Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
+    share the same hex (different materials or manufacturers), Bambu Lab wins,
+    then default entries, then the first encountered.
+    """
+    result = await db.execute(
+        select(
+            ColorCatalogEntry.hex_color,
+            ColorCatalogEntry.color_name,
+            ColorCatalogEntry.manufacturer,
+            ColorCatalogEntry.is_default,
+        )
+    )
+    mapping: dict[str, tuple[str, int]] = {}  # hex → (name, priority); higher priority wins
+    for hex_color, color_name, manufacturer, is_default in result.all():
+        if not hex_color or not color_name:
+            continue
+        key = hex_color.lstrip("#").lower()[:6]
+        if len(key) != 6:
+            continue
+        priority = 0
+        if manufacturer and manufacturer.strip().lower() == "bambu lab":
+            priority += 2
+        if is_default:
+            priority += 1
+        existing = mapping.get(key)
+        if existing is None or priority > existing[1]:
+            mapping[key] = (color_name, priority)
+    return {"colors": {k: v[0] for k, v in mapping.items()}}
+
+
 @router.post("/colors", response_model=ColorEntryResponse)
 async def add_color_entry(
     entry: ColorEntryCreate,
@@ -522,6 +561,7 @@ async def create_spool(
     await db.commit()
     await db.refresh(spool)
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
 
@@ -540,6 +580,7 @@ async def bulk_create_spools(
     await db.commit()
     ids = [s.id for s in spools]
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return list(result.scalars().all())
 
 
@@ -566,6 +607,7 @@ async def update_spool(
 
     await db.commit()
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
 
@@ -583,6 +625,7 @@ async def delete_spool(
 
     await db.delete(spool)
     await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return {"status": "deleted"}
 
 
@@ -603,6 +646,7 @@ async def archive_spool(
     spool.archived_at = datetime.now(timezone.utc)
     await db.commit()
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
 
@@ -621,6 +665,7 @@ async def restore_spool(
     spool.archived_at = None
     await db.commit()
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
 
@@ -741,7 +786,7 @@ async def list_assignments(
 async def assign_spool(
     data: SpoolAssignmentCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Assign a spool to an AMS slot and auto-configure via MQTT."""
     from backend.app.services.printer_manager import printer_manager
@@ -866,34 +911,39 @@ async def assign_spool(
                     # Use base_sf (version suffix stripped) for cloud API + MQTT
                     setting_id = base_sf
                     try:
-                        from backend.app.services.bambu_cloud import get_cloud_service
-
-                        cloud = get_cloud_service()
-                        if cloud.is_authenticated:
-                            detail = await cloud.get_setting_detail(base_sf)
-                            if detail.get("filament_id"):
-                                tray_info_idx = detail["filament_id"]
-                                logger.info(
-                                    "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
-                                    tray_info_idx,
-                                    sf,
-                                )
-                                # Use cloud preset name for tray_sub_brands if available
-                                cloud_name = detail.get("name", "")
-                                if cloud_name:
-                                    tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
-                            elif detail.get("base_id"):
-                                # Derive from base_id (e.g. "GFSL05" → "GFL05")
-                                bid = detail["base_id"].split("_")[0]
-                                if bid.startswith("GFS") and len(bid) >= 5:
-                                    tray_info_idx = f"GF{bid[3:]}"
-                                else:
-                                    tray_info_idx = bid
-                                logger.info(
-                                    "Spool assign: derived filament_id=%r from base_id=%r",
-                                    tray_info_idx,
-                                    detail["base_id"],
-                                )
+                        from backend.app.api.routes.cloud import build_authenticated_cloud
+
+                        cloud = await build_authenticated_cloud(db, current_user)
+                        if cloud is not None and cloud.is_authenticated:
+                            try:
+                                detail = await cloud.get_setting_detail(base_sf)
+                                if detail.get("filament_id"):
+                                    tray_info_idx = detail["filament_id"]
+                                    logger.info(
+                                        "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
+                                        tray_info_idx,
+                                        sf,
+                                    )
+                                    # Use cloud preset name for tray_sub_brands if available
+                                    cloud_name = detail.get("name", "")
+                                    if cloud_name:
+                                        tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                                elif detail.get("base_id"):
+                                    # Derive from base_id (e.g. "GFSL05" → "GFL05")
+                                    bid = detail["base_id"].split("_")[0]
+                                    if bid.startswith("GFS") and len(bid) >= 5:
+                                        tray_info_idx = f"GF{bid[3:]}"
+                                    else:
+                                        tray_info_idx = bid
+                                    logger.info(
+                                        "Spool assign: derived filament_id=%r from base_id=%r",
+                                        tray_info_idx,
+                                        detail["base_id"],
+                                    )
+                            finally:
+                                await cloud.close()
+                        elif cloud is not None:
+                            await cloud.close()
                     except Exception as e:
                         logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
 

+ 3 - 2
backend/app/api/routes/kprofiles.py

@@ -113,8 +113,9 @@ async def set_kprofile(
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
 
-    # Detect H2D by serial number prefix
-    is_h2d = printer.serial_number.startswith("094")
+    # Detect dual-nozzle families by serial number prefix
+    # H2D series: "094"; X2D series: "20P9"
+    is_h2d = printer.serial_number.startswith(("094", "20P9"))
 
     if is_edit and is_h2d:
         # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id

+ 169 - 22
backend/app/api/routes/library.py

@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
     require_ownership_permission,
     require_permission_if_auth_enabled,
 )
@@ -640,6 +641,7 @@ async def delete_folder(
 
     # Delete folder (cascade will handle files and subfolders)
     await db.delete(folder)
+    await db.commit()
 
     return {"status": "success", "message": "Folder deleted"}
 
@@ -779,18 +781,105 @@ async def scan_external_folder(
     if not ext_path.exists() or not ext_path.is_dir():
         raise HTTPException(status_code=400, detail=f"External path is not accessible: {folder.external_path}")
 
-    # Get existing DB files for this folder
+    # Collect all existing child external subfolder IDs (single query)
+    all_folder_ids = [folder_id]
+    child_result = await db.execute(
+        select(LibraryFolder).where(
+            LibraryFolder.is_external.is_(True),
+            LibraryFolder.parent_id.isnot(None),
+        )
+    )
+    all_child_folders = child_result.scalars().all()
+
+    # Walk the parent chain to find all descendants of folder_id
+    parent_to_children: dict[int, list] = {}
+    for cf in all_child_folders:
+        parent_to_children.setdefault(cf.parent_id, []).append(cf)
+
+    queue = [folder_id]
+    while queue:
+        pid = queue.pop()
+        for child in parent_to_children.get(pid, []):
+            all_folder_ids.append(child.id)
+            queue.append(child.id)
+
+    # Get existing DB files across root and all subfolders
     existing_result = await db.execute(
-        select(LibraryFile).where(LibraryFile.folder_id == folder_id, LibraryFile.is_external.is_(True))
+        select(LibraryFile).where(
+            LibraryFile.folder_id.in_(all_folder_ids),
+            LibraryFile.is_external.is_(True),
+        )
     )
     existing_files = {f.file_path: f for f in existing_result.scalars().all()}
 
+    # Build folder cache: relative path -> folder_id (for resolving subfolders)
+    # Pre-populate with existing child folders keyed by their external_path
+    folder_cache: dict[str, int] = {"": folder_id}
+    for fid in all_folder_ids:
+        if fid == folder_id:
+            continue
+        # Find the child folder object
+        for cf in all_child_folders:
+            if cf.id == fid and cf.external_path:
+                try:
+                    rel = str(Path(cf.external_path).relative_to(ext_path))
+                    if rel != ".":
+                        folder_cache[rel] = cf.id
+                except ValueError:
+                    pass
+
     # Scan the directory
     added = 0
     removed = 0
-    found_paths = set()
+    found_paths: set[str] = set()
+    seen_rel_dirs: set[str] = set()
+
+    for dirpath, dirnames, filenames in os.walk(ext_path):
+        # Filter hidden directories unless configured
+        if not folder.external_show_hidden:
+            dirnames[:] = [d for d in dirnames if not d.startswith(".")]
+
+        rel_dir = str(Path(dirpath).relative_to(ext_path))
+        if rel_dir == ".":
+            rel_dir = ""
+        seen_rel_dirs.add(rel_dir)
+
+        # Resolve or create subfolder chain for this directory
+        if rel_dir and rel_dir not in folder_cache:
+            parts = Path(rel_dir).parts
+            current_path = ""
+            current_parent = folder_id
+            for part in parts:
+                current_path = f"{current_path}/{part}".lstrip("/")
+                if current_path in folder_cache:
+                    current_parent = folder_cache[current_path]
+                else:
+                    existing_sub = await db.execute(
+                        select(LibraryFolder).where(
+                            LibraryFolder.name == part,
+                            LibraryFolder.parent_id == current_parent,
+                            LibraryFolder.is_external.is_(True),
+                        )
+                    )
+                    existing_folder = existing_sub.scalar_one_or_none()
+                    if existing_folder:
+                        current_parent = existing_folder.id
+                    else:
+                        new_folder = LibraryFolder(
+                            name=part,
+                            parent_id=current_parent,
+                            is_external=True,
+                            external_path=str(ext_path / current_path),
+                            external_readonly=folder.external_readonly,
+                            external_show_hidden=folder.external_show_hidden,
+                        )
+                        db.add(new_folder)
+                        await db.flush()
+                        current_parent = new_folder.id
+                    folder_cache[current_path] = current_parent
+
+        target_folder_id = folder_cache.get(rel_dir, folder_id)
 
-    for dirpath, _dirnames, filenames in os.walk(ext_path):
         for filename in filenames:
             # Skip hidden files unless configured
             if not folder.external_show_hidden and filename.startswith("."):
@@ -838,16 +927,33 @@ async def scan_external_folder(
             if file_type == "3mf":
                 try:
                     parser = ThreeMFParser(str(filepath))
-                    meta = parser.parse()
-                    if meta:
-                        file_metadata = meta
-                    thumb_data = parser.extract_thumbnail()
-                    if thumb_data:
-                        thumb_dir = get_library_thumbnails_dir()
-                        thumb_filename = f"{uuid.uuid4().hex}.png"
-                        thumb_full = thumb_dir / thumb_filename
-                        thumb_full.write_bytes(thumb_data)
-                        thumbnail_path = to_relative_path(thumb_full)
+                    raw_metadata = parser.parse()
+                    if raw_metadata:
+                        # Extract thumbnail before cleaning metadata
+                        thumb_data = raw_metadata.get("_thumbnail_data")
+                        thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png")
+                        if thumb_data:
+                            thumb_dir = get_library_thumbnails_dir()
+                            thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
+                            thumb_full = thumb_dir / thumb_filename
+                            thumb_full.write_bytes(thumb_data)
+                            thumbnail_path = to_relative_path(thumb_full)
+
+                        # Clean metadata - remove non-JSON-serializable data (bytes, etc.)
+                        def clean_metadata(obj):
+                            if isinstance(obj, dict):
+                                return {
+                                    k: clean_metadata(v)
+                                    for k, v in obj.items()
+                                    if not isinstance(v, bytes) and k not in ("_thumbnail_data", "_thumbnail_ext")
+                                }
+                            elif isinstance(obj, list):
+                                return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]
+                            elif isinstance(obj, bytes):
+                                return None
+                            return obj
+
+                        file_metadata = clean_metadata(raw_metadata)
                 except Exception as e:
                     logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
 
@@ -878,7 +984,7 @@ async def scan_external_folder(
                     thumbnail_path = to_relative_path(Path(thumbnail_path_str))
 
             db_file = LibraryFile(
-                folder_id=folder_id,
+                folder_id=target_folder_id,
                 is_external=True,
                 filename=filename,
                 file_path=file_path_str,
@@ -905,6 +1011,26 @@ async def scan_external_folder(
             await db.delete(db_file)
             removed += 1
 
+    # Remove empty subfolders whose directories no longer exist on disk
+    # Process deepest-first by sorting on path depth (descending)
+    subfolder_entries = [(rel, fid) for rel, fid in folder_cache.items() if rel and fid != folder_id]
+    subfolder_entries.sort(key=lambda x: x[0].count("/"), reverse=True)
+    for rel_path, sub_fid in subfolder_entries:
+        if rel_path in seen_rel_dirs:
+            continue  # Directory still exists on disk
+        # Check if subfolder has any remaining files
+        file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == sub_fid))
+        if (file_count_result.scalar() or 0) == 0:
+            # Check if it has any remaining child folders
+            child_count_result = await db.execute(
+                select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == sub_fid)
+            )
+            if (child_count_result.scalar() or 0) == 0:
+                sub_folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == sub_fid))
+                sub_folder_obj = sub_folder_result.scalar_one_or_none()
+                if sub_folder_obj:
+                    await db.delete(sub_folder_obj)
+
     await db.commit()
 
     return {"status": "success", "added": added, "removed": removed}
@@ -918,14 +1044,16 @@ async def scan_external_folder(
 async def list_files(
     response: Response,
     folder_id: int | None = None,
+    project_id: int | None = None,
     include_root: bool = True,
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
-    """List files, optionally filtered by folder.
+    """List files, optionally filtered by folder or project.
 
     Args:
         folder_id: Filter by folder ID. If None and include_root=True, returns root files.
+        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.
                      If False and folder_id is None, returns all files.
     """
@@ -933,6 +1061,10 @@ async def list_files(
 
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
+    elif project_id is not None:
+        # Single join instead of one query per folder (avoids N+1 pattern)
+        query = query.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        query = query.where(LibraryFolder.project_id == project_id)
     elif include_root:
         query = query.where(LibraryFile.folder_id.is_(None))
 
@@ -1871,6 +2003,7 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail image for a specific plate from a library file."""
     from starlette.responses import Response
@@ -2036,7 +2169,7 @@ async def print_library_file(
     printer_id: int,
     body: FilePrintRequest | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
 ):
     """Dispatch a library file for send/start on a printer.
 
@@ -2083,6 +2216,12 @@ async def print_library_file(
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(status_code=400, detail="Printer is not connected")
 
+    # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
+    if body.project_id is not None:
+        project_result = await db.execute(select(Project).where(Project.id == body.project_id))
+        if not project_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Project not found")
+
     plate_name = body.plate_name
     if not plate_name and body.plate_id is not None:
         plate_name = f"Plate {body.plate_id}"
@@ -2098,8 +2237,9 @@ async def print_library_file(
             printer_id=printer_id,
             printer_name=printer.name,
             options=body.model_dump(exclude_none=True),
-            requested_by_user_id=None,
-            requested_by_username=None,
+            project_id=body.project_id,
+            requested_by_user_id=current_user.id if current_user else None,
+            requested_by_username=current_user.username if current_user else None,
         )
     except DispatchEnqueueRejected as e:
         raise HTTPException(status_code=409, detail=str(e)) from e
@@ -2309,6 +2449,7 @@ async def delete_file(
         logger.warning("Failed to delete file from disk: %s", e)
 
     await db.delete(file)
+    await db.commit()
 
     return {"status": "success", "message": "File deleted"}
 
@@ -2358,7 +2499,7 @@ async def create_library_slicer_token(
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
 
-    token = create_slicer_download_token("library", file_id)
+    token = await create_slicer_download_token("library", file_id)
     return {"token": token}
 
 
@@ -2377,7 +2518,7 @@ async def download_library_file_for_slicer(
     """
     from backend.app.core.auth import verify_slicer_download_token
 
-    if not verify_slicer_download_token(token, "library", file_id):
+    if not await verify_slicer_download_token(token, "library", file_id):
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
 
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
@@ -2397,7 +2538,11 @@ async def download_library_file_for_slicer(
 
 
 @router.get("/files/{file_id}/thumbnail")
-async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
+async def get_thumbnail(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
+):
     """Get a file's thumbnail."""
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
@@ -2571,6 +2716,8 @@ async def bulk_delete(
             await db.delete(folder)
             deleted_folders += 1
 
+    await db.commit()
+
     return BulkDeleteResponse(deleted_files=deleted_files, deleted_folders=deleted_folders)
 
 

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

@@ -0,0 +1,104 @@
+"""API routes for scheduled local backups."""
+
+import logging
+
+from fastapi import APIRouter, Path
+from fastapi.responses import FileResponse, JSONResponse
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.local_backup import local_backup_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/local-backup", tags=["local-backup"])
+
+
+@router.get("/status")
+async def get_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Get local backup scheduler status and configuration."""
+    settings = await local_backup_service._load_settings()
+    status = local_backup_service.get_status()
+    return {
+        **status,
+        "enabled": settings["enabled"],
+        "schedule": settings["schedule"],
+        "time": settings["time"],
+        "retention": settings["retention"],
+        "path": settings["path"],
+        "default_path": str(local_backup_service._resolve_backup_dir("")),
+    }
+
+
+@router.post("/run")
+async def trigger_backup(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Trigger a local backup immediately."""
+    result = await local_backup_service.run_backup()
+    return result
+
+
+@router.get("/backups")
+async def list_backups(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """List existing backup files."""
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.list_backups(settings["path"])
+
+
+@router.get("/backups/{filename}/download")
+async def download_backup(
+    filename: str = Path(..., description="Backup filename to download"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Download a specific backup file."""
+    settings = await local_backup_service._load_settings()
+    file_path = local_backup_service.resolve_backup_file(settings["path"], filename)
+    if file_path is None:
+        return JSONResponse(status_code=404, content={"success": False, "message": "Backup not found"})
+    return FileResponse(
+        path=file_path,
+        filename=filename,
+        media_type="application/zip",
+    )
+
+
+@router.post("/backups/{filename}/restore")
+async def restore_backup(
+    filename: str = Path(..., description="Backup filename to restore"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
+):
+    """Restore from a scheduled backup file on the server."""
+    import io
+
+    from fastapi import UploadFile
+    from fastapi.responses import JSONResponse
+
+    settings = await local_backup_service._load_settings()
+    file_path = local_backup_service.resolve_backup_file(settings["path"], filename)
+    if file_path is None:
+        return JSONResponse(status_code=404, content={"success": False, "message": "Backup not found"})
+
+    from backend.app.api.routes.settings import restore_backup as settings_restore_backup
+    from backend.app.core.database import async_session
+
+    content = file_path.read_bytes()
+    upload = UploadFile(filename=filename, file=io.BytesIO(content))
+
+    async with async_session() as db:
+        return await settings_restore_backup(file=upload, db=db)
+
+
+@router.delete("/backups/{filename}")
+async def delete_backup(
+    filename: str = Path(..., description="Backup filename to delete"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Delete a specific backup file."""
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.delete_backup(settings["path"], filename)

+ 1693 - 0
backend/app/api/routes/mfa.py

@@ -0,0 +1,1693 @@
+"""2FA (TOTP + Email OTP) and OIDC authentication routes.
+
+Security model
+--------------
+* Pre-auth tokens  : secrets.token_urlsafe(32) stored in-memory with a 5-minute TTL.
+  They are single-use and do NOT grant access to any protected resource.
+* TOTP codes       : verified with pyotp (30-second window, ±1 step tolerance).
+* Email OTP codes  : 6-digit numeric, hashed with pbkdf2_sha256, 10-minute TTL,
+  max 5 failed attempts per code before invalidation.
+* Backup codes     : 10 × 8-char alphanumeric codes, each stored as pbkdf2_sha256 hash,
+  single-use.
+* OIDC state       : secrets.token_urlsafe(32) bound to provider_id + nonce, 10-minute TTL.
+* OIDC exchange    : secrets.token_urlsafe(32), 2-minute TTL, single-use.
+* Rate limiting    : max 5 failed 2FA verification attempts per user within 15 minutes.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import io
+import logging
+import os
+import re
+import secrets
+import string
+import urllib.parse
+from datetime import datetime, timedelta, timezone
+
+import httpx
+import jwt
+import pyotp
+from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
+from fastapi.responses import RedirectResponse
+from jwt import PyJWKClient
+from passlib.context import CryptContext
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.api.routes.settings import get_setting, set_setting
+from backend.app.core.auth import (
+    ACCESS_TOKEN_EXPIRE_MINUTES,
+    RequirePermissionIfAuthEnabled,
+    create_access_token,
+    get_current_active_user,
+    get_user_by_email,
+    get_user_by_username,
+    is_auth_enabled,
+    verify_password,
+)
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
+from backend.app.models.group import Group
+from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+from backend.app.models.user import User
+from backend.app.models.user_otp_code import UserOTPCode
+from backend.app.models.user_totp import UserTOTP
+from backend.app.schemas.auth import (
+    AdminDisable2FARequest,
+    BackupCodesResponse,
+    EmailOTPDisableRequest,
+    EmailOTPEnableConfirmRequest,
+    EmailOTPSendRequest,
+    GroupBrief,
+    LoginResponse,
+    OIDCAuthorizeResponse,
+    OIDCExchangeRequest,
+    OIDCLinkResponse,
+    OIDCProviderCreate,
+    OIDCProviderResponse,
+    OIDCProviderUpdate,
+    TOTPDisableRequest,
+    TOTPEnableRequest,
+    TOTPEnableResponse,
+    TOTPSetupRequest,
+    TOTPSetupResponse,
+    TwoFAStatusResponse,
+    TwoFAVerifyRequest,
+    TwoFAVerifyResponse,
+    UserResponse,
+)
+from backend.app.services.email_service import get_smtp_settings, send_email
+
+logger = logging.getLogger(__name__)
+
+
+def _as_utc(dt: datetime) -> datetime:
+    """Return *dt* with UTC timezone attached.
+
+    SQLite/aiosqlite strips timezone info when reading DateTime(timezone=True)
+    columns back – the stored value is always UTC, so we just re-attach the
+    info when doing Python-level comparisons.
+    """
+    return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
+
+
+# ---------------------------------------------------------------------------
+# Passlib context (same scheme as auth.py)
+# ---------------------------------------------------------------------------
+pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+
+# ---------------------------------------------------------------------------
+# TTL / rate-limit constants
+# ---------------------------------------------------------------------------
+MAX_2FA_ATTEMPTS = 5
+MAX_LOGIN_ATTEMPTS = 10
+LOCKOUT_WINDOW = timedelta(minutes=15)
+MAX_EMAIL_OTP_SENDS = 3
+EMAIL_OTP_SEND_WINDOW = timedelta(minutes=10)
+PRE_AUTH_TOKEN_TTL = timedelta(minutes=5)
+OIDC_STATE_TTL = timedelta(minutes=10)
+OIDC_EXCHANGE_TTL = timedelta(minutes=2)
+
+# ---------------------------------------------------------------------------
+# Router
+# ---------------------------------------------------------------------------
+router = APIRouter(prefix="/auth", tags=["2fa", "oidc"])
+
+
+# ---------------------------------------------------------------------------
+# Helper: user response
+# ---------------------------------------------------------------------------
+def _user_to_response(user: User) -> UserResponse:
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        email=user.email,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
+
+# ---------------------------------------------------------------------------
+# Helper: QR code generation
+# ---------------------------------------------------------------------------
+def _generate_totp_qr_b64(provisioning_uri: str) -> str:
+    """Generate a base64-encoded PNG QR code for the given TOTP provisioning URI."""
+    import qrcode  # type: ignore
+
+    qr = qrcode.QRCode(box_size=6, border=2)
+    qr.add_data(provisioning_uri)
+    qr.make(fit=True)
+    img = qr.make_image(fill_color="black", back_color="white")
+    buf = io.BytesIO()
+    img.save(buf, format="PNG")
+    return base64.b64encode(buf.getvalue()).decode()
+
+
+# ---------------------------------------------------------------------------
+# Helper: backup code generation
+# ---------------------------------------------------------------------------
+def _generate_backup_codes() -> tuple[list[str], list[str]]:
+    """Return (plain_codes, hashed_codes) — 10 codes of 8 alphanumeric chars each."""
+    alphabet = string.ascii_uppercase + string.digits
+    plain = ["".join(secrets.choice(alphabet) for _ in range(8)) for _ in range(10)]
+    hashed = [pwd_context.hash(c) for c in plain]
+    return plain, hashed
+
+
+# ---------------------------------------------------------------------------
+# DB-backed pre-auth token helpers
+# ---------------------------------------------------------------------------
+async def create_pre_auth_token(db: AsyncSession, username: str, challenge_id: str | None = None) -> str:
+    """Create a single-use pre-auth token stored in the DB.
+
+    Pass ``challenge_id`` (from the HttpOnly 2fa_challenge cookie) to bind the
+    token to the originating browser session.  The same value must be present as
+    a cookie on every subsequent call that consumes this token.
+    """
+    now = datetime.now(timezone.utc)
+    # Prune expired tokens opportunistically (keep table small)
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
+            AuthEphemeralToken.expires_at < now,
+        )
+    )
+    token = secrets.token_urlsafe(32)
+    db.add(
+        AuthEphemeralToken(
+            token=token,
+            token_type=TokenType.PRE_AUTH,
+            username=username,
+            challenge_id=challenge_id,
+            expires_at=now + PRE_AUTH_TOKEN_TTL,
+        )
+    )
+    await db.commit()
+    return token
+
+
+async def consume_pre_auth_token(db: AsyncSession, token: str, challenge_id: str | None = None) -> str | None:
+    """Atomically validate and consume a pre-auth token. Returns username or None.
+
+    Uses DELETE...RETURNING so two concurrent requests with the same token cannot
+    both succeed — only the first DELETE finds the row.
+
+    M5: When challenge_id is provided, also enforces the cookie-binding constraint
+    so a stolen token cannot be replayed from a different browser session.
+    """
+    now = datetime.now(timezone.utc)
+    result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == token,
+            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
+            AuthEphemeralToken.expires_at > now,
+        )
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id)
+    )
+    row = result.one_or_none()
+    if row is None:
+        return None
+    username, stored_challenge_id = row
+    # Enforce client binding: if the token was issued with a challenge_id,
+    # the caller must supply the matching value.
+    if stored_challenge_id is not None and stored_challenge_id != challenge_id:
+        await db.rollback()
+        return None
+    await db.commit()
+    return username
+
+
+async def peek_pre_auth_token(db: AsyncSession, token: str, challenge_id: str | None = None) -> str | None:
+    """Validate a pre-auth token and return the username WITHOUT consuming it.
+
+    When the stored token has a ``challenge_id`` (client-binding cookie), the
+    caller must supply the matching value.  A mismatch is treated as an invalid
+    token — no information leakage about whether the token itself exists.
+    """
+    now = datetime.now(timezone.utc)
+    result = await db.execute(
+        select(AuthEphemeralToken).where(
+            AuthEphemeralToken.token == token,
+            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
+            AuthEphemeralToken.expires_at > now,
+        )
+    )
+    eph = result.scalar_one_or_none()
+    if eph is None:
+        return None
+    # Enforce client binding: if the token was issued with a challenge_id the
+    # cookie must match.  Treat a mismatch as if the token doesn't exist.
+    if eph.challenge_id is not None and eph.challenge_id != challenge_id:
+        return None
+    return eph.username
+
+
+# ---------------------------------------------------------------------------
+# DB-backed rate-limiting helpers
+# ---------------------------------------------------------------------------
+async def check_rate_limit(
+    db: AsyncSession,
+    username: str,
+    event_type: str = EventType.TWO_FA_ATTEMPT,
+    max_attempts: int = MAX_2FA_ATTEMPTS,
+) -> None:
+    """Raise HTTP 429 if the user has exceeded the failed attempt limit.
+
+    The username is normalised to lower-case so case-variant attempts
+    (which all resolve to the same user) share the same rate-limit bucket.
+
+    L-2: Known TOCTOU — the SELECT (count) and the subsequent INSERT
+    (record_failed_attempt) are not atomic.  Two concurrent requests can both
+    read a count below the threshold and both proceed.  This is an inherent
+    trade-off of the event-log rate-limit pattern: fixing it would require
+    a serialising lock (SELECT FOR UPDATE on a dedicated counter row), which
+    adds contention and is not worth it for a soft rate-limit whose window is
+    already measured in minutes.  In practice the race window is microseconds
+    and the limit can be slightly exceeded only under precise concurrent timing.
+    """
+    username_key = username.lower()
+    now = datetime.now(timezone.utc)
+    cutoff = now - LOCKOUT_WINDOW
+    result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == username_key,
+            AuthRateLimitEvent.event_type == event_type,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    recent_count = len(result.scalars().all())
+    if recent_count >= max_attempts:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail="Too many failed attempts. Please try again later.",
+        )
+
+
+async def record_failed_attempt(db: AsyncSession, username: str, event_type: str = EventType.TWO_FA_ATTEMPT) -> None:
+    """Record a failed attempt for rate-limiting purposes."""
+    db.add(AuthRateLimitEvent(username=username.lower(), event_type=event_type))
+    await db.commit()
+
+
+async def clear_failed_attempts(db: AsyncSession, username: str, event_type: str = EventType.TWO_FA_ATTEMPT) -> None:
+    """Delete all recorded failed attempts for a user on successful verification."""
+    await db.execute(
+        delete(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == username.lower(),
+            AuthRateLimitEvent.event_type == event_type,
+        )
+    )
+    await db.commit()
+
+
+async def check_email_otp_send_rate(db: AsyncSession, username: str) -> None:
+    """Raise HTTP 429 if the user has requested too many OTP emails recently.
+
+    I1: This function only *checks* the limit.  The caller is responsible for
+    recording the slot via ``record_email_otp_send`` **after** the email has
+    been sent successfully.  This prevents failed sends from consuming a slot
+    (wasting the user's quota) and makes it impossible to farm rate-limit events
+    without actually triggering a send.
+    """
+    username_key = username.lower()
+    now = datetime.now(timezone.utc)
+    cutoff = now - EMAIL_OTP_SEND_WINDOW
+    result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == username_key,
+            AuthRateLimitEvent.event_type == EventType.EMAIL_SEND,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    recent_count = len(result.scalars().all())
+    if recent_count >= MAX_EMAIL_OTP_SENDS:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail=f"Too many OTP email requests. Please wait {EMAIL_OTP_SEND_WINDOW.seconds // 60} minutes.",
+        )
+
+
+async def record_email_otp_send(db: AsyncSession, username: str) -> None:
+    """Record a successful OTP email send for rate-limiting purposes (I1).
+
+    Must be called *after* the email has been sent successfully so that failed
+    sends do not consume a slot from the user's quota.
+    """
+    db.add(AuthRateLimitEvent(username=username.lower(), event_type=EventType.EMAIL_SEND))
+    await db.commit()
+
+
+# ---------------------------------------------------------------------------
+# TOTP replay-protection helper
+# ---------------------------------------------------------------------------
+def _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code: str) -> None:
+    """Raise HTTP 400 if this TOTP code was already accepted in its time window.
+
+    M3 fix: store the counter of the *accepted* code rather than the current
+    wall-clock counter.  With valid_window=1, pyotp accepts codes from the
+    previous 30-second step.  Using timecode(now) would store the wrong counter
+    when the previous-window code is accepted, allowing immediate replay.
+    """
+    # Determine which time-step the accepted code belongs to.
+    now = datetime.now(timezone.utc)
+    accepted_counter: int | None = None
+    for offset in (0, -1):  # current window first, then previous
+        candidate_time = now.timestamp() + offset * totp_obj.interval
+        candidate_counter = totp_obj.timecode(datetime.fromtimestamp(candidate_time, tz=timezone.utc))
+        if totp_obj.at(candidate_counter) == code:
+            accepted_counter = candidate_counter
+            break
+    if accepted_counter is None:
+        accepted_counter = totp_obj.timecode(now)  # fallback (should not happen after verify())
+
+    totp_record.accept_counter(accepted_counter)
+
+
+# ---------------------------------------------------------------------------
+# Settings helpers (email 2FA flag)
+# ---------------------------------------------------------------------------
+async def _get_email_2fa_enabled(db: AsyncSession, user_id: int) -> bool:
+    val = await get_setting(db, f"user_{user_id}_email_2fa_enabled")
+    return val == "true"
+
+
+async def _set_email_2fa_enabled(db: AsyncSession, user_id: int, enabled: bool) -> None:
+    await set_setting(db, f"user_{user_id}_email_2fa_enabled", "true" if enabled else "false")
+
+
+# ===========================================================================
+# 2FA Endpoints
+# ===========================================================================
+
+
+@router.get("/2fa/status", response_model=TwoFAStatusResponse)
+async def get_2fa_status(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> TwoFAStatusResponse:
+    """Return the current 2FA configuration for the authenticated user."""
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    totp_enabled = totp_record is not None and totp_record.is_enabled
+    backup_codes_remaining = len(totp_record.backup_code_hashes) if totp_record else 0
+    email_otp_enabled = await _get_email_2fa_enabled(db, current_user.id)
+
+    return TwoFAStatusResponse(
+        totp_enabled=totp_enabled,
+        email_otp_enabled=email_otp_enabled,
+        backup_codes_remaining=backup_codes_remaining,
+    )
+
+
+@router.post("/2fa/totp/setup", response_model=TOTPSetupResponse)
+async def setup_totp(
+    body: TOTPSetupRequest | None = Body(default=None),
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> TOTPSetupResponse:
+    """Initiate TOTP setup: generates a new secret and QR code.
+
+    Creates (or replaces) a pending UserTOTP record with is_enabled=False.
+    The caller must confirm with POST /auth/2fa/totp/enable.
+
+    M-R7-A: If an *active* TOTP is already configured, the caller must supply
+    the current TOTP code in the request body to confirm intent before the
+    secret is overwritten (prevents silently locking out the real user).
+    """
+    if not await is_auth_enabled(db):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is not enabled")
+
+    # Upsert a pending TOTP record (is_enabled=False)
+    existing = (await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))).scalar_one_or_none()
+
+    # M-R7-A: Guard against silent TOTP replacement when one is already active.
+    if existing and existing.is_enabled:
+        await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        supplied_code = (body.code if body else None) or ""
+        if not pyotp.TOTP(existing.secret).verify(supplied_code, valid_window=1):
+            await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Current TOTP code required to replace an active authenticator",
+            )
+        await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        _assert_totp_not_replayed(pyotp.TOTP(existing.secret), existing, supplied_code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+
+    secret = pyotp.random_base32()
+    totp = pyotp.TOTP(secret)
+    provisioning_uri = totp.provisioning_uri(name=current_user.username, issuer_name="Bambuddy")
+    qr_b64 = _generate_totp_qr_b64(provisioning_uri)
+
+    if existing:
+        existing.secret = secret
+        existing.is_enabled = False
+        existing.backup_code_hashes = []
+    else:
+        db.add(UserTOTP(user_id=current_user.id, secret=secret, is_enabled=False))
+
+    await db.commit()
+
+    return TOTPSetupResponse(secret=secret, qr_code_b64=qr_b64, issuer="Bambuddy")
+
+
+@router.post("/2fa/totp/enable", response_model=TOTPEnableResponse)
+async def enable_totp(
+    body: TOTPEnableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> TOTPEnableResponse:
+    """Confirm TOTP setup by verifying a code from the authenticator app.
+
+    On success, enables TOTP and returns 10 single-use backup codes (shown once).
+    L-R7-A: Rate-limited to prevent brute-forcing the 6-digit confirmation code.
+    """
+    # L-R7-A: Rate-limit the enable step to prevent brute-forcing the 6-digit code.
+    await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    if not totp_record:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP setup not initiated. Call /auth/2fa/totp/setup first."
+        )
+
+    if not pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1):
+        await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
+
+    await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+    plain_codes, hashed_codes = _generate_backup_codes()
+    totp_record.is_enabled = True
+    totp_record.backup_code_hashes = hashed_codes
+    await db.commit()
+
+    return TOTPEnableResponse(
+        message="TOTP enabled successfully. Store your backup codes in a safe place.",
+        backup_codes=plain_codes,
+    )
+
+
+@router.post("/2fa/totp/disable")
+async def disable_totp(
+    body: TOTPDisableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Disable TOTP by verifying a valid TOTP code or a backup code.
+
+    I10: Rate-limited to prevent backup-code brute-forcing from a hijacked session.
+    """
+    await check_rate_limit(db, current_user.username)
+
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    if not totp_record or not totp_record.is_enabled:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled")
+
+    # Accept either a valid TOTP code or a valid backup code
+    totp_obj = pyotp.TOTP(totp_record.secret)
+    code_valid = totp_obj.verify(body.code, valid_window=1)
+    if code_valid:
+        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+    else:
+        # Check backup codes — always iterate all entries (L-R9-A: no early break
+        # to avoid timing oracle based on code position in the list).
+        for hashed in totp_record.backup_code_hashes:
+            if pwd_context.verify(body.code, hashed):
+                code_valid = True
+
+    if not code_valid:
+        await record_failed_attempt(db, current_user.username)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid code")
+
+    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    await db.commit()
+    return {"message": "TOTP disabled"}
+
+
+@router.post("/2fa/totp/regenerate-backup-codes", response_model=BackupCodesResponse)
+async def regenerate_backup_codes(
+    body: TOTPDisableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> BackupCodesResponse:
+    """Generate 10 new backup codes. Requires a valid TOTP code OR a backup code.
+
+    M10: Accepts backup codes for consistency with disable_totp — users who have
+    lost their authenticator app but still have backup codes can regenerate.
+    Rate-limited to prevent brute-forcing from a hijacked session.
+    """
+    await check_rate_limit(db, current_user.username)
+
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    if not totp_record or not totp_record.is_enabled:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled")
+
+    totp_obj = pyotp.TOTP(totp_record.secret)
+    code_valid = totp_obj.verify(body.code, valid_window=1)
+    if code_valid:
+        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+    else:
+        # Accept a backup code as an alternative (M10)
+        matched_index: int | None = None
+        for idx, hashed in enumerate(totp_record.backup_code_hashes):
+            if pwd_context.verify(body.code, hashed) and matched_index is None:
+                matched_index = idx
+        if matched_index is None:
+            await record_failed_attempt(db, current_user.username)
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP or backup code")
+        # Remove the used backup code
+        totp_record.backup_code_hashes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]
+
+    plain_codes, hashed_codes = _generate_backup_codes()
+    totp_record.backup_code_hashes = hashed_codes
+    await db.commit()
+
+    return BackupCodesResponse(
+        backup_codes=plain_codes,
+        message="Backup codes regenerated. Store them safely — they will not be shown again.",
+    )
+
+
+@router.post("/2fa/email/enable")
+async def enable_email_otp(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Step 1 of email OTP enable: send a verification code to the user's email.
+
+    C5: Proof of possession — the user must prove they control the registered email
+    address before email 2FA is activated.  Returns a ``setup_token`` that must be
+    passed to POST /auth/2fa/email/enable/confirm together with the received code.
+    H-3: Rate-limited to prevent email flooding via repeated calls to this endpoint.
+    """
+    await check_email_otp_send_rate(db, current_user.username)
+    if not current_user.email:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="You must have an email address configured to enable email OTP 2FA",
+        )
+
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email service is not configured")
+
+    # Generate and store the setup token (reuse AuthEphemeralToken with type "email_otp_setup")
+    now = datetime.now(timezone.utc)
+    # Prune any existing pending setup tokens for this user
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,
+            AuthEphemeralToken.username == current_user.username,
+        )
+    )
+
+    code = str(secrets.randbelow(1_000_000)).zfill(6)
+    code_hash = pwd_context.hash(code)
+    setup_token = secrets.token_urlsafe(32)
+
+    db.add(
+        AuthEphemeralToken(
+            token=setup_token,
+            token_type=TokenType.EMAIL_OTP_SETUP,
+            username=current_user.username,
+            # Reuse the nonce field to store the code hash
+            nonce=code_hash,
+            expires_at=now + timedelta(minutes=10),
+        )
+    )
+    await db.commit()
+
+    try:
+        send_email(
+            smtp_settings=smtp_settings,
+            to_email=current_user.email,
+            subject="Verify your Bambuddy email address for 2FA",
+            body_text=(
+                f"Your Bambuddy email 2FA setup code is: {code}\n\n"
+                "Enter this code to confirm email-based two-factor authentication.\n"
+                "The code expires in 10 minutes."
+            ),
+            body_html=(
+                "<p>To enable <strong>email-based two-factor authentication</strong> on your Bambuddy account, "
+                "enter the code below:</p>"
+                f"<h2 style='letter-spacing:4px'>{code}</h2>"
+                "<p>The code expires in <strong>10 minutes</strong>. "
+                "If you did not request this, you can safely ignore this email.</p>"
+            ),
+        )
+        await record_email_otp_send(db, current_user.username)
+    except Exception as exc:
+        logger.error("Failed to send email OTP setup code to user_id=%d: %s", current_user.id, exc)
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send verification email"
+        )
+
+    return {"message": "Verification code sent to your email address", "setup_token": setup_token}
+
+
+@router.post("/2fa/email/enable/confirm")
+async def confirm_enable_email_otp(
+    body: EmailOTPEnableConfirmRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Step 2 of email OTP enable: verify the code and activate email 2FA.
+
+    H-2 fix: Uses peek-then-consume so a wrong code does NOT burn the setup token.
+    The token is only deleted after successful code verification, allowing retries
+    up to the rate limit (5 attempts / 15 min).
+    M4: Rate-limited to prevent brute-forcing the 6-digit setup code.
+    """
+    await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+    now = datetime.now(timezone.utc)
+
+    # --- Peek: validate token without consuming ---
+    peek_result = await db.execute(
+        select(AuthEphemeralToken).where(
+            AuthEphemeralToken.token == body.setup_token,
+            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,
+            AuthEphemeralToken.username == current_user.username,
+            AuthEphemeralToken.expires_at > now,
+        )
+    )
+    eph = peek_result.scalar_one_or_none()
+    if eph is None:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
+
+    code_hash = eph.nonce  # code hash stored in the nonce field
+
+    # --- Verify code before consuming the token ---
+    if not pwd_context.verify(body.code, code_hash):
+        await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
+
+    # --- Atomically consume the token now that the code is correct ---
+    # DELETE...RETURNING prevents a concurrent request from using the same token.
+    del_result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == body.setup_token,
+            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,
+            AuthEphemeralToken.username == current_user.username,
+        )
+        .returning(AuthEphemeralToken.id)
+    )
+    if del_result.one_or_none() is None:
+        # Concurrent request consumed it between peek and delete — treat as invalid.
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
+
+    await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+    await _set_email_2fa_enabled(db, current_user.id, True)
+    await db.commit()
+    return {"message": "Email OTP 2FA enabled"}
+
+
+@router.post("/2fa/email/disable")
+async def disable_email_otp(
+    body: EmailOTPDisableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Disable email-based OTP 2FA for the current user.
+
+    C6: Re-authentication required — the caller must supply their account password
+    to prevent a hijacked session from silently removing a second factor.
+    LDAP/OIDC-only users (no local password) are exempt from this check.
+    H-2: Rate-limited to prevent brute-forcing the password via this endpoint.
+    """
+    await check_rate_limit(db, current_user.username)
+    if current_user.password_hash:
+        if not verify_password(body.password, current_user.password_hash):
+            await record_failed_attempt(db, current_user.username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
+    await _set_email_2fa_enabled(db, current_user.id, False)
+    await db.commit()
+    return {"message": "Email OTP 2FA disabled"}
+
+
+@router.post("/2fa/email/send")
+async def send_email_otp(
+    request: Request,
+    body: EmailOTPSendRequest,
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Send a 6-digit OTP code to the user's email address.
+
+    Requires a valid pre_auth_token obtained during the login flow.
+    """
+    # Peek (validate without consuming) first so a rate-limit rejection does not
+    # permanently burn the caller's pre-auth token.
+    challenge_id = request.cookies.get("2fa_challenge")
+    username = await peek_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+    # Enforce rate limit BEFORE consuming the token to prevent OTP email flooding.
+    await check_email_otp_send_rate(db, username)
+
+    user = await get_user_by_username(db, username)
+    if not user or not user.is_active:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+    if not user.email:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no email address configured")
+
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email service is not configured")
+
+    # Invalidate all existing unused OTP codes for this user (staged, not yet committed)
+    await db.execute(
+        UserOTPCode.__table__.update()  # type: ignore[attr-defined]
+        .where(UserOTPCode.user_id == user.id)
+        .where(UserOTPCode.used.is_(False))
+        .values(used=True)
+    )
+
+    # Generate a 6-digit code and stage the record (not committed yet)
+    code = str(secrets.randbelow(1_000_000)).zfill(6)
+    code_hash = pwd_context.hash(code)
+    expires_at = datetime.now(timezone.utc) + timedelta(minutes=UserOTPCode.OTP_TTL_MINUTES)
+
+    otp_record = UserOTPCode(
+        user_id=user.id,
+        code_hash=code_hash,
+        attempts=0,
+        used=False,
+        expires_at=expires_at,
+    )
+    db.add(otp_record)
+
+    # M2: Send the email BEFORE consuming the pre-auth token.
+    # If the send fails we raise an exception here; the session is uncommitted so
+    # the OTP record is discarded and the original token remains valid for retry.
+    try:
+        send_email(
+            smtp_settings=smtp_settings,
+            to_email=user.email,
+            subject="Your Bambuddy verification code",
+            body_text=f"Your Bambuddy login code is: {code}\n\nThis code expires in {UserOTPCode.OTP_TTL_MINUTES} minutes and can only be used once.",
+            body_html=(
+                f"<p>Your <strong>Bambuddy</strong> login verification code is:</p>"
+                f"<h2 style='letter-spacing:4px'>{code}</h2>"
+                f"<p>This code expires in <strong>{UserOTPCode.OTP_TTL_MINUTES} minutes</strong> and can only be used once.</p>"
+                f"<p>If you did not request this code, you can safely ignore this email.</p>"
+            ),
+        )
+        await record_email_otp_send(db, username)
+    except Exception as exc:
+        logger.error("Failed to send OTP email to user_id=%d: %s", user.id, exc)
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send OTP email")
+
+    # Email sent — now atomically consume the old token (this also commits the
+    # staged OTP record) and issue a fresh token for the verify step.
+    consumed = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not consumed:
+        # Raced with another request or token just expired — treat as invalid.
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+    # Re-issue a fresh pre-auth token bound to the same cookie so the binding
+    # carries forward through the email → verify step.
+    fresh_token = await create_pre_auth_token(db, username, challenge_id=challenge_id)
+
+    # Return the fresh pre-auth token so the frontend can proceed to verify
+    return {"message": "Code sent to your email address", "pre_auth_token": fresh_token}
+
+
+@router.post("/2fa/verify", response_model=TwoFAVerifyResponse)
+async def verify_2fa(
+    request: Request,
+    body: TwoFAVerifyRequest,
+    db: AsyncSession = Depends(get_db),
+) -> TwoFAVerifyResponse:
+    """Verify a 2FA code and exchange the pre_auth_token for a full JWT.
+
+    Accepted methods: ``totp``, ``email``, ``backup``.
+
+    The pre_auth_token is NOT consumed on failed verification attempts so the
+    user can retry without restarting the login flow.  It is only consumed once
+    verification succeeds, preventing token replay after success.
+    """
+    # Peek without consuming — bad codes must not burn the session token.
+    # Pass the HttpOnly challenge cookie so the binding check is enforced.
+    challenge_id = request.cookies.get("2fa_challenge")
+    username = await peek_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+    await check_rate_limit(db, username)
+
+    user = await get_user_by_username(db, username)
+    if not user or not user.is_active:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+    method = body.method
+
+    if method == "totp":
+        result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+        totp_record = result.scalar_one_or_none()
+        if not totp_record or not totp_record.is_enabled:
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user")
+        totp_obj = pyotp.TOTP(totp_record.secret)
+        if not totp_obj.verify(body.code, valid_window=1):
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code")
+        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+
+    elif method == "email":
+        now = datetime.now(timezone.utc)
+        result = await db.execute(
+            select(UserOTPCode)
+            .where(UserOTPCode.user_id == user.id)
+            .where(UserOTPCode.used.is_(False))
+            .where(UserOTPCode.expires_at > now)
+            .order_by(UserOTPCode.created_at.desc())
+        )
+        otp_record = result.scalar_one_or_none()
+        if not otp_record:
+            await record_failed_attempt(db, username)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED, detail="No valid OTP code found. Request a new one."
+            )
+
+        if otp_record.attempts >= UserOTPCode.MAX_ATTEMPTS:
+            otp_record.consume()
+            await db.commit()
+            await record_failed_attempt(db, username)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED, detail="OTP code has been invalidated after too many attempts"
+            )
+
+        if not pwd_context.verify(body.code, otp_record.code_hash):
+            otp_record.attempts += 1
+            await db.commit()
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid OTP code")
+
+        otp_record.consume()
+        await db.commit()
+
+    else:  # method == "backup"
+        result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+        totp_record = result.scalar_one_or_none()
+        if not totp_record or not totp_record.is_enabled:
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user")
+
+        # Always iterate all codes — no early break (L-R9-A: constant iteration
+        # count prevents timing oracle based on used-code position in the list).
+        matched_index: int | None = None
+        for idx, hashed in enumerate(totp_record.backup_code_hashes):
+            if pwd_context.verify(body.code, hashed) and matched_index is None:
+                matched_index = idx
+
+        if matched_index is None:
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid backup code")
+
+        # M1: Consume the pre-auth token FIRST (atomic single-use enforcement).
+        # Only if that succeeds do we remove the backup code — this prevents a race
+        # where two concurrent requests both pass code verification but only one
+        # should be granted a session.
+        consumed_username = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+        if not consumed_username:
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+        # Remove the used backup code now that the token is atomically consumed.
+        updated_codes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]
+        totp_record.backup_code_hashes = updated_codes
+        await db.commit()
+        await clear_failed_attempts(db, username)
+
+        access_token = create_access_token(
+            data={"sub": user.username},
+            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        )
+        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+        user = result.scalar_one()
+        return TwoFAVerifyResponse(access_token=access_token, token_type="bearer", user=_user_to_response(user))
+
+    # Verification succeeded (TOTP or email) — consume the pre-auth token.
+    # C-1: Check the return value; if None the token was already consumed by a
+    # concurrent request (race condition) — reject to prevent double-use.
+    consumed_username = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not consumed_username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+    await clear_failed_attempts(db, username)
+
+    access_token = create_access_token(
+        data={"sub": user.username},
+        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+    )
+
+    # Reload with groups for permission calculation
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
+    return TwoFAVerifyResponse(
+        access_token=access_token,
+        token_type="bearer",
+        user=_user_to_response(user),
+    )
+
+
+@router.delete("/2fa/admin/{user_id}")
+async def admin_disable_2fa(
+    user_id: int,
+    body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Admin endpoint: disable all 2FA for a given user.
+
+    Nit 3: Requires the admin's own password as a re-auth step (matching how
+    disable_email_otp protects a user's own 2FA removal). OIDC/LDAP-only admins
+    (no local password_hash) are exempt.
+    """
+    # Nit 3: Re-auth — admin must supply their own password.
+    if current_user and current_user.password_hash:
+        if not body.admin_password or not verify_password(body.admin_password, current_user.password_hash):
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin password required")
+
+    # Delete TOTP record
+    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
+
+    # Disable email 2FA setting
+    await _set_email_2fa_enabled(db, user_id, False)
+
+    # Invalidate all OTP codes
+    await db.execute(
+        UserOTPCode.__table__.update()  # type: ignore[attr-defined]
+        .where(UserOTPCode.user_id == user_id)
+        .values(used=True)
+    )
+
+    # I2: Invalidate existing JWTs for the target user by bumping password_changed_at.
+    # Without this, a stolen token remains valid after 2FA removal.
+    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
+    if target_user:
+        target_user.password_changed_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    actor = current_user.username if current_user else "anonymous"
+    logger.info("Admin %s disabled all 2FA for user_id=%d", actor, user_id)
+    return {"message": "2FA disabled for user"}
+
+
+# ===========================================================================
+# OIDC Endpoints
+# ===========================================================================
+
+
+@router.get("/oidc/providers", response_model=list[OIDCProviderResponse])
+async def list_oidc_providers(
+    db: AsyncSession = Depends(get_db),
+) -> list[OIDCProviderResponse]:
+    """List all enabled OIDC providers (public)."""
+    result = await db.execute(select(OIDCProvider).where(OIDCProvider.is_enabled.is_(True)))
+    providers = result.scalars().all()
+    return [OIDCProviderResponse.model_validate(p) for p in providers]
+
+
+@router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
+async def list_all_oidc_providers(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+) -> list[OIDCProviderResponse]:
+    """List ALL OIDC providers including disabled ones (admin only)."""
+    result2 = await db.execute(select(OIDCProvider))
+    providers = result2.scalars().all()
+    return [OIDCProviderResponse.model_validate(p) for p in providers]
+
+
+@router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
+async def create_oidc_provider(
+    body: OIDCProviderCreate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> OIDCProviderResponse:
+    """Create a new OIDC provider (admin only)."""
+    provider = OIDCProvider(
+        name=body.name,
+        issuer_url=body.issuer_url.rstrip("/"),
+        client_id=body.client_id,
+        client_secret=body.client_secret,
+        scopes=body.scopes,
+        is_enabled=body.is_enabled,
+        auto_create_users=body.auto_create_users,
+        icon_url=body.icon_url,
+    )
+    db.add(provider)
+    await db.commit()
+    await db.refresh(provider)
+    return OIDCProviderResponse.model_validate(provider)
+
+
+@router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
+async def update_oidc_provider(
+    provider_id: int,
+    body: OIDCProviderUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> OIDCProviderResponse:
+    """Update an existing OIDC provider (admin only)."""
+    result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+    provider = result2.scalar_one_or_none()
+    if not provider:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+
+    for field, value in body.model_dump(exclude_none=True).items():
+        if field == "issuer_url" and value:
+            value = value.rstrip("/")
+        setattr(provider, field, value)
+
+    await db.commit()
+    await db.refresh(provider)
+    return OIDCProviderResponse.model_validate(provider)
+
+
+@router.delete("/oidc/providers/{provider_id}")
+async def delete_oidc_provider(
+    provider_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Delete an OIDC provider and all its user links (admin only)."""
+    result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+    provider = result2.scalar_one_or_none()
+    if not provider:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+
+    await db.delete(provider)
+    await db.commit()
+    return {"message": "Provider deleted"}
+
+
+@router.get("/oidc/authorize/{provider_id}", response_model=OIDCAuthorizeResponse)
+async def oidc_authorize(
+    provider_id: int,
+    db: AsyncSession = Depends(get_db),
+) -> OIDCAuthorizeResponse:
+    """Return the OIDC authorization URL for the given provider."""
+    result = await db.execute(
+        select(OIDCProvider).where(OIDCProvider.id == provider_id).where(OIDCProvider.is_enabled.is_(True))
+    )
+    provider = result.scalar_one_or_none()
+    if not provider:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found or not enabled")
+
+    # Fetch discovery document
+    discovery_url = f"{provider.issuer_url.rstrip('/')}/.well-known/openid-configuration"
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            resp = await client.get(discovery_url)
+            resp.raise_for_status()
+            discovery = resp.json()
+    except Exception as exc:
+        logger.error("Failed to fetch OIDC discovery for provider %d: %s", provider_id, exc)
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to fetch OIDC discovery document")
+
+    authorization_endpoint = discovery.get("authorization_endpoint")
+    if not authorization_endpoint:
+        raise HTTPException(
+            status_code=status.HTTP_502_BAD_GATEWAY, detail="OIDC discovery document missing authorization_endpoint"
+        )
+    # B2: SSRF guard — reject non-HTTP(S) schemes in the authorization endpoint
+    if not authorization_endpoint.startswith(("https://", "http://")):
+        logger.warning("OIDC discovery authorization_endpoint has invalid scheme: %s", authorization_endpoint)
+        raise HTTPException(
+            status_code=status.HTTP_502_BAD_GATEWAY,
+            detail="OIDC discovery document contains invalid authorization_endpoint",
+        )
+
+    external_url = await _get_base_external_url(db)
+    redirect_uri = f"{external_url}/api/v1/auth/oidc/callback"
+
+    now = datetime.now(timezone.utc)
+    # Prune expired OIDC states from the DB
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.OIDC_STATE,
+            AuthEphemeralToken.expires_at < now,
+        )
+    )
+    state = secrets.token_urlsafe(32)
+    nonce = secrets.token_urlsafe(32)
+
+    # PKCE (S256) – required by PocketID and recommended for all OIDC flows
+    code_verifier = secrets.token_urlsafe(48)  # 64-char URL-safe string
+    code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
+
+    db.add(
+        AuthEphemeralToken(
+            token=state,
+            token_type=TokenType.OIDC_STATE,
+            provider_id=provider_id,
+            nonce=nonce,
+            code_verifier=code_verifier,
+            expires_at=now + OIDC_STATE_TTL,
+        )
+    )
+    await db.commit()
+
+    params = urllib.parse.urlencode(
+        {
+            "response_type": "code",
+            "client_id": provider.client_id,
+            "redirect_uri": redirect_uri,
+            "scope": provider.scopes,
+            "state": state,
+            "nonce": nonce,
+            "code_challenge": code_challenge,
+            "code_challenge_method": "S256",
+        }
+    )
+    auth_url = f"{authorization_endpoint}?{params}"
+    return OIDCAuthorizeResponse(auth_url=auth_url)
+
+
+@router.get("/oidc/callback")
+async def oidc_callback(
+    code: str | None = Query(default=None, max_length=2048),
+    state: str | None = Query(default=None, max_length=2048),
+    error: str | None = Query(default=None, max_length=256),
+    db: AsyncSession = Depends(get_db),
+) -> RedirectResponse:
+    """Handle the OIDC authorization code callback from the identity provider."""
+    external_url = await _get_base_external_url(db)
+    frontend_error_url = f"{external_url}/?oidc_error="
+
+    try:
+        if error:
+            logger.warning("OIDC callback received error: %s", error)
+            return RedirectResponse(url=f"{frontend_error_url}oidc_provider_error", status_code=302)
+
+        if not code or not state:
+            return RedirectResponse(url=f"{frontend_error_url}missing_parameters", status_code=302)
+
+        # Atomically validate and consume OIDC state from DB (I6: single-use enforcement).
+        # DELETE...RETURNING ensures concurrent callbacks with the same state token
+        # cannot both succeed — only the first DELETE finds the row.
+        now = datetime.now(timezone.utc)
+        state_del = await db.execute(
+            delete(AuthEphemeralToken)
+            .where(
+                AuthEphemeralToken.token == state,
+                AuthEphemeralToken.token_type == TokenType.OIDC_STATE,
+                AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
+            )
+            .returning(
+                AuthEphemeralToken.provider_id,
+                AuthEphemeralToken.nonce,
+                AuthEphemeralToken.code_verifier,
+            )
+        )
+        state_row = state_del.one_or_none()
+        if state_row is None:
+            await db.rollback()
+            return RedirectResponse(url=f"{frontend_error_url}invalid_state", status_code=302)
+
+        provider_id, nonce, code_verifier = state_row
+        await db.commit()
+
+        # Load provider
+        result = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+        provider = result.scalar_one_or_none()
+        if not provider:
+            return RedirectResponse(url=f"{frontend_error_url}provider_not_found", status_code=302)
+
+        redirect_uri = f"{external_url}/api/v1/auth/oidc/callback"
+
+        # ── Step 1: Fetch discovery document ────────────────────────────────
+        discovery_url = f"{provider.issuer_url.rstrip('/')}/.well-known/openid-configuration"
+        try:
+            async with httpx.AsyncClient(timeout=10) as client:
+                disc_resp = await client.get(discovery_url)
+                disc_resp.raise_for_status()
+                discovery = disc_resp.json()
+        except Exception as exc:
+            logger.error("OIDC discovery fetch failed for provider %d: %s", provider_id, exc)
+            return RedirectResponse(url=f"{frontend_error_url}discovery_failed", status_code=302)
+
+        token_endpoint = discovery.get("token_endpoint")
+        jwks_uri = discovery.get("jwks_uri")
+        if not token_endpoint or not jwks_uri:
+            return RedirectResponse(url=f"{frontend_error_url}invalid_discovery_document", status_code=302)
+        # L-R7-C: Reject non-HTTP(S) URLs in the discovery document to prevent
+        # SSRF via crafted responses (e.g. file://, gopher://, internal schemes).
+        if not token_endpoint.startswith(("https://", "http://")) or not jwks_uri.startswith(("https://", "http://")):
+            logger.warning(
+                "OIDC discovery document contains non-HTTP URL(s): token=%s jwks=%s", token_endpoint, jwks_uri
+            )
+            return RedirectResponse(url=f"{frontend_error_url}invalid_discovery_document", status_code=302)
+
+        # ── Step 2: Exchange authorization code for tokens ───────────────────
+        token_form: dict[str, str] = {
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": redirect_uri,
+            "client_id": provider.client_id,
+        }
+        if provider.client_secret:
+            token_form["client_secret"] = provider.client_secret
+        if code_verifier:
+            token_form["code_verifier"] = code_verifier
+
+        try:
+            async with httpx.AsyncClient(timeout=15) as client:
+                token_resp = await client.post(
+                    token_endpoint,
+                    data=token_form,
+                    headers={"Accept": "application/json"},
+                )
+        except Exception as exc:
+            logger.error("OIDC token exchange request failed for provider %d: %s", provider_id, exc)
+            return RedirectResponse(url=f"{frontend_error_url}token_exchange_network_error", status_code=302)
+
+        if not token_resp.is_success:
+            try:
+                err_body = token_resp.json()
+                oidc_err = err_body.get("error", "")
+                oidc_desc = err_body.get("error_description", "")
+            except Exception:
+                oidc_err = ""
+                oidc_desc = token_resp.text[:200]
+            logger.error(
+                "OIDC token exchange HTTP %d for provider %d. redirect_uri=%r error=%r desc=%r",
+                token_resp.status_code,
+                provider_id,
+                redirect_uri,
+                oidc_err,
+                oidc_desc,
+            )
+            # Encode the OIDC error code into the redirect so the user sees it in the toast.
+            # URL-encode the value to prevent query-parameter injection from provider responses.
+            raw_err = oidc_err[:40] if oidc_err else str(token_resp.status_code)
+            safe_err = urllib.parse.quote(raw_err, safe="")
+            return RedirectResponse(
+                url=f"{frontend_error_url}token_exchange_{safe_err}",
+                status_code=302,
+            )
+
+        try:
+            token_data = token_resp.json()
+        except Exception as exc:
+            logger.error("OIDC token exchange non-JSON response for provider %d: %s", provider_id, exc)
+            return RedirectResponse(url=f"{frontend_error_url}token_exchange_bad_response", status_code=302)
+
+        id_token = token_data.get("id_token")
+        if not id_token:
+            # Only log the keys present — values may contain secrets (access_token, etc.)
+            logger.error(
+                "OIDC token response missing id_token for provider %d; keys present: %s",
+                provider_id,
+                list(token_data.keys()),
+            )
+            return RedirectResponse(url=f"{frontend_error_url}no_id_token", status_code=302)
+
+        # ── Step 3: Fetch JWKS and validate ID token ─────────────────────────
+        # Use the issuer from the discovery document as the canonical value (OIDC Core
+        # §3.1.3.7 requires iss == discovery issuer exactly).  We strip trailing slashes
+        # from both sides because some providers (e.g. Authentik, older PocketID versions)
+        # are inconsistent between the discovery issuer and the JWT iss claim.
+        discovery_issuer: str = discovery.get("issuer", provider.issuer_url).rstrip("/")
+        try:
+            async with httpx.AsyncClient(timeout=10) as jwks_http:
+                jwks_resp = await jwks_http.get(jwks_uri)
+                jwks_resp.raise_for_status()
+                jwks_data = jwks_resp.json()
+
+            jwks_client = PyJWKClient(jwks_uri)
+            jwks_client.fetch_data = lambda: jwks_data  # type: ignore[method-assign]
+            signing_key = jwks_client.get_signing_key_from_jwt(id_token)
+
+            # M-3: Decode without built-in issuer check, then compare normalised
+            # (both sides rstrip("/")) to handle providers like Authentik that include
+            # a trailing slash in iss but not in the discovery issuer, or vice-versa.
+            claims = jwt.decode(
+                id_token,
+                signing_key.key,
+                algorithms=["RS256", "ES256", "RS384", "ES384", "RS512"],
+                audience=provider.client_id,
+                options={"verify_iss": False},
+            )
+            token_iss = claims.get("iss", "").rstrip("/")
+            if token_iss != discovery_issuer:
+                raise jwt.exceptions.InvalidIssuerError("Invalid issuer")
+        except Exception as exc:
+            logger.error("OIDC JWT validation failed for provider %d: %s", provider_id, exc, exc_info=True)
+            return RedirectResponse(url=f"{frontend_error_url}token_validation_failed", status_code=302)
+
+        # Verify nonce — fail closed: we always send a nonce, so the provider must echo it.
+        # Skipping the check when nonce is absent would allow CSRF on non-nonce providers.
+        token_nonce = claims.get("nonce")
+        if token_nonce is None or token_nonce != nonce:
+            logger.warning("OIDC nonce mismatch for provider %d (present=%r)", provider_id, token_nonce is not None)
+            return RedirectResponse(url=f"{frontend_error_url}nonce_mismatch", status_code=302)
+
+        provider_sub: str = claims.get("sub", "")
+        if not provider_sub:
+            return RedirectResponse(url=f"{frontend_error_url}missing_sub_claim", status_code=302)
+
+        # C1: Only trust the email claim when the provider explicitly marks it verified.
+        # Treating absent email_verified as verified enables account-takeover: an attacker
+        # could register an unverified email with an IdP and auto-link to an existing account.
+        # Fail closed: require email_verified == True; absent/False both drop the email.
+        raw_email: str | None = claims.get("email")
+        email_verified = claims.get("email_verified")
+        if email_verified is not True:
+            if raw_email:
+                logger.info(
+                    "OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
+                    provider_id,
+                    provider_sub,
+                    email_verified,
+                )
+            provider_email: str | None = None
+        else:
+            provider_email = raw_email
+
+        # ── Step 4: Resolve / create user ────────────────────────────────────
+        try:
+            # 1. Look up existing OIDC link
+            link_result = await db.execute(
+                select(UserOIDCLink)
+                .where(UserOIDCLink.provider_id == provider_id)
+                .where(UserOIDCLink.provider_user_id == provider_sub)
+            )
+            link = link_result.scalar_one_or_none()
+
+            user: User | None = None
+
+            if link:
+                # Existing link → load the linked user
+                user_result = await db.execute(
+                    select(User).where(User.id == link.user_id).options(selectinload(User.groups))
+                )
+                user = user_result.scalar_one_or_none()
+            else:
+                # 2. No OIDC link yet — check for an existing user with the same email.
+                # Use case-insensitive matching (func.lower) so that "User@Example.com"
+                # and "user@example.com" are treated as the same identity, preventing
+                # an attacker-controlled IdP from bypassing the auto-link guard by
+                # registering the target email with different casing.
+                email_user: User | None = None
+                if provider_email:
+                    email_user = await get_user_by_email(db, provider_email)
+
+                if email_user and provider.auto_link_existing_accounts:
+                    # M-4: Only auto-link when the provider has auto_link_existing_accounts
+                    # enabled.  Operators can disable this to require explicit account linking,
+                    # preventing an attacker-controlled IdP from hijacking local accounts.
+                    #
+                    # M-NEW-6: Refuse auto-link if the target user already has any OIDC
+                    # link (to any provider).  Without this guard an attacker who controls
+                    # a second OIDC provider with auto_link enabled could add themselves as
+                    # a second IdP for a user that already authenticates via a legitimate
+                    # provider, effectively taking over the account.
+                    existing_links_result = await db.execute(
+                        select(UserOIDCLink).where(UserOIDCLink.user_id == email_user.id)
+                    )
+                    has_existing_oidc_link = existing_links_result.scalar_one_or_none() is not None
+                    if has_existing_oidc_link:
+                        logger.warning(
+                            "Auto-link rejected for user '%s': already linked to another OIDC provider",
+                            email_user.username,
+                        )
+                        return RedirectResponse(url=f"{frontend_error_url}no_linked_account", status_code=302)
+                    db.add(
+                        UserOIDCLink(
+                            user_id=email_user.id,
+                            provider_id=provider_id,
+                            provider_user_id=provider_sub,
+                            provider_email=provider_email,
+                        )
+                    )
+                    await db.commit()
+                    user = email_user
+                    logger.info(
+                        "Auto-linked existing user '%s' to OIDC provider %d via email match",
+                        email_user.username,
+                        provider_id,
+                    )
+                elif provider.auto_create_users:
+                    # 3. No existing user — create one
+                    if provider_email:
+                        raw = provider_email.split("@")[0]
+                    else:
+                        raw = provider_sub[:30]
+                    candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
+
+                    username = candidate
+                    counter = 1
+                    while True:
+                        existing = await get_user_by_username(db, username)
+                        if not existing:
+                            break
+                        username = f"{candidate}{counter}"
+                        counter += 1
+
+                    # I9: Assign new OIDC users to the default "Viewers" group so they
+                    # have read-only access rather than starting with no permissions.
+                    # Fetch the group BEFORE creating the user so we can set the
+                    # relationship before flush — accessing new_user.groups after a
+                    # flush triggers a lazy-load which fails in async context.
+                    viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
+                    viewers_group = viewers_result.scalar_one_or_none()
+
+                    new_user = User(
+                        username=username,
+                        email=provider_email,
+                        # M-1: auth_source="oidc" prevents local password-reset flow
+                        # for users who should only authenticate via OIDC.
+                        auth_source="oidc",
+                        password_hash=None,  # OIDC users never use password auth
+                        role="user",
+                        is_active=True,
+                        groups=[viewers_group] if viewers_group else [],
+                    )
+                    db.add(new_user)
+                    await db.flush()
+
+                    db.add(
+                        UserOIDCLink(
+                            user_id=new_user.id,
+                            provider_id=provider_id,
+                            provider_user_id=provider_sub,
+                            provider_email=provider_email,
+                        )
+                    )
+                    await db.commit()
+
+                    user_result = await db.execute(
+                        select(User).where(User.id == new_user.id).options(selectinload(User.groups))
+                    )
+                    user = user_result.scalar_one()
+                    logger.info("Auto-created user '%s' via OIDC provider %d", username, provider_id)
+                else:
+                    return RedirectResponse(url=f"{frontend_error_url}no_linked_account", status_code=302)
+
+            if not user or not user.is_active:
+                return RedirectResponse(url=f"{frontend_error_url}account_inactive", status_code=302)
+
+            # Issue an OIDC exchange token (short-lived, single-use) stored in DB.
+            # I7: Opportunistically prune expired exchange tokens to keep the table small.
+            now2 = datetime.now(timezone.utc)
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
+                    AuthEphemeralToken.expires_at < now2,
+                )
+            )
+            exchange_token = secrets.token_urlsafe(32)
+            db.add(
+                AuthEphemeralToken(
+                    token=exchange_token,
+                    token_type=TokenType.OIDC_EXCHANGE,
+                    username=user.username,
+                    expires_at=now2 + OIDC_EXCHANGE_TTL,
+                )
+            )
+            await db.commit()
+
+            # H-4: Use a URL fragment (#) instead of a query parameter so the exchange
+            # token is never sent to the server in the Referer header or server logs.
+            return RedirectResponse(url=f"{external_url}/login#oidc_token={exchange_token}", status_code=302)
+
+        except Exception as exc:
+            logger.error("OIDC user resolution failed for provider %d: %s", provider_id, exc, exc_info=True)
+            try:
+                await db.rollback()
+            except Exception as rb_exc:
+                logger.error("DB rollback failed after OIDC user-resolution error: %s", rb_exc, exc_info=True)
+            return RedirectResponse(url=f"{frontend_error_url}user_resolution_failed", status_code=302)
+
+    except Exception as exc:
+        # L-1: Log the exception class name internally but never expose it in the
+        # redirect URL — leaking exception names aids attacker reconnaissance.
+        logger.error("Unexpected error in OIDC callback (%s): %s", type(exc).__name__, exc, exc_info=True)
+        try:
+            return RedirectResponse(url=f"{frontend_error_url}internal_error", status_code=302)
+        except Exception:
+            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="OIDC callback failed")
+
+
+@router.post("/oidc/exchange", response_model=LoginResponse)
+async def oidc_exchange(
+    body: OIDCExchangeRequest,
+    raw_request: Request,
+    response: Response,
+    db: AsyncSession = Depends(get_db),
+) -> LoginResponse:
+    """Exchange an OIDC exchange token (from the callback redirect) for a full JWT.
+
+    C4: If the resolved user has 2FA enabled the exchange returns a pre_auth_token
+    (requires_2fa=True) instead of a full JWT.  The frontend must then complete the
+    2FA step exactly as it would after a password-based login.
+    """
+    now = datetime.now(timezone.utc)
+    # Atomically consume the exchange token (DELETE...RETURNING prevents replay).
+    consume_result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == body.oidc_token,
+            AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
+            AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
+        )
+        .returning(AuthEphemeralToken.username)
+    )
+    row = consume_result.one_or_none()
+    if row is None:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
+
+    (username,) = row
+    await db.commit()
+
+    user = await get_user_by_username(db, username)
+    if not user or not user.is_active:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+    # Reload with groups
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
+    # C4: Check whether the user has any 2FA method enabled.
+    totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+    totp_record = totp_result.scalar_one_or_none()
+    totp_enabled = totp_record is not None and totp_record.is_enabled
+    email_2fa_enabled = await _get_email_2fa_enabled(db, user.id)
+
+    if totp_enabled or email_2fa_enabled:
+        # User has 2FA — issue a pre_auth_token bound to this browser session via
+        # an HttpOnly cookie (H-A: mirrors the cookie-binding done in auth.py:login).
+        two_fa_methods: list[str] = []
+        if totp_enabled:
+            two_fa_methods.append("totp")
+        if email_2fa_enabled:
+            two_fa_methods.append("email")
+        if totp_enabled:
+            two_fa_methods.append("backup")
+        challenge_id = secrets.token_urlsafe(32)
+        pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
+        response.set_cookie(
+            key="2fa_challenge",
+            value=challenge_id,
+            httponly=True,
+            secure=raw_request.url.scheme == "https",
+            samesite="lax",
+            max_age=300,
+            path="/api/v1/auth/2fa",
+        )
+        return LoginResponse(
+            requires_2fa=True,
+            pre_auth_token=pre_auth_token,
+            two_fa_methods=two_fa_methods,
+            user=_user_to_response(user),
+        )
+
+    access_token = create_access_token(
+        data={"sub": user.username},
+        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+    )
+
+    return LoginResponse(
+        access_token=access_token,
+        token_type="bearer",
+        user=_user_to_response(user),
+        requires_2fa=False,
+    )
+
+
+@router.get("/oidc/links", response_model=list[OIDCLinkResponse])
+async def list_oidc_links(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> list[OIDCLinkResponse]:
+    """List all OIDC provider links for the current user."""
+    result = await db.execute(
+        select(UserOIDCLink).where(UserOIDCLink.user_id == current_user.id).options(selectinload(UserOIDCLink.provider))
+    )
+    links = result.scalars().all()
+    return [
+        OIDCLinkResponse(
+            id=link.id,
+            provider_id=link.provider_id,
+            provider_name=link.provider.name,
+            provider_email=link.provider_email,
+            created_at=link.created_at.isoformat(),
+        )
+        for link in links
+    ]
+
+
+@router.delete("/oidc/links/{provider_id}")
+async def remove_oidc_link(
+    provider_id: int,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Remove the OIDC link between the current user and a provider."""
+    result = await db.execute(
+        select(UserOIDCLink)
+        .where(UserOIDCLink.user_id == current_user.id)
+        .where(UserOIDCLink.provider_id == provider_id)
+    )
+    link = result.scalar_one_or_none()
+    if not link:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC link not found")
+
+    await db.delete(link)
+    await db.commit()
+    return {"message": "OIDC link removed"}
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+async def _get_base_external_url(db: AsyncSession) -> str:
+    """Return the base external URL (no trailing slash, no /login suffix)."""
+    external_url = await get_setting(db, "external_url")
+    if external_url:
+        return external_url.rstrip("/")
+    return os.environ.get("APP_URL", "http://localhost:5173").rstrip("/")

+ 69 - 0
backend/app/api/routes/obico.py

@@ -0,0 +1,69 @@
+"""API routes for Obico AI failure detection."""
+
+import logging
+
+from fastapi import APIRouter, HTTPException, Response
+from pydantic import BaseModel
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.obico_detection import obico_detection_service, pop_frame
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/obico", tags=["obico"])
+
+
+class TestConnectionRequest(BaseModel):
+    url: str
+
+
+@router.get("/status")
+async def get_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Scheduler status, per-printer classification, and recent detection history."""
+    settings = await obico_detection_service._load_settings()
+    status = obico_detection_service.get_status()
+    return {
+        **status,
+        "enabled": settings["enabled"],
+        "ml_url": settings["ml_url"],
+        "sensitivity": settings["sensitivity"],
+        "action": settings["action"],
+        "poll_interval": settings["poll_interval"],
+        "external_url_configured": bool(settings["external_url"]),
+    }
+
+
+@router.post("/test-connection")
+async def test_connection(
+    req: TestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
+    if not req.url:
+        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
+    return await obico_detection_service.test_connection(req.url)
+
+
+@router.get("/cached-frame/{nonce}")
+async def cached_frame(nonce: str):
+    """Serve a pre-captured JPEG to the Obico ML API.
+
+    The detection loop captures a snapshot locally (where we control the timeout),
+    stashes the bytes under a one-shot random nonce, then hands this URL to Obico's
+    ML API. Obico's hardcoded 5s read timeout never races our snapshot pipeline.
+
+    Unauthenticated: the unguessable 32-byte nonce is single-use and expires in
+    seconds, so exposing this path doesn't widen the camera access surface.
+    """
+    data = await pop_frame(nonce)
+    if data is None:
+        raise HTTPException(status_code=404, detail="Frame not found or expired")
+    return Response(
+        content=data,
+        media_type="image/jpeg",
+        headers={"Cache-Control": "no-store"},
+    )

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

@@ -6,7 +6,7 @@ from fastapi.responses import FileResponse
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -92,10 +92,11 @@ async def get_print_log(
 async def get_print_log_thumbnail(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the thumbnail for a print log entry.
 
-    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     entry = await db.get(PrintLogEntry, entry_id)
     if not entry or not entry.thumbnail_path:

+ 218 - 32
backend/app/api/routes/print_queue.py

@@ -18,10 +18,13 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
+from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
+    PrintBatchResponse,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
@@ -209,6 +212,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # User tracking (Issue #206)
         "created_by_id": item.created_by_id,
         "created_by_username": item.created_by.username if item.created_by else None,
+        # Batch grouping
+        "batch_id": item.batch_id,
+        "batch_name": item.batch.name if item.batch else None,
+        # SJF scheduling
+        "been_jumped": item.been_jumped,
+        # Auto-print G-code injection
+        "gcode_injection": item.gcode_injection,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -281,6 +291,7 @@ async def list_queue(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
+            selectinload(PrintQueueItem.batch),
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
@@ -407,6 +418,36 @@ async def add_to_queue(
             all_types = existing_types | set(override_types)
             required_filament_types = json.dumps(sorted(all_types))
 
+    # Validate quantity
+    quantity = max(1, data.quantity)
+
+    # Create batch if quantity > 1
+    batch = None
+    batch_id = None
+    if quantity > 1:
+        # Derive batch name from source file
+        batch_name_base = "Batch"
+        if archive:
+            batch_name_base = archive.print_name or archive.filename or "Batch"
+        elif library_file:
+            if library_file.file_metadata:
+                batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
+            else:
+                batch_name_base = library_file.filename
+        batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
+
+        batch = PrintBatch(
+            name=f"{batch_name_base} ×{quantity}",
+            archive_id=data.archive_id,
+            library_file_id=data.library_file_id,
+            quantity=quantity,
+            status="active",
+            created_by_id=current_user.id if current_user else None,
+        )
+        db.add(batch)
+        await db.flush()  # Get batch.id before creating items
+        batch_id = batch.id
+
     # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
         result = await db.execute(
@@ -423,40 +464,78 @@ async def add_to_queue(
         )
     max_pos = result.scalar() or 0
 
-    item = PrintQueueItem(
-        printer_id=data.printer_id,
-        target_model=target_model_norm,
-        target_location=data.target_location,
-        required_filament_types=required_filament_types,
-        filament_overrides=filament_overrides_json,
-        archive_id=data.archive_id,
-        library_file_id=data.library_file_id,
-        scheduled_time=data.scheduled_time,
-        require_previous_success=data.require_previous_success,
-        auto_off_after=data.auto_off_after,
-        manual_start=data.manual_start,
-        ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None,
-        plate_id=data.plate_id,
-        bed_levelling=data.bed_levelling,
-        flow_cali=data.flow_cali,
-        vibration_cali=data.vibration_cali,
-        layer_inspect=data.layer_inspect,
-        timelapse=data.timelapse,
-        use_ams=data.use_ams,
-        position=max_pos + 1,
-        status="pending",
-        created_by_id=current_user.id if current_user else None,
-    )
-    db.add(item)
+    # Resolve print_time_seconds for SJF scheduling (cache on item at creation)
+    cached_print_time = None
+    if archive:
+        cached_print_time = archive.print_time_seconds
+        if data.plate_id:
+            archive_path = settings.base_dir / archive.file_path
+            if archive_path.exists():
+                plate_time = _extract_print_time_from_3mf(archive_path, data.plate_id)
+                if plate_time is not None:
+                    cached_print_time = plate_time
+    elif library_file:
+        if library_file.file_metadata:
+            cached_print_time = library_file.file_metadata.get("print_time_seconds")
+        if data.plate_id:
+            lib_path = Path(library_file.file_path)
+            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+            if library_file_path.exists():
+                plate_time = _extract_print_time_from_3mf(library_file_path, data.plate_id)
+                if plate_time is not None:
+                    cached_print_time = plate_time
+
+    # Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500
+    if data.project_id is not None:
+        project_result = await db.execute(select(Project).where(Project.id == data.project_id))
+        if not project_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Project not found")
+
+    ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+    items = []
+    for i in range(quantity):
+        item = PrintQueueItem(
+            printer_id=data.printer_id,
+            target_model=target_model_norm,
+            target_location=data.target_location,
+            required_filament_types=required_filament_types,
+            filament_overrides=filament_overrides_json,
+            archive_id=data.archive_id,
+            library_file_id=data.library_file_id,
+            scheduled_time=data.scheduled_time,
+            require_previous_success=data.require_previous_success,
+            auto_off_after=data.auto_off_after,
+            manual_start=data.manual_start,
+            ams_mapping=ams_mapping_json,
+            plate_id=data.plate_id,
+            bed_levelling=data.bed_levelling,
+            flow_cali=data.flow_cali,
+            vibration_cali=data.vibration_cali,
+            layer_inspect=data.layer_inspect,
+            timelapse=data.timelapse,
+            use_ams=data.use_ams,
+            gcode_injection=data.gcode_injection,
+            project_id=data.project_id,
+            position=max_pos + 1 + i,
+            status="pending",
+            created_by_id=current_user.id if current_user else None,
+            batch_id=batch_id,
+            print_time_seconds=cached_print_time,
+        )
+        db.add(item)
+        items.append(item)
+
     await db.commit()
-    await db.refresh(item)
 
-    # Load relationships for response
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    # Refresh the first item for the response
+    item = items[0]
+    await db.refresh(item)
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
-    logger.info("Added %s to queue for %s", source_name, target_desc)
+    qty_desc = f" (×{quantity})" if quantity > 1 else ""
+    logger.info("Added %s to queue for %s%s", source_name, target_desc, qty_desc)
 
     # MQTT relay - publish queue job added
     try:
@@ -481,6 +560,8 @@ async def add_to_queue(
             else f"Job #{item.id}"
         )
         job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
+        if quantity > 1:
+            job_name = f"{job_name} ×{quantity}"
         target = (
             item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
         )
@@ -561,6 +642,106 @@ async def bulk_update_queue_items(
     )
 
 
+# --- Batch endpoints ---
+
+
+@router.get("/batches", response_model=list[PrintBatchResponse])
+async def list_batches(
+    status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
+    """List all print batches with progress stats."""
+    query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
+    if status:
+        query = query.where(PrintBatch.status == status)
+    result = await db.execute(query)
+    batches = result.scalars().all()
+
+    responses = []
+    for batch in batches:
+        responses.append(await _build_batch_response(db, batch))
+    return responses
+
+
+@router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
+async def get_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
+    """Get a print batch with progress stats."""
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+    return await _build_batch_response(db, batch)
+
+
+@router.delete("/batches/{batch_id}")
+async def cancel_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+):
+    """Cancel all pending items in a batch and mark batch as cancelled."""
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+
+    # Cancel all pending queue items in this batch
+    result = await db.execute(
+        select(PrintQueueItem).where(and_(PrintQueueItem.batch_id == batch_id, PrintQueueItem.status == "pending"))
+    )
+    pending_items = result.scalars().all()
+    cancelled_count = 0
+    for item in pending_items:
+        item.status = "cancelled"
+        cancelled_count += 1
+
+    batch.status = "cancelled"
+    await db.commit()
+
+    return {"message": f"Batch cancelled, {cancelled_count} pending items cancelled"}
+
+
+async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBatchResponse:
+    """Build a batch response with derived counts from queue items."""
+    # Count queue items by status
+    result = await db.execute(
+        select(PrintQueueItem.status, func.count(PrintQueueItem.id))
+        .where(PrintQueueItem.batch_id == batch.id)
+        .group_by(PrintQueueItem.status)
+    )
+    status_counts = {row[0]: row[1] for row in result.fetchall()}
+
+    # Load created_by for username
+    created_by_username = None
+    if batch.created_by_id:
+        result = await db.execute(select(User).where(User.id == batch.created_by_id))
+        user = result.scalar_one_or_none()
+        if user:
+            created_by_username = user.username
+
+    return PrintBatchResponse(
+        id=batch.id,
+        name=batch.name,
+        archive_id=batch.archive_id,
+        library_file_id=batch.library_file_id,
+        quantity=batch.quantity,
+        status=batch.status,
+        created_at=batch.created_at,
+        created_by_id=batch.created_by_id,
+        created_by_username=created_by_username,
+        pending_count=status_counts.get("pending", 0),
+        printing_count=status_counts.get("printing", 0),
+        completed_count=status_counts.get("completed", 0),
+        failed_count=status_counts.get("failed", 0),
+        cancelled_count=status_counts.get("cancelled", 0),
+    )
+
+
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 async def get_queue_item(
     item_id: int,
@@ -575,6 +756,7 @@ async def get_queue_item(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
+            selectinload(PrintQueueItem.batch),
         )
         .where(PrintQueueItem.id == item_id)
     )
@@ -656,7 +838,7 @@ async def update_queue_item(
         setattr(item, field, value)
 
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
     logger.info("Updated queue item %s", item_id)
     return _enrich_response(item)
@@ -844,7 +1026,11 @@ async def start_queue_item(
     """
     result = await db.execute(
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.batch),
+        )
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
@@ -857,7 +1043,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
     logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
     return _enrich_response(item)

+ 215 - 40
backend/app/api/routes/printers.py

@@ -8,7 +8,7 @@ from fastapi.responses import Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -29,9 +29,11 @@ from backend.app.schemas.printer import (
     PrintOptionsResponse,
 )
 from backend.app.services.bambu_ftp import (
+    cache_3mf_download,
     delete_file_async,
     download_file_bytes_async,
     download_file_try_paths_async,
+    get_cached_3mf,
     get_storage_info_async,
     list_files_async,
 )
@@ -583,6 +585,7 @@ async def get_printer_status(
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
         wired_network=state.wired_network,
+        door_open=state.door_open,
         nozzles=nozzles,
         nozzle_rack=nozzle_rack,
         print_options=print_options,
@@ -607,7 +610,7 @@ async def get_printer_status(
         heatbreak_fan_speed=state.heatbreak_fan_speed,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
-        plate_cleared=printer_manager.is_plate_cleared(printer_id),
+        awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
     )
 
@@ -714,8 +717,8 @@ async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
-    # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
     """Get the cover image for the current print job.
 
     Args:
@@ -791,43 +794,61 @@ async def get_printer_cover(
     temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
     temp_path.parent.mkdir(parents=True, exist_ok=True)
 
-    logger.info(
-        f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
-    )
-
-    # Retry logic for transient FTP failures
-    max_retries = 2
-    last_error = None
+    # Cache check (#972): the archive-metadata flow in main.py may have already
+    # downloaded this 3MF during the print-start handler. Reusing that file
+    # avoids a second 36MB transfer competing with the printer's single FTP
+    # socket (which produces the 425 errors that feed the retry storm).
     downloaded = False
-
-    for attempt in range(max_retries + 1):
-        try:
-            downloaded = await download_file_try_paths_async(
-                printer.ip_address,
-                printer.access_code,
-                remote_paths,
-                temp_path,
-                printer_model=printer.model,
-            )
-            if downloaded:
-                break
-        except Exception as e:
-            last_error = e
-            if attempt < max_retries:
-                logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
-                await asyncio.sleep(0.5 * (attempt + 1))  # Brief backoff
-            else:
-                logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
-
-    if last_error and not downloaded:
-        raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
+    using_cached = False
+    for candidate_name in possible_filenames:
+        cached = get_cached_3mf(printer_id, candidate_name)
+        if cached:
+            logger.info("Cover using cached 3MF from %s (avoided duplicate FTP)", cached)
+            temp_path = cached
+            downloaded = True
+            using_cached = True
+            break
 
     if not downloaded:
-        raise HTTPException(
-            404,
-            f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
+        logger.info(
+            f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
         )
 
+        # Retry logic for transient FTP failures
+        max_retries = 2
+        last_error = None
+
+        for attempt in range(max_retries + 1):
+            try:
+                downloaded = await download_file_try_paths_async(
+                    printer.ip_address,
+                    printer.access_code,
+                    remote_paths,
+                    temp_path,
+                    printer_model=printer.model,
+                )
+                if downloaded:
+                    break
+            except Exception as e:
+                last_error = e
+                if attempt < max_retries:
+                    logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
+                    await asyncio.sleep(0.5 * (attempt + 1))  # Brief backoff
+                else:
+                    logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
+
+        if last_error and not downloaded:
+            raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
+
+        if not downloaded:
+            raise HTTPException(
+                404,
+                f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
+            )
+
+        # Share the fresh download with the archive flow.
+        cache_3mf_download(printer_id, temp_filename, temp_path)
+
     # Verify file actually exists and has content
     if not temp_path.exists():
         raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
@@ -836,7 +857,8 @@ async def get_printer_cover(
     logger.info("Downloaded file size: %s bytes", file_size)
 
     if file_size == 0:
-        temp_path.unlink()
+        if not using_cached:
+            temp_path.unlink()
         raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
 
     try:
@@ -899,7 +921,10 @@ async def get_printer_cover(
             zf.close()
 
     finally:
-        if temp_path.exists():
+        # Only delete when this invocation owns the file. A cached path is
+        # shared with the archive flow — removing it would force a refetch
+        # the next time either flow needs the 3MF.
+        if not using_cached and temp_path.exists():
             temp_path.unlink()
 
 
@@ -1492,6 +1517,49 @@ async def start_drying(
     if duration < 1 or duration > 24:
         raise HTTPException(400, "Duration must be 1-24 hours")
 
+    # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the
+    # firmware silently ignores the command — #971) and backfill an empty
+    # filament field from the first loaded tray so the printer doesn't reject
+    # the payload.
+    target_ams: dict | None = None
+    for unit in (live_state.raw_data.get("ams") if live_state else None) or []:
+        try:
+            if int(unit.get("id", -1)) == ams_id:
+                target_ams = unit
+                break
+        except (TypeError, ValueError):
+            continue
+
+    if target_ams is not None:
+        reason_messages = {
+            0: "Printer is busy",
+            1: "Insufficient power — too many AMS drying or external PSU required",
+            2: "AMS is busy",
+            3: "Filament is at the AMS outlet — retract it first",
+            4: "AMS is already starting a drying cycle",
+            5: "Not supported in 2D mode",
+            6: "AMS is already drying",
+            7: "AMS firmware is upgrading",
+            8: "Plug in the external AMS power adapter to start drying",
+        }
+        for code in target_ams.get("dry_sf_reason") or []:
+            try:
+                code_int = int(code)
+            except (TypeError, ValueError):
+                continue
+            if code_int in reason_messages:
+                raise HTTPException(409, reason_messages[code_int])
+
+        if not filament:
+            for tray in target_ams.get("tray") or []:
+                tray_type = tray.get("tray_type")
+                if tray_type:
+                    filament = str(tray_type)
+                    break
+
+    if not filament:
+        filament = "PLA"
+
     success = printer_manager.send_drying_command(
         printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
     )
@@ -2246,13 +2314,18 @@ async def clear_plate(
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(400, "Printer not connected")
 
+    # Accept the acknowledgment whenever the printer is awaiting it — not only when the
+    # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE
+    # but the awaiting flag persists, and the user still needs a way to ack it (#961).
     state = printer_manager.get_status(printer_id)
-    if not state or state.state not in ("FINISH", "FAILED"):
+    awaiting = printer_manager.is_awaiting_plate_clear(printer_id)
+    if not awaiting and (not state or state.state not in ("FINISH", "FAILED")):
         raise HTTPException(
-            400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
+            400,
+            f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})",
         )
 
-    printer_manager.set_plate_cleared(printer_id)
+    printer_manager.set_awaiting_plate_clear(printer_id, False)
 
     return {"success": True, "message": "Plate cleared, next print will start shortly"}
 
@@ -2328,6 +2401,33 @@ async def set_print_speed(
     return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
 
 
+@router.post("/{printer_id}/airduct-mode")
+async def set_airduct_mode(
+    printer_id: int,
+    mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
+    if mode not in ("cooling", "heating"):
+        raise HTTPException(400, "Mode must be 'cooling' or 'heating'")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_airduct_mode(mode)
+    if not success:
+        raise HTTPException(500, "Failed to set airduct mode")
+
+    return {"success": True, "message": f"Airduct mode set to {mode}"}
+
+
 @router.post("/{printer_id}/chamber-light")
 async def set_chamber_light(
     printer_id: int,
@@ -2352,6 +2452,81 @@ async def set_chamber_light(
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
 
 
+@router.post("/{printer_id}/bed-jog")
+async def bed_jog(
+    printer_id: int,
+    distance: float = Query(
+        ..., description="Relative Z distance in mm (positive = bed down / nozzle further away, negative = bed up)"
+    ),
+    force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Move the build plate along the Z axis by a relative distance.
+
+    Emits a short G-code sequence via MQTT. When ``force`` is true the soft
+    endstops are disabled for the duration of the move, matching the
+    "ignore and move anyway" option Bambu Studio offers when the printer
+    is not homed.
+    """
+    if distance == 0 or abs(distance) > 200:
+        raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    lines = []
+    if force:
+        lines.append("M211 S0")
+    lines += ["G91", f"G1 Z{distance:.2f} F600", "G90"]
+    if force:
+        lines.append("M211 S1")
+
+    if not client.send_gcode("\n".join(lines)):
+        raise HTTPException(500, "Failed to send bed-jog command")
+
+    return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
+
+
+@router.post("/{printer_id}/home-axes")
+async def home_axes(
+    printer_id: int,
+    axes: str = Query("z", description="Axes to home: 'z', 'xy', or 'all'"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Home one or more axes via G28."""
+    axes = axes.lower()
+    if axes == "z":
+        gcode = "G28 Z"
+    elif axes == "xy":
+        gcode = "G28 X Y"
+    elif axes == "all":
+        gcode = "G28"
+    else:
+        raise HTTPException(400, "axes must be 'z', 'xy', or 'all'")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    if not client.send_gcode(gcode):
+        raise HTTPException(500, "Failed to send home command")
+
+    return {"success": True, "message": f"Home {axes} command sent"}
+
+
 @router.post("/{printer_id}/hms/clear")
 async def clear_hms_errors(
     printer_id: int,

+ 325 - 65
backend/app/api/routes/settings.py

@@ -1,11 +1,12 @@
 import io
 import logging
+import os
 import zipfile
 from datetime import datetime
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, UploadFile
-from fastapi.responses import JSONResponse, StreamingResponse
+from fastapi.responses import FileResponse, JSONResponse
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -55,13 +56,9 @@ async def get_external_login_url(db: AsyncSession) -> str:
 
 async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     """Set a single setting value."""
-    from sqlalchemy import func
-    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+    from backend.app.core.db_dialect import upsert_setting
 
-    # Use upsert (INSERT ... ON CONFLICT UPDATE) for reliability
-    stmt = sqlite_insert(Settings).values(key=key, value=value)
-    stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": value, "updated_at": func.now()})
-    await db.execute(stmt)
+    await upsert_setting(db, Settings, key, value)
 
 
 @router.get("", response_model=AppSettings)
@@ -88,6 +85,7 @@ async def get_settings(
                 "spoolman_disable_weight_sync",
                 "spoolman_report_partial_usage",
                 "disable_filament_warnings",
+                "prefer_lowest_filament",
                 "check_updates",
                 "check_printer_firmware",
                 "include_beta_updates",
@@ -102,6 +100,15 @@ async def get_settings(
                 "queue_drying_enabled",
                 "queue_drying_block",
                 "ambient_drying_enabled",
+                "require_plate_clear",
+                "queue_shortest_first",
+                "default_bed_levelling",
+                "default_flow_cali",
+                "default_vibration_cali",
+                "default_layer_inspect",
+                "default_timelapse",
+                "ldap_enabled",
+                "ldap_auto_provision",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
@@ -121,6 +128,8 @@ async def get_settings(
                 "ftp_retry_delay",
                 "ftp_timeout",
                 "mqtt_port",
+                "stagger_group_size",
+                "stagger_interval_minutes",
             ]:
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":
@@ -133,6 +142,9 @@ async def get_settings(
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
 
+    # Never return LDAP bind password in API responses
+    settings_dict["ldap_bind_password"] = ""
+
     return AppSettings(**settings_dict)
 
 
@@ -339,73 +351,142 @@ async def get_homeassistant_settings(db: AsyncSession) -> dict:
     }
 
 
-@router.get("/backup")
-async def create_backup(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
-):
-    """Create a complete backup (database + all files) as a ZIP.
+async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]:
+    """Create a complete backup ZIP (database + all data directories).
 
-    This is a simplified backup that includes the entire SQLite database
-    and all data directories. It is complete by definition and cannot miss data.
+    If output_path is given, the ZIP is written there.
+    Otherwise a temporary file is created (caller must clean up).
+    Returns (zip_path, filename).
     """
     import shutil
     import tempfile
 
-    from sqlalchemy import text
+    from backend.app.core.db_dialect import is_sqlite
 
-    from backend.app.core.database import engine
+    base_dir = app_settings.base_dir
+    filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
 
-    try:
-        base_dir = app_settings.base_dir
-        db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
+    with tempfile.TemporaryDirectory() as temp_dir:
+        temp_path = Path(temp_dir)
+
+        if is_sqlite():
+            from sqlalchemy import text
 
-        with tempfile.TemporaryDirectory() as temp_dir:
-            temp_path = Path(temp_dir)
+            from backend.app.core.database import engine
 
-            # 1. Checkpoint WAL to ensure all data is in main db file
+            db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
+
+            # Checkpoint WAL to ensure all data is in main db file
             async with engine.begin() as conn:
                 await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
 
-            # 2. Copy database file
+            # Copy database file
             shutil.copy2(db_path, temp_path / "bambuddy.db")
+        else:
+            # PostgreSQL: export to a portable SQLite file via SQLAlchemy.
+            # This makes backups restorable on both SQLite and Postgres installs.
+            import json
+            import sqlite3
+
+            from backend.app.core.database import Base, engine
+
+            backup_db_path = temp_path / "bambuddy.db"
+            dst = sqlite3.connect(str(backup_db_path))
+            metadata = Base.metadata
+
+            # Create tables in SQLite backup (simplified — just column names and types)
+            for table in metadata.sorted_tables:
+                cols = []
+                pk_cols = [col.name for col in table.columns if col.primary_key]
+                for col in table.columns:
+                    col_type = "TEXT"  # Default
+                    type_str = str(col.type).upper()
+                    if "INT" in type_str:
+                        col_type = "INTEGER"
+                    elif "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
+                        col_type = "REAL"
+                    elif "BOOL" in type_str:
+                        col_type = "BOOLEAN"
+                    # Only inline PRIMARY KEY for single-column PKs
+                    pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
+                    cols.append(f"{col.name} {col_type}{pk}")
+                # Add composite primary key constraint if needed
+                if len(pk_cols) > 1:
+                    cols.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
+                dst.execute(f"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})")  # noqa: S608
+
+            # Export data from Postgres to SQLite
+            async with engine.connect() as conn:
+                for table in metadata.sorted_tables:
+                    result = await conn.execute(table.select())
+                    rows = result.fetchall()
+                    if not rows:
+                        continue
+                    columns = list(result.keys())
+                    placeholders = ", ".join(["?"] * len(columns))
+                    col_list = ", ".join(columns)
+                    insert_sql = f"INSERT INTO {table.name} ({col_list}) VALUES ({placeholders})"  # noqa: S608  # nosec B608 — table/column names from ORM metadata, not user input
+
+                    def _serialize_row(row):
+                        return tuple(json.dumps(v) if isinstance(v, (list, dict)) else v for v in row)
+
+                    dst.executemany(insert_sql, [_serialize_row(row) for row in rows])
+
+            dst.commit()
+            dst.close()
+            logger.info("PostgreSQL backup exported to portable SQLite format")
+
+        # Copy data directories (if they exist)
+        dirs_to_backup = [
+            ("archive", base_dir / "archive"),
+            ("virtual_printer", base_dir / "virtual_printer"),
+            ("plate_calibration", app_settings.plate_calibration_dir),
+            ("icons", base_dir / "icons"),
+            ("projects", base_dir / "projects"),
+        ]
+
+        for name, src_dir in dirs_to_backup:
+            if src_dir.exists() and any(src_dir.iterdir()):
+                try:
+                    shutil.copytree(src_dir, temp_path / name)
+                except shutil.Error as e:
+                    logger.warning("Some files in %s could not be copied: %s", name, e)
+                except PermissionError as e:
+                    logger.warning("Permission denied copying %s: %s", name, e)
+
+        # Create ZIP
+        if output_path is not None:
+            zip_file = output_path / filename
+        else:
+            fd, tmp = tempfile.mkstemp(suffix=".zip")
+            os.close(fd)
+            zip_file = Path(tmp)
 
-            # 3. Copy data directories (if they exist)
-            dirs_to_backup = [
-                ("archive", base_dir / "archive"),
-                ("virtual_printer", base_dir / "virtual_printer"),
-                ("plate_calibration", app_settings.plate_calibration_dir),
-                ("icons", base_dir / "icons"),
-                ("projects", base_dir / "projects"),
-            ]
+        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
+            for file_path in temp_path.rglob("*"):
+                if file_path.is_file():
+                    arcname = file_path.relative_to(temp_path)
+                    zf.write(file_path, arcname)
 
-            for name, src_dir in dirs_to_backup:
-                if src_dir.exists() and any(src_dir.iterdir()):
-                    try:
-                        shutil.copytree(src_dir, temp_path / name)
-                    except shutil.Error as e:
-                        # Some files may have restricted permissions (e.g., SSL keys)
-                        # Log the error but continue with partial backup
-                        logger.warning("Some files in %s could not be copied: %s", name, e)
-                    except PermissionError as e:
-                        logger.warning("Permission denied copying %s: %s", name, e)
-
-            # 4. Create ZIP
-            zip_buffer = io.BytesIO()
-            with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
-                for file_path in temp_path.rglob("*"):
-                    if file_path.is_file():
-                        arcname = file_path.relative_to(temp_path)
-                        zf.write(file_path, arcname)
-
-            zip_buffer.seek(0)
-            filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
-
-            return StreamingResponse(
-                zip_buffer,
-                media_type="application/zip",
-                headers={"Content-Disposition": f"attachment; filename={filename}"},
-            )
+    return zip_file, filename
+
+
+@router.get("/backup")
+async def create_backup(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Create a complete backup (database + all files) as a ZIP download."""
+    from starlette.background import BackgroundTask
+
+    try:
+        zip_file, filename = await create_backup_zip()
+        return FileResponse(
+            path=zip_file,
+            filename=filename,
+            media_type="application/zip",
+            background=BackgroundTask(lambda: zip_file.unlink(missing_ok=True)),
+        )
     except Exception as e:
         logger.error("Backup failed: %s", e, exc_info=True)
         return JSONResponse(
@@ -414,6 +495,180 @@ async def create_backup(
         )
 
 
+async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
+    """Import data from a SQLite database file into the current PostgreSQL database.
+
+    Used for cross-database restore (SQLite backup → PostgreSQL).
+    Reads all tables from the SQLite file and bulk-inserts into Postgres.
+    """
+    import sqlite3
+
+    from sqlalchemy import text
+
+    from backend.app.core.database import Base, _create_engine
+
+    # Create a temporary engine for the import (current engine was disposed)
+    pg_engine = _create_engine()
+
+    try:
+        # Open SQLite file directly (sync — it's a local file read)
+        src = sqlite3.connect(str(sqlite_path))
+        src.row_factory = sqlite3.Row
+
+        # Get list of tables from SQLite (skip internal/FTS tables)
+        cursor = src.execute(
+            "SELECT name FROM sqlite_master WHERE type='table' "
+            "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'"
+        )
+        src_tables = {row["name"] for row in cursor.fetchall()}
+
+        # Get Postgres tables from our ORM models
+        metadata = Base.metadata
+        pg_tables = set(metadata.tables.keys())
+
+        # Only import tables that exist in both source and destination
+        tables_to_import = src_tables & pg_tables
+        sorted_tables = [t.name for t in metadata.sorted_tables if t.name in tables_to_import]
+
+        # Phase 1: Drop all tables and recreate WITHOUT foreign keys.
+        # This avoids all FK ordering/orphan issues during import.
+        saved_fks = {}
+        for table in metadata.sorted_tables:
+            fks = list(table.foreign_key_constraints)
+            if fks:
+                saved_fks[table.name] = fks
+                for fk in fks:
+                    table.constraints.discard(fk)
+
+        async with pg_engine.begin() as conn:
+            await conn.run_sync(metadata.drop_all)
+            await conn.run_sync(metadata.create_all)
+
+        # Restore FK definitions in metadata (needed for re-adding later)
+        for table_name, fks in saved_fks.items():
+            table_obj = metadata.tables[table_name]
+            for fk in fks:
+                table_obj.constraints.add(fk)
+
+        # Phase 2: Import data (no FKs to worry about)
+        async with pg_engine.begin() as conn:
+            # Import each table in dependency order (parents before children)
+            for table_name in sorted_tables:
+                rows = src.execute(f"SELECT * FROM {table_name}").fetchall()  # noqa: S608  # nosec B608
+                if not rows:
+                    continue
+
+                # Filter to columns that exist in the Postgres table
+                src_columns = rows[0].keys()
+                pg_table = metadata.tables.get(table_name)
+                pg_columns = {c.name for c in pg_table.columns} if pg_table is not None else set()
+                columns = [c for c in src_columns if c in pg_columns]
+
+                if not columns:
+                    continue
+
+                col_list = ", ".join(columns)
+                param_list = ", ".join(f":{c}" for c in columns)
+                # ON CONFLICT DO NOTHING handles duplicate rows from SQLite (which doesn't enforce unique constraints)
+                insert_sql = text(f"INSERT INTO {table_name} ({col_list}) VALUES ({param_list}) ON CONFLICT DO NOTHING")  # noqa: S608  # nosec B608
+
+                # Identify columns that need type conversion (SQLite stores booleans
+                # as int and datetimes as str — asyncpg requires native Python types)
+                from datetime import datetime as dt
+
+                bool_columns = set()
+                datetime_columns = set()
+                not_null_defaults = {}  # col_name -> default value for NOT NULL columns
+                if pg_table is not None:
+                    for col in pg_table.columns:
+                        if col.name not in columns:
+                            continue
+                        col_type = str(col.type)
+                        if col_type == "BOOLEAN":
+                            bool_columns.add(col.name)
+                        elif col_type in ("DATETIME", "TIMESTAMP WITHOUT TIME ZONE", "TIMESTAMP WITH TIME ZONE"):
+                            datetime_columns.add(col.name)
+                        # Track NOT NULL columns with defaults — older backups may have NULL
+                        # for columns added after the backup was created
+                        if not col.nullable:
+                            if col.default is not None:
+                                default = col.default.arg
+                                if callable(default):
+                                    default = default(None)
+                                not_null_defaults[col.name] = default
+                            elif col.server_default is not None:
+                                # server_default=func.now() → use current timestamp
+                                if col.name in datetime_columns:
+                                    not_null_defaults[col.name] = "__now__"
+                                else:
+                                    # Try to extract literal server default
+                                    sd = str(col.server_default.arg) if hasattr(col.server_default, "arg") else None
+                                    if sd is not None:
+                                        not_null_defaults[col.name] = sd
+
+                now = dt.now()
+
+                def _convert_row(
+                    row, cols=columns, bools=bool_columns, dts=datetime_columns, nn_defaults=not_null_defaults, _now=now
+                ):
+                    result = {}
+                    for c in cols:
+                        val = row[c]
+                        if val is None and c in nn_defaults:
+                            val = _now if nn_defaults[c] == "__now__" else nn_defaults[c]
+                        if val is not None:
+                            if c in bools:
+                                val = bool(val)
+                            elif c in dts and isinstance(val, str):
+                                try:
+                                    val = dt.fromisoformat(val)
+                                except ValueError:
+                                    pass
+                        result[c] = val
+                    return result
+
+                batch = [_convert_row(row) for row in rows]
+                await conn.execute(insert_sql, batch)
+                logger.info("Imported %d rows into %s", len(batch), table_name)
+
+            # Reset sequences to max(id) + 1 for each table with an id column
+            for table_name in sorted_tables:
+                try:
+                    async with conn.begin_nested():
+                        result = await conn.execute(text(f"SELECT MAX(id) FROM {table_name}"))  # noqa: S608  # nosec B608
+                        max_id = result.scalar()
+                        if max_id is not None:
+                            seq_name = f"{table_name}_id_seq"
+                            await conn.execute(text(f"SELECT setval('{seq_name}', {max_id})"))  # noqa: S608
+                except Exception:
+                    pass  # Table may not have an id column or sequence
+
+        src.close()
+        logger.info("Cross-database import complete: %d tables imported", len(tables_to_import))
+
+        # Recreate FK constraints from ORM metadata (not from saved definitions).
+        # Use individual transactions so orphaned SQLite data doesn't block valid FKs.
+        from sqlalchemy.schema import AddConstraint
+
+        failed_fks = []
+        for table in metadata.sorted_tables:
+            for fk in table.foreign_key_constraints:
+                try:
+                    async with pg_engine.begin() as fk_conn:
+                        await fk_conn.execute(AddConstraint(fk))
+                except Exception:
+                    failed_fks.append(f"{table.name}.{fk.name}")
+        if failed_fks:
+            logger.warning(
+                "Could not restore %d FK constraints (orphaned data in SQLite): %s",
+                len(failed_fks),
+                ", ".join(failed_fks),
+            )
+
+    finally:
+        await pg_engine.dispose()
+
+
 @router.post("/restore")
 async def restore_backup(
     file: UploadFile = File(...),
@@ -422,8 +677,8 @@ async def restore_backup(
 ):
     """Restore from a complete backup ZIP.
 
-    This is a simplified restore that replaces the database and all data directories
-    from the backup ZIP. Requires a restart after restore.
+    Replaces the database and all data directories from the backup ZIP.
+    Requires a restart after restore.
     """
     import shutil
     import tempfile
@@ -431,10 +686,10 @@ async def restore_backup(
     from fastapi import HTTPException
 
     from backend.app.core.database import close_all_connections, init_db, reinitialize_database
+    from backend.app.core.db_dialect import is_sqlite
     from backend.app.services.virtual_printer import virtual_printer_manager
 
     base_dir = app_settings.base_dir
-    db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
 
     with tempfile.TemporaryDirectory() as temp_dir:
         temp_path = Path(temp_dir)
@@ -452,7 +707,7 @@ async def restore_backup(
         except zipfile.BadZipFile:
             raise HTTPException(400, "Invalid backup file: not a valid ZIP")
 
-        # 2. Validate backup (must have database)
+        # 2. Validate backup
         backup_db = temp_path / "bambuddy.db"
         if not backup_db.exists():
             raise HTTPException(400, "Invalid backup: missing bambuddy.db")
@@ -465,7 +720,6 @@ async def restore_backup(
                 if virtual_printer_manager.is_enabled:
                     logger.info("Stopping virtual printer for restore...")
                     await virtual_printer_manager.configure(enabled=False)
-                    # Give it time to fully release file handles
                     await asyncio.sleep(1)
             except Exception as e:
                 logger.warning("Failed to stop virtual printer: %s", e)
@@ -476,7 +730,13 @@ async def restore_backup(
 
             # 5. Replace database
             logger.info("Restoring database from backup...")
-            shutil.copy2(backup_db, db_path)
+            if is_sqlite():
+                db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
+                shutil.copy2(backup_db, db_path)
+            else:
+                # Import SQLite backup into PostgreSQL
+                logger.info("Importing SQLite backup into PostgreSQL...")
+                await _import_sqlite_to_postgres(backup_db, app_settings.database_url)
 
             # 6. Replace data directories
             # For Docker compatibility: clear contents then copy (don't delete mount points)

+ 15 - 0
backend/app/api/routes/smart_plugs.py

@@ -20,6 +20,8 @@ from backend.app.schemas.smart_plug import (
     HASensorEntity,
     HATestConnectionRequest,
     HATestConnectionResponse,
+    RESTTestConnectionRequest,
+    RESTTestConnectionResponse,
     SmartPlugControl,
     SmartPlugCreate,
     SmartPlugEnergy,
@@ -33,6 +35,7 @@ from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.notification_service import notification_service
 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.tasmota import tasmota_service
 
 logger = logging.getLogger(__name__)
@@ -341,6 +344,16 @@ async def test_ha_connection(
     return HATestConnectionResponse(**result)
 
 
+@router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
+async def test_rest_connection(
+    request: RESTTestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+):
+    """Test connection to a REST/HTTP endpoint."""
+    result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)
+    return RESTTestConnectionResponse(**result)
+
+
 @router.get("/ha/entities", response_model=list[HAEntity])
 async def list_ha_entities(
     db: AsyncSession = Depends(get_db),
@@ -557,6 +570,8 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
         ha_settings = await get_homeassistant_settings(db)
         homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         return homeassistant_service
+    if plug.plug_type == "rest":
+        return rest_smart_plug_service
     return tasmota_service
 
 

+ 75 - 0
backend/app/api/routes/spoolbuddy.py

@@ -28,6 +28,7 @@ from backend.app.schemas.spoolbuddy import (
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
     SetTareRequest,
+    SystemCommandRequest,
     SystemCommandResultRequest,
     SystemConfigRequest,
     TagRemovedRequest,
@@ -187,6 +188,26 @@ async def list_devices(
     return [_device_to_response(d) for d in devices]
 
 
+@router.delete("/devices/{device_id}")
+async def unregister_device(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
+):
+    """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    await db.delete(device)
+    await db.commit()
+    _spoolbuddy_online_last_broadcast.pop(device_id, None)
+    logger.info("SpoolBuddy device unregistered: %s (%s)", device_id, device.hostname)
+    await ws_manager.broadcast({"type": "spoolbuddy_unregistered", "device_id": device_id})
+    return {"status": "deleted", "device_id": device_id}
+
+
 @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
 async def device_heartbeat(
     device_id: str,
@@ -609,6 +630,28 @@ async def get_calibration(
 # --- Display settings ---
 
 
+@router.get("/devices/{device_id}/display")
+async def get_display_settings(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Read current display brightness and screen blank timeout for a device.
+
+    Used by the SpoolBuddy kiosk idle watchdog on autostart to configure
+    swayidle with the same timeout the user picked in the UI, without having
+    to wait for the daemon heartbeat to arrive first.
+    """
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+    return {
+        "brightness": device.display_brightness,
+        "blank_timeout": device.display_blank_timeout,
+    }
+
+
 @router.put("/devices/{device_id}/display")
 async def update_display_settings(
     device_id: str,
@@ -669,6 +712,38 @@ async def queue_system_config_update(
     return {"status": "queued", "message": "System config update queued"}
 
 
+VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browser"}
+
+
+@router.post("/devices/{device_id}/system/command")
+async def queue_system_command(
+    device_id: str,
+    req: SystemCommandRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device."""
+    if req.command not in VALID_SYSTEM_COMMANDS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Invalid command. Must be one of: {', '.join(sorted(VALID_SYSTEM_COMMANDS))}",
+        )
+
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if not _is_online(device):
+        raise HTTPException(status_code=409, detail="Device is offline")
+
+    device.pending_command = req.command
+    await db.commit()
+
+    logger.info("System command queued for device %s: %s", device_id, req.command)
+    return {"status": "queued", "command": req.command}
+
+
 @router.post("/devices/{device_id}/system/command-result")
 async def system_command_result(
     device_id: str,

+ 6 - 2
backend/app/api/routes/spoolman.py

@@ -197,6 +197,7 @@ async def sync_printer_ams(
     errors = []
     # Track tray UUIDs currently in the AMS (for clearing removed spools)
     current_tray_uuids: set[str] = set()
+    synced_spool_ids: set[int] = set()
 
     # Handle different AMS data structures
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -299,8 +300,9 @@ async def sync_printer_ams(
                 )
                 if sync_result:
                     synced += 1
-                    # Add newly created spool to cache
+                    # Add newly created spool to cache and track synced ID
                     if sync_result.get("id"):
+                        synced_spool_ids.add(sync_result["id"])
                         spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                         if not spool_exists:
                             cached_spools.append(sync_result)
@@ -319,7 +321,7 @@ async def sync_printer_ams(
     # Clear location for spools that were removed from this printer's AMS
     try:
         cleared = await client.clear_location_for_removed_spools(
-            printer.name, current_tray_uuids, cached_spools=cached_spools
+            printer.name, current_tray_uuids, cached_spools=cached_spools, synced_spool_ids=synced_spool_ids
         )
         if cleared > 0:
             logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
@@ -569,6 +571,7 @@ class UnlinkedSpool(BaseModel):
 
     id: int
     filament_name: str | None
+    filament_vendor: str | None
     filament_material: str | None
     filament_color_hex: str | None
     remaining_weight: float | None
@@ -611,6 +614,7 @@ async def get_unlinked_spools(
                 UnlinkedSpool(
                     id=spool["id"],
                     filament_name=filament.get("name"),
+                    filament_vendor=(filament.get("vendor") or {}).get("name"),
                     filament_material=filament.get("material"),
                     filament_color_hex=filament.get("color_hex"),
                     remaining_weight=spool.get("remaining_weight"),

+ 90 - 33
backend/app/api/routes/support.py

@@ -103,18 +103,14 @@ def _apply_log_level(debug: bool):
     for handler in root_logger.handlers:
         handler.setLevel(new_level)
 
-    # Also adjust third-party loggers
-    if debug:
-        logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
-        logging.getLogger("aiosqlite").setLevel(logging.WARNING)
-        logging.getLogger("httpcore").setLevel(logging.DEBUG)
-        logging.getLogger("httpx").setLevel(logging.DEBUG)
-        logging.getLogger("paho.mqtt").setLevel(logging.DEBUG)
-    else:
-        logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
-        logging.getLogger("httpcore").setLevel(logging.WARNING)
-        logging.getLogger("httpx").setLevel(logging.WARNING)
-        logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
+    # Also adjust third-party loggers. httpx/httpcore stay pinned to WARNING
+    # even in debug mode — at INFO/DEBUG they log full request URLs, which
+    # leaks secrets embedded in webhook URLs (Discord, generic webhooks, etc.).
+    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+    logging.getLogger("aiosqlite").setLevel(logging.WARNING)
+    logging.getLogger("httpcore").setLevel(logging.WARNING)
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("paho.mqtt").setLevel(logging.DEBUG if debug else logging.WARNING)
 
     logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
 
@@ -560,7 +556,10 @@ async def _collect_support_info() -> dict:
         except Exception:
             logger.debug("Failed to collect virtual printer info", exc_info=True)
 
-        # Non-sensitive settings
+        # All settings — sensitive values are redacted rather than dropped so
+        # new settings automatically show up in support bundles without a code
+        # change. The value is replaced with "[REDACTED]" but the key is kept
+        # so we can still see which integrations are configured.
         result = await db.execute(select(Settings))
         all_settings = result.scalars().all()
         sensitive_keys = {
@@ -581,12 +580,17 @@ async def _collect_support_info() -> dict:
             "url",
             "path",  # Filesystem paths may contain usernames
             "config",  # URLs may contain IPs, configs may have embedded secrets
+            "_ip",  # IP address fields (e.g. virtual_printer_remote_interface_ip)
+            "host",
+            "credential",
         }
         for s in all_settings:
-            # Skip sensitive settings
-            if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
-                continue
-            info["settings"][s.key] = s.value
+            key_lower = s.key.lower()
+            if any(sensitive in key_lower for sensitive in sensitive_keys):
+                # Preserve shape: mark presence without leaking the value
+                info["settings"][s.key] = "[REDACTED]" if s.value else ""
+            else:
+                info["settings"][s.key] = s.value
 
         # Notification providers (anonymized — type/enabled/error status only)
         try:
@@ -606,22 +610,37 @@ async def _collect_support_info() -> dict:
 
         # Database health
         try:
-            result = await db.execute(text("PRAGMA journal_mode"))
-            journal_mode = result.scalar()
-            result = await db.execute(text("PRAGMA quick_check"))
-            quick_check = result.scalar()
-
-            db_path = settings.base_dir / "bambuddy.db"
-            db_size = db_path.stat().st_size if db_path.exists() else 0
-            wal_path = settings.base_dir / "bambuddy.db-wal"
-            wal_size = wal_path.stat().st_size if wal_path.exists() else 0
-
-            info["database_health"] = {
-                "journal_mode": journal_mode,
-                "quick_check": quick_check,
-                "db_size_bytes": db_size,
-                "wal_size_bytes": wal_size,
-            }
+            from backend.app.core.db_dialect import is_sqlite
+
+            if is_sqlite():
+                result = await db.execute(text("PRAGMA journal_mode"))
+                journal_mode = result.scalar()
+                result = await db.execute(text("PRAGMA quick_check"))
+                quick_check = result.scalar()
+
+                db_path = settings.base_dir / "bambuddy.db"
+                db_size = db_path.stat().st_size if db_path.exists() else 0
+                wal_path = settings.base_dir / "bambuddy.db-wal"
+                wal_size = wal_path.stat().st_size if wal_path.exists() else 0
+
+                info["database_health"] = {
+                    "backend": "sqlite",
+                    "journal_mode": journal_mode,
+                    "quick_check": quick_check,
+                    "db_size_bytes": db_size,
+                    "wal_size_bytes": wal_size,
+                }
+            else:
+                result = await db.execute(text("SELECT version()"))
+                pg_version = result.scalar()
+                result = await db.execute(text("SELECT pg_database_size(current_database())"))
+                db_size = result.scalar() or 0
+
+                info["database_health"] = {
+                    "backend": "postgresql",
+                    "version": pg_version,
+                    "db_size_bytes": db_size,
+                }
         except Exception:
             logger.debug("Failed to collect database health info", exc_info=True)
 
@@ -656,6 +675,44 @@ async def _collect_support_info() -> dict:
     except Exception:
         logger.debug("Failed to collect MQTT relay info", exc_info=True)
 
+    # SpoolBuddy devices (anonymized — no hostnames, IPs or device IDs)
+    try:
+        async with async_session() as db:
+            from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+            result = await db.execute(select(SpoolBuddyDevice))
+            devices = result.scalars().all()
+            info["integrations"]["spoolbuddy"] = {
+                "device_count": len(devices),
+                "online_count": sum(
+                    1
+                    for d in devices
+                    if d.last_seen
+                    and (datetime.now(tz=timezone.utc) - d.last_seen.replace(tzinfo=timezone.utc)).total_seconds() < 30
+                ),
+                "devices": [
+                    {
+                        "index": i + 1,
+                        "firmware_version": d.firmware_version,
+                        "has_nfc": d.has_nfc,
+                        "has_scale": d.has_scale,
+                        "nfc_reader_type": d.nfc_reader_type,
+                        "nfc_connection": d.nfc_connection,
+                        "has_backlight": d.has_backlight,
+                        "nfc_ok": d.nfc_ok,
+                        "scale_ok": d.scale_ok,
+                        "uptime_s": d.uptime_s,
+                        "calibration_factor": d.calibration_factor,
+                        "tare_offset": d.tare_offset,
+                        "last_calibrated_at": d.last_calibrated_at.isoformat() if d.last_calibrated_at else None,
+                        "update_status": d.update_status,
+                    }
+                    for i, d in enumerate(devices)
+                ],
+            }
+    except Exception:
+        logger.debug("Failed to collect SpoolBuddy info", exc_info=True)
+
     # Home Assistant (check ha_enabled setting)
     try:
         info["integrations"]["homeassistant"] = {

+ 38 - 3
backend/app/api/routes/system.py

@@ -80,6 +80,10 @@ def _is_under(path: Path, root: Path) -> bool:
 
 
 def _get_database_paths() -> list[Path]:
+    from backend.app.core.db_dialect import is_sqlite
+
+    if not is_sqlite():
+        return []  # PostgreSQL — no local DB files
     candidates = [settings.base_dir / "bambuddy.db", settings.base_dir / "bambutrack.db"]
     return [path for path in candidates if path.exists()]
 
@@ -449,9 +453,38 @@ async def get_system_info(
     archive_dir = settings.archive_dir
     archive_size = get_directory_size(archive_dir) if archive_dir.exists() else 0
 
-    # Database file size
-    db_path = settings.base_dir / "bambuddy.db"
-    db_size = db_path.stat().st_size if db_path.exists() else 0
+    # Database info (engine type, version, size)
+    from backend.app.core.db_dialect import is_postgres, is_sqlite
+
+    db_engine_info: dict = {"engine": "unknown", "version": "unknown"}
+    db_size = 0
+    try:
+        if is_postgres():
+            from sqlalchemy import text
+
+            result = await db.execute(text("SELECT version()"))
+            pg_version_full = result.scalar() or "unknown"
+            # e.g. "PostgreSQL 16.2 on x86_64..." → "PostgreSQL 16.2"
+            pg_version = " ".join(pg_version_full.split()[:2])
+            result = await db.execute(text("SELECT pg_database_size(current_database())"))
+            db_size = result.scalar() or 0
+            db_engine_info = {
+                "engine": "PostgreSQL",
+                "version": pg_version,
+            }
+        elif is_sqlite():
+            from sqlalchemy import text
+
+            result = await db.execute(text("SELECT sqlite_version()"))
+            sqlite_ver = result.scalar() or "unknown"
+            db_path = settings.base_dir / "bambuddy.db"
+            db_size = db_path.stat().st_size if db_path.exists() else 0
+            db_engine_info = {
+                "engine": "SQLite",
+                "version": f"SQLite {sqlite_ver}",
+            }
+    except Exception:
+        pass
 
     # Disk usage
     disk = psutil.disk_usage(str(settings.base_dir))
@@ -471,6 +504,8 @@ async def get_system_info(
             "archive_dir": str(archive_dir),
         },
         "database": {
+            "engine": db_engine_info["engine"],
+            "version": db_engine_info["version"],
             "archives": archive_count,
             "archives_completed": completed_count,
             "archives_failed": failed_count,

+ 10 - 2
backend/app/api/routes/updates.py

@@ -42,8 +42,16 @@ def _is_docker_environment() -> bool:
                 return True
     except (FileNotFoundError, PermissionError):
         pass  # cgroup file unavailable; continue with other detection methods
-    git_dir = settings.base_dir / ".git"
-    return not git_dir.exists()
+    # Check container runtime hint (systemd sets this for Docker/podman,
+    # but NOT for LXC/LXD — avoids false positives on Proxmox containers)
+    try:
+        with open("/run/systemd/container") as f:
+            runtime = f.read().strip()
+            if runtime in ("docker", "podman", "oci"):
+                return True
+    except (FileNotFoundError, PermissionError):
+        pass
+    return False
 
 
 def _find_executable(name: str) -> str | None:

+ 59 - 5
backend/app/api/routes/users.py

@@ -1,13 +1,22 @@
+from datetime import datetime, timezone
+from typing import Annotated
+
+import jwt as _jwt
 from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
+    ALGORITHM,
+    SECRET_KEY,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_password_hash,
+    revoke_jti,
+    security,
     verify_password,
 )
 from backend.app.core.database import get_db
@@ -38,6 +47,7 @@ def _user_to_response(user: User) -> UserResponse:
         role=user.role,
         is_active=user.is_active,
         is_admin=user.is_admin,
+        auth_source=getattr(user, "auth_source", "local"),
         groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
         permissions=sorted(user.get_permissions()),
         created_at=user.created_at.isoformat(),
@@ -253,6 +263,11 @@ async def update_user(
         user.email = user_data.email
 
     if user_data.password is not None:
+        if getattr(user, "auth_source", "local") == "ldap":
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot set password for LDAP users",
+            )
         user.password_hash = get_password_hash(user_data.password)
 
     if user_data.role is not None:
@@ -392,6 +407,7 @@ async def delete_user(
 @router.post("/me/change-password", response_model=dict)
 async def change_own_password(
     password_data: ChangePasswordRequest,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     current_user: User | None = Depends(get_current_user_optional),
     db: AsyncSession = Depends(get_db),
 ):
@@ -402,18 +418,30 @@ async def change_own_password(
             detail="Authentication required to change password",
         )
 
+    # Block password change for LDAP users
+    if getattr(current_user, "auth_source", "local") == "ldap":
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot change password for LDAP users — passwords are managed by the LDAP server",
+        )
+
     # Verify current password
-    if not verify_password(password_data.current_password, current_user.password_hash):
+    if not current_user.password_hash:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
-            detail="Current password is incorrect",
+            detail="Account has no local password set",
         )
 
-    # Validate new password
-    if len(password_data.new_password) < 6:
+    # Rate-limit failed password-change attempts (H-R5-A)
+    from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt
+
+    await check_rate_limit(db, current_user.username, event_type="password_change", max_attempts=MAX_2FA_ATTEMPTS)
+
+    if not verify_password(password_data.current_password, current_user.password_hash):
+        await record_failed_attempt(db, current_user.username, event_type="password_change")
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
-            detail="New password must be at least 6 characters",
+            detail="Current password is incorrect",
         )
 
     # Fetch user from this session to ensure changes are persisted
@@ -427,6 +455,32 @@ async def change_own_password(
 
     # Update password
     user.password_hash = get_password_hash(password_data.new_password)
+    user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs
     await db.commit()
 
+    # L-R6-A: Password verified successfully — reset the failure counter
+    from backend.app.api.routes.mfa import clear_failed_attempts
+
+    await clear_failed_attempts(db, user.username, event_type="password_change")
+
+    # Revoke the current session token so the caller must re-authenticate (M-R5-A)
+    if credentials is not None:
+        try:
+            payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
+            jti = payload.get("jti")
+            exp = payload.get("exp")
+            if jti and exp:
+                try:
+                    await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)
+                except Exception as exc:
+                    # B4: log so operators know revocation is broken; password was
+                    # already changed so the token will fail freshness checks anyway.
+                    import logging
+
+                    logging.getLogger(__name__).error(
+                        "Failed to revoke JTI after password change for user %s: %s", user.username, exc
+                    )
+        except Exception:
+            pass  # Decode failure is harmless — token is already invalidated by password_changed_at
+
     return {"message": "Password changed successfully"}

+ 1 - 1
backend/app/api/routes/webhook.py

@@ -294,7 +294,7 @@ async def webhook_get_queue_status(
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
         # Filter by allowed printers if limited
-        if api_key.printer_ids:
+        if api_key.printer_ids is not None:
             printers = [p for p in printers if p.id in api_key.printer_ids]
 
     response = []

+ 128 - 0
backend/app/cli.py

@@ -0,0 +1,128 @@
+"""Bambuddy administrative CLI.
+
+Invoked via ``python -m backend.app.cli <subcommand>``.
+
+Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk
+API key during install (see ``spoolbuddy/install/install.sh``).
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+from backend.app.core.auth import generate_api_key
+from backend.app.core.database import async_session as default_session_maker, init_db
+from backend.app.core.db_dialect import upsert_setting
+from backend.app.models.api_key import APIKey
+from backend.app.models.settings import Settings
+
+DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
+
+
+class KioskBootstrapError(RuntimeError):
+    """Raised when an existing kiosk key would be silently overwritten."""
+
+
+async def kiosk_bootstrap(
+    name: str,
+    *,
+    force: bool,
+    session_maker: async_sessionmaker | None = None,
+    ensure_schema: bool = True,
+) -> str:
+    """Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
+
+    The returned value is the one-time full key string; callers are responsible
+    for writing it somewhere secure — it cannot be retrieved again.
+    """
+    if ensure_schema and session_maker is None:
+        await init_db()
+
+    maker = session_maker or default_session_maker
+
+    async with maker() as db:
+        existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
+
+        if existing and not force:
+            raise KioskBootstrapError(
+                f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
+            )
+
+        if existing:
+            await db.delete(existing)
+            await db.flush()
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        row = APIKey(
+            name=name,
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            can_queue=False,
+            can_control_printer=False,
+            can_read_status=True,
+            printer_ids=None,
+            enabled=True,
+            expires_at=None,
+        )
+        db.add(row)
+
+        # Mark first-run setup as completed so the kiosk URL loads directly
+        # instead of being force-redirected to /setup by AuthContext. Without
+        # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy
+        # first-run wizard (touch-only Pi has no keyboard to complete it).
+        # Users who want authentication enable it later from the admin UI; the
+        # API key we just created is already valid so the kiosk keeps working.
+        await upsert_setting(db, Settings, "setup_completed", "true")
+
+        await db.commit()
+        return full_key
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="python -m backend.app.cli",
+        description="Bambuddy administrative commands",
+    )
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    kiosk = sub.add_parser(
+        "kiosk-bootstrap",
+        help="Create an API key for the SpoolBuddy kiosk",
+        description=(
+            "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
+            "kiosk. The full key is printed to stdout — capture it into "
+            "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
+        ),
+    )
+    kiosk.add_argument(
+        "--name",
+        default=DEFAULT_KIOSK_KEY_NAME,
+        help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
+    )
+    kiosk.add_argument(
+        "--force",
+        action="store_true",
+        help="Rotate an existing key with the same name (deletes the old one)",
+    )
+
+    args = parser.parse_args(argv)
+
+    if args.command == "kiosk-bootstrap":
+        try:
+            key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
+        except KioskBootstrapError as exc:
+            print(str(exc), file=sys.stderr)
+            return 1
+        print(key)
+        return 0
+
+    return 2
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 296 - 44
backend/app/core/auth.py

@@ -12,13 +12,14 @@ from fastapi import Depends, Header, HTTPException, status
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
-from sqlalchemy import func, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 
@@ -93,47 +94,118 @@ def _get_jwt_secret() -> str:
 # JWT settings
 SECRET_KEY = _get_jwt_secret()
 ALGORITHM = "HS256"
-ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
+ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24 hours (M-2: reduced from 7 days)
 
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 
 # --- Slicer download tokens ---
-# Short-lived tokens for slicer protocol handlers that can't send auth headers.
-# Maps token → (resource_key, expiry). resource_key = "archive:{id}" or "library:{id}".
-_slicer_tokens: dict[str, tuple[str, datetime]] = {}
+# Short-lived, single-use tokens for slicer protocol handlers that can't send
+# auth headers.  Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
+# so they survive server restarts and work in multi-worker deployments (M-3).
 SLICER_TOKEN_EXPIRE_MINUTES = 5
 
 
-def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
-    """Create a short-lived download token for slicer protocol handlers."""
-    # Cleanup expired tokens
+async def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
+    """Create a short-lived, single-use download token for slicer protocol handlers."""
     now = datetime.now(timezone.utc)
-    expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
-    for k in expired:
-        del _slicer_tokens[k]
-
+    expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
     resource_key = f"{resource_type}:{resource_id}"
-    _slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
+    async with async_session() as db:
+        # Prune expired tokens opportunistically
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type=TokenType.SLICER_DOWNLOAD,
+                nonce=resource_key,
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
     return token
 
 
-def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
-    """Verify a slicer download token is valid for the given resource."""
-    entry = _slicer_tokens.get(token)
-    if not entry:
-        return False
-    resource_key, expiry = entry
-    if datetime.now(timezone.utc) > expiry:
-        del _slicer_tokens[token]
-        return False
+async def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
+    """Verify and atomically consume a slicer download token.
+
+    Returns True only if the token is valid, unexpired, and bound to the given resource.
+    DELETE...RETURNING ensures the token is single-use even under concurrent requests.
+
+    M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so the DELETE
+    only succeeds when the token is presented to the *correct* resource endpoint.
+    Previously the token was consumed (committed) even when stored_key != expected_key,
+    permanently invalidating it while returning False to the caller.
+    """
     expected_key = f"{resource_type}:{resource_id}"
-    if resource_key != expected_key:
-        return False
-    # Token is single-use
-    del _slicer_tokens[token]
-    return True
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            delete(AuthEphemeralToken)
+            .where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
+                AuthEphemeralToken.nonce == expected_key,
+                AuthEphemeralToken.expires_at > now,
+            )
+            .returning(AuthEphemeralToken.id)
+        )
+        if result.one_or_none() is None:
+            return False
+        await db.commit()
+        return True
+
+
+# --- Camera stream tokens ---
+# Reusable tokens for camera stream/snapshot endpoints loaded via <img>/<video>
+# tags (these cannot send Authorization headers).  Unlike slicer tokens they are
+# NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken
+# (token_type="camera_stream") for multi-worker compatibility (M-3).
+CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
+
+
+async def create_camera_stream_token() -> str:
+    """Create a reusable token for camera stream/snapshot access."""
+    now = datetime.now(timezone.utc)
+    expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
+    token = secrets.token_urlsafe(24)
+    async with async_session() as db:
+        # Prune expired tokens opportunistically
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == "camera_stream",
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type="camera_stream",
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
+    return token
+
+
+async def verify_camera_stream_token(token: str) -> bool:
+    """Verify a camera stream token is valid (reusable — does not consume it)."""
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == "camera_stream",
+                AuthEphemeralToken.expires_at > now,
+            )
+        )
+        return result.scalar_one_or_none() is not None
 
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -153,17 +225,73 @@ def get_password_hash(password: str) -> str:
 
 
 def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
-    """Create a JWT access token."""
+    """Create a JWT access token with jti (revocation) and iat (freshness) claims."""
     to_encode = data.copy()
+    now = datetime.now(timezone.utc)
     if expires_delta:
-        expire = datetime.now(timezone.utc) + expires_delta
+        expire = now + expires_delta
     else:
-        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
-    to_encode.update({"exp": expire})
+        expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    jti = secrets.token_hex(16)
+    to_encode.update({"exp": expire, "jti": jti, "iat": now})
     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
     return encoded_jwt
 
 
+def _is_token_fresh(iat: int | float | None, user: User) -> bool:
+    """Return False if the token was issued before the user's last password change.
+
+    Used to invalidate all sessions after a password reset/change (M-R7-B).
+    All tokens without an iat claim are unconditionally rejected — every token
+    issued by this server carries iat, so absence means the token is forged or
+    from a pre-iat code path whose max TTL (24 h) has long since expired.
+    """
+    if iat is None:
+        return False
+    if not hasattr(user, "password_changed_at") or user.password_changed_at is None:
+        return True  # No password change recorded yet (I2 migration handles this)
+    token_issued_at = datetime.fromtimestamp(iat, tz=timezone.utc)
+    pca = user.password_changed_at
+    if pca.tzinfo is None:
+        pca = pca.replace(tzinfo=timezone.utc)
+    # JWT iat is whole seconds; truncate pca so tokens issued in the same second pass.
+    pca = pca.replace(microsecond=0)
+    return token_issued_at >= pca
+
+
+async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None) -> None:
+    """Store a revoked JWT jti so it is rejected on future requests.
+
+    Silently ignores duplicate inserts (e.g. double-logout with the same token).
+    """
+    from sqlalchemy.exc import IntegrityError
+
+    async with async_session() as db:
+        revoked = AuthEphemeralToken(
+            token=jti,
+            token_type="revoked_jti",
+            username=username,
+            expires_at=expires_at,
+        )
+        db.add(revoked)
+        try:
+            await db.commit()
+        except IntegrityError:
+            await db.rollback()  # jti already revoked — desired state, ignore
+
+
+async def is_jti_revoked(jti: str) -> bool:
+    """Return True if the given jti has been revoked."""
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == jti,
+                AuthEphemeralToken.token_type == "revoked_jti",
+            )
+        )
+        return result.scalar_one_or_none() is not None
+
+
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
     """Get a user by username (case-insensitive) with groups loaded for permission checks."""
     result = await db.execute(
@@ -184,11 +312,14 @@ async def authenticate_user(db: AsyncSession, username: str, password: str) -> U
     """Authenticate a user by username and password.
 
     Username lookup is case-insensitive. Password is case-sensitive.
+    LDAP and OIDC users must authenticate via their respective providers.
     """
     user = await get_user_by_username(db, username)
     if not user:
         return None
-    if not verify_password(password, user.password_hash):
+    if getattr(user, "auth_source", "local") in ("ldap", "oidc"):
+        return None  # LDAP/OIDC users must authenticate via their provider
+    if not user.password_hash or not verify_password(password, user.password_hash):
         return None
     if not user.is_active:
         return None
@@ -199,11 +330,14 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
     """Authenticate a user by email and password.
 
     Email lookup is case-insensitive. Password is case-sensitive.
+    LDAP and OIDC users must authenticate via their respective providers.
     """
     user = await get_user_by_email(db, email)
     if not user:
         return None
-    if not verify_password(password, user.password_hash):
+    if getattr(user, "auth_source", "local") in ("ldap", "oidc"):
+        return None  # LDAP/OIDC users must authenticate via their provider
+    if not user.password_hash or not verify_password(password, user.password_hash):
         return None
     if not user.is_active:
         return None
@@ -226,10 +360,23 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
-    This is an internal helper used by auth functions to check API keys.
+    L-1: Pre-filter by key_prefix (first 8 chars) before running pbkdf2 so only
+    O(1) candidate rows are hashed instead of the full key table.  The prefix is
+    not secret (it is shown in the admin UI), so this does not reduce security.
     """
     try:
-        result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+        # key_prefix is stored as "<first-8-chars>..." (e.g. "bb_Abc12...").
+        # Matching on the first 8 chars of the submitted key reduces the scan to
+        # at most one row in practice (2^40 collision space for 5 base64 chars).
+        key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
+        result = await db.execute(
+            select(APIKey).where(
+                APIKey.enabled.is_(True),
+                APIKey.key_prefix.like(
+                    key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%", escape="\\"
+                ),
+            )
+        )
         api_keys = result.scalars().all()
 
         for api_key in api_keys:
@@ -253,23 +400,40 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
 async def get_current_user_optional(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
 ) -> User | None:
-    """Get the current authenticated user from JWT token, or None if not authenticated."""
+    """Get the current authenticated user from JWT token, or None if not authenticated.
+
+    Returns None only when NO credentials are supplied.  If a token is supplied
+    but invalid/revoked, raises 401 — a revoked token must not grant anonymous
+    access (I6).
+    """
     if credentials is None:
         return None
 
+    _unauthorized = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Could not validate credentials",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
+
     try:
         token = credentials.credentials
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         username: str = payload.get("sub")
         if username is None:
-            return None
+            raise _unauthorized
+        jti: str | None = payload.get("jti")
+        if not jti or await is_jti_revoked(jti):
+            raise _unauthorized  # I6: revoked token → 401, not anonymous
+        iat: int | float | None = payload.get("iat")
     except JWTError:
-        return None
+        raise _unauthorized
 
     async with async_session() as db:
         user = await get_user_by_username(db, username)
         if user is None or not user.is_active:
-            return None
+            raise _unauthorized
+        if not _is_token_fresh(iat, user):
+            raise _unauthorized
         return user
 
 
@@ -290,6 +454,10 @@ async def get_current_user(
         username: str = payload.get("sub")
         if username is None:
             raise credentials_exception
+        jti: str | None = payload.get("jti")
+        if not jti or await is_jti_revoked(jti):
+            raise credentials_exception
+        iat: int | float | None = payload.get("iat")
     except JWTError:
         raise credentials_exception
 
@@ -302,6 +470,8 @@ async def get_current_user(
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="User account is disabled",
             )
+        if not _is_token_fresh(iat, user):
+            raise credentials_exception
         return user
 
 
@@ -354,6 +524,14 @@ async def require_auth_if_enabled(
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                iat: int | float | None = payload.get("iat")
             except JWTError:
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -368,6 +546,12 @@ async def require_auth_if_enabled(
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                 )
+            if not _is_token_fresh(iat, user):
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
             return user
 
         # No credentials provided
@@ -447,8 +631,18 @@ async def get_api_key(
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
         )
 
-    # Get all API keys and check them
-    result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+    # M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all
+    # enabled keys — same fix as in _validate_api_key (L-1 from previous review).
+    key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
+    result = await db.execute(
+        select(APIKey).where(
+            APIKey.enabled.is_(True),
+            APIKey.key_prefix.like(
+                key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%",
+                escape="\\",
+            ),
+        )
+    )
     api_keys = result.scalars().all()
 
     for api_key in api_keys:
@@ -515,11 +709,11 @@ def check_printer_access(api_key: APIKey, printer_id: int) -> None:
     Raises:
         HTTPException: If access is denied
     """
-    # If printer_ids is None or empty, access to all printers
-    if api_key.printer_ids is None or len(api_key.printer_ids) == 0:
+    # None = global key, access to all printers
+    if api_key.printer_ids is None:
         return
 
-    # Check if printer_id is in allowed list
+    # Empty list or printer not in allowed list = no access
     if printer_id not in api_key.printer_ids:
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
@@ -591,12 +785,18 @@ def require_permission(*permissions: str | Permission):
                 username: str = payload.get("sub")
                 if username is None:
                     raise credentials_exception
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise credentials_exception
+                iat: int | float | None = payload.get("iat")
             except JWTError:
                 raise credentials_exception
 
             user = await get_user_by_username(db, username)
             if user is None or not user.is_active:
                 raise credentials_exception
+            if not _is_token_fresh(iat, user):
+                raise credentials_exception
 
             if not user.has_all_permissions(*perm_strings):
                 raise HTTPException(
@@ -663,6 +863,14 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                             detail="Could not validate credentials",
                             headers={"WWW-Authenticate": "Bearer"},
                         )
+                    jti: str | None = payload.get("jti")
+                    if not jti or await is_jti_revoked(jti):
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                    iat: int | float | None = payload.get("iat")
                 except JWTError:
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -677,6 +885,12 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
+                if not _is_token_fresh(iat, user):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
 
                 if not user.has_all_permissions(*perm_strings):
                     raise HTTPException(
@@ -705,6 +919,30 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
+def require_camera_stream_token_if_auth_enabled():
+    """Dependency that validates a camera stream token query param when auth is enabled.
+
+    Used for camera stream/snapshot endpoints that are loaded via <img> tags
+    which cannot send Authorization headers. The frontend obtains a token from
+    POST /printers/camera/stream-token and appends it as ?token=xxx.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_camera_stream_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
+            )
+
+    return checker
+
+
+RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,
@@ -768,6 +1006,14 @@ def require_ownership_permission(
                             detail="Could not validate credentials",
                             headers={"WWW-Authenticate": "Bearer"},
                         )
+                    jti: str | None = payload.get("jti")
+                    if not jti or await is_jti_revoked(jti):
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                    iat: int | float | None = payload.get("iat")
                 except JWTError:
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -782,6 +1028,12 @@ def require_ownership_permission(
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
+                if not _is_token_fresh(iat, user):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
 
                 if user.has_permission(all_perm):
                     return user, True

+ 0 - 317
backend/app/core/bambu_colors.py

@@ -1,317 +0,0 @@
-"""Bambu Lab filament color code to color name mapping.
-
-Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
-
-Maps tray_id_name codes (e.g. "A06-D0") to human-readable color names (e.g. "Titan Gray").
-"""
-
-# Full color code → name mapping by material prefix
-BAMBU_FILAMENT_COLORS: dict[str, str] = {
-    # PLA Basic (A00)
-    "A00-W1": "Jade White",
-    "A00-P0": "Beige",
-    "A00-D2": "Light Gray",
-    "A00-Y0": "Yellow",
-    "A00-Y2": "Sunflower Yellow",
-    "A00-A1": "Pumpkin Orange",
-    "A00-A0": "Orange",
-    "A00-Y4": "Gold",
-    "A00-G3": "Bright Green",
-    "A00-G1": "Bambu Green",
-    "A00-G2": "Mistletoe Green",
-    "A00-R3": "Hot Pink",
-    "A00-P6": "Magenta",
-    "A00-R0": "Red",
-    "A00-R2": "Maroon Red",
-    "A00-P5": "Purple",
-    "A00-P2": "Indigo Purple",
-    "A00-B5": "Turquoise",
-    "A00-B8": "Cyan",
-    "A00-B3": "Cobalt Blue",
-    "A00-N0": "Brown",
-    "A00-N1": "Cocoa Brown",
-    "A00-Y3": "Bronze",
-    "A00-D0": "Gray",
-    "A00-D1": "Silver",
-    "A00-B1": "Blue Grey",
-    "A00-D3": "Dark Gray",
-    "A00-K0": "Black",
-    # PLA Basic Gradient (A00-M*)
-    "A00-M3": "Pink Citrus",
-    "A00-M6": "Dusk Glare",
-    "A00-M0": "Arctic Whisper",
-    "A00-M1": "Solar Breeze",
-    "A00-M5": "Blueberry Bubblegum",
-    "A00-M4": "Mint Lime",
-    "A00-M2": "Ocean to Meadow",
-    "A00-M7": "Cotton Candy Cloud",
-    # PLA Lite (A18)
-    "A18-K0": "Black",
-    "A18-D0": "Gray",
-    "A18-W0": "White",
-    "A18-R0": "Red",
-    "A18-Y0": "Yellow",
-    "A18-B0": "Cyan",
-    "A18-B1": "Blue",
-    "A18-P0": "Matte Beige",
-    # PLA Matte (A01)
-    "A01-W2": "Ivory White",
-    "A01-W3": "Bone White",
-    "A01-Y2": "Lemon Yellow",
-    "A01-A2": "Mandarin Orange",
-    "A01-P3": "Sakura Pink",
-    "A01-P4": "Lilac Purple",
-    "A01-R3": "Plum",
-    "A01-R1": "Scarlet Red",
-    "A01-R4": "Dark Red",
-    "A01-G0": "Apple Green",
-    "A01-G1": "Grass Green",
-    "A01-G7": "Dark Green",
-    "A01-B4": "Ice Blue",
-    "A01-B0": "Sky Blue",
-    "A01-B3": "Marine Blue",
-    "A01-B6": "Dark Blue",
-    "A01-Y3": "Desert Tan",
-    "A01-N1": "Latte Brown",
-    "A01-N3": "Caramel",
-    "A01-R2": "Terracotta",
-    "A01-N2": "Dark Brown",
-    "A01-N0": "Dark Chocolate",
-    "A01-D3": "Ash Gray",
-    "A01-D0": "Nardo Gray",
-    "A01-K1": "Charcoal",
-    # PLA Glow (A12)
-    "A12-G0": "Green",
-    "A12-R0": "Pink",
-    "A12-A0": "Orange",
-    "A12-Y0": "Yellow",
-    "A12-B0": "Blue",
-    # PLA Marble (A07)
-    "A07-R5": "Red Granite",
-    "A07-D4": "White Marble",
-    # PLA Aero (A11)
-    "A11-W0": "White",
-    "A11-K0": "Black",
-    # PLA Sparkle (A08)
-    "A08-G3": "Alpine Green Sparkle",
-    "A08-D5": "Slate Gray Sparkle",
-    "A08-B7": "Royal Purple Sparkle",
-    "A08-R2": "Crimson Red Sparkle",
-    "A08-K2": "Onyx Black Sparkle",
-    "A08-Y1": "Classic Gold Sparkle",
-    # PLA Metal (A02)
-    "A02-B2": "Cobalt Blue Metallic",
-    "A02-G2": "Oxide Green Metallic",
-    "A02-Y1": "Iridium Gold Metallic",
-    "A02-D2": "Iron Gray Metallic",
-    # PLA Translucent (A17)
-    "A17-B1": "Blue",
-    "A17-A0": "Orange",
-    "A17-P0": "Purple",
-    # PLA Silk+ (A06)
-    "A06-Y1": "Gold",
-    "A06-D0": "Titan Gray",
-    "A06-D1": "Silver",
-    "A06-W0": "White",
-    "A06-R0": "Candy Red",
-    "A06-G0": "Candy Green",
-    "A06-G1": "Mint",
-    "A06-B1": "Blue",
-    "A06-B0": "Baby Blue",
-    "A06-P0": "Purple",
-    "A06-R1": "Rose Gold",
-    "A06-R2": "Pink",
-    "A06-Y0": "Champagne",
-    # PLA Silk Multi-Color (A05)
-    "A05-M8": "Dawn Radiance",
-    "A05-M4": "Aurora Purple",
-    "A05-M1": "South Beach",
-    "A05-T3": "Neon City",
-    "A05-T2": "Midnight Blaze",
-    "A05-T1": "Gilded Rose",
-    "A05-T4": "Blue Hawaii",
-    "A05-T5": "Velvet Eclipse",
-    # PLA Galaxy (A15)
-    "A15-B0": "Purple",
-    "A15-G0": "Green",
-    "A15-G1": "Nebulae",
-    "A15-R0": "Brown",
-    # PLA Wood (A16)
-    "A16-K0": "Black Walnut",
-    "A16-R0": "Rosewood",
-    "A16-N0": "Clay Brown",
-    "A16-G0": "Classic Birch",
-    "A16-W0": "White Oak",
-    "A16-Y0": "Ochre Yellow",
-    # PLA-CF (A50)
-    "A50-D6": "Lava Gray",
-    "A50-K0": "Black",
-    "A50-B6": "Royal Blue",
-    # PLA Tough+ (A10)
-    "A10-W0": "White",
-    "A10-D0": "Gray",
-    # PLA Tough (A09)
-    "A09-B5": "Lavender Blue",
-    "A09-B4": "Light Blue",
-    "A09-A0": "Orange",
-    "A09-D1": "Silver",
-    "A09-R3": "Vermilion Red",
-    "A09-Y0": "Yellow",
-    # PETG HF (G02)
-    "G02-K0": "Black",
-    "G02-W0": "White",
-    "G02-R0": "Red",
-    "G02-D0": "Gray",
-    "G02-D1": "Dark Gray",
-    "G02-Y1": "Cream",
-    "G02-Y0": "Yellow",
-    "G02-A0": "Orange",
-    "G02-N1": "Peanut Brown",
-    "G02-G1": "Lime Green",
-    "G02-G0": "Green",
-    "G02-G2": "Forest Green",
-    "G02-B1": "Lake Blue",
-    "G02-B0": "Blue",
-    # PETG Translucent (G01)
-    "G01-G1": "Translucent Teal",
-    "G01-B0": "Translucent Light Blue",
-    "G01-C0": "Clear",
-    "G01-D0": "Translucent Gray",
-    "G01-G0": "Translucent Olive",
-    "G01-N0": "Translucent Brown",
-    "G01-A0": "Translucent Orange",
-    "G01-P1": "Translucent Pink",
-    "G01-P0": "Translucent Purple",
-    # PETG-CF (G50)
-    "G50-P7": "Violet Purple",
-    "G50-K0": "Black",
-    # ABS (B00)
-    "B00-D1": "Silver",
-    "B00-K0": "Black",
-    "B00-W0": "White",
-    "B00-G6": "Bambu Green",
-    "B00-G7": "Olive",
-    "B00-Y1": "Tangerine Yellow",
-    "B00-A0": "Orange",
-    "B00-R0": "Red",
-    "B00-B4": "Azure",
-    "B00-B0": "Blue",
-    "B00-B6": "Navy Blue",
-    # ABS-GF (B50)
-    "B50-A0": "Orange",
-    "B50-K0": "Black",
-    # ASA (B01)
-    "B01-W0": "White",
-    "B01-K0": "Black",
-    "B01-D0": "Gray",
-    # ASA Aero (B02)
-    "B02-W0": "White",
-    # PC (C00)
-    "C00-C1": "Transparent",
-    "C00-C0": "Clear Black",
-    "C00-K0": "Black",
-    "C00-W0": "White",
-    # PC FR (C01)
-    "C01-K0": "Black",
-    # TPU for AMS (U02)
-    "U02-B0": "Blue",
-    "U02-D0": "Gray",
-    "U02-K0": "Black",
-    # PAHT-CF (N04)
-    "N04-K0": "Black",
-    # PA6-GF (N08)
-    "N08-K0": "Black",
-    # Support for PLA/PETG (S02, S05)
-    "S02-W0": "Nature",
-    "S02-W1": "White",
-    "S05-C0": "Black",
-    # Support for ABS (S06)
-    "S06-W0": "White",
-    # Support for PA/PET (S03)
-    "S03-G1": "Green",
-    # PVA (S04)
-    "S04-Y0": "Clear",
-}
-
-# Fallback: color code suffix → name (for unknown material prefixes)
-BAMBU_COLOR_CODE_FALLBACK: dict[str, str] = {
-    "W0": "White",
-    "W1": "Jade White",
-    "W2": "Ivory White",
-    "W3": "Bone White",
-    "Y0": "Yellow",
-    "Y1": "Gold",
-    "Y2": "Sunflower Yellow",
-    "Y3": "Bronze",
-    "Y4": "Gold",
-    "A0": "Orange",
-    "A1": "Pumpkin Orange",
-    "A2": "Mandarin Orange",
-    "R0": "Red",
-    "R1": "Scarlet Red",
-    "R2": "Maroon Red",
-    "R3": "Hot Pink",
-    "R4": "Dark Red",
-    "R5": "Red Granite",
-    "P0": "Beige",
-    "P1": "Pink",
-    "P2": "Indigo Purple",
-    "P3": "Sakura Pink",
-    "P4": "Lilac Purple",
-    "P5": "Purple",
-    "P6": "Magenta",
-    "P7": "Violet Purple",
-    "B0": "Blue",
-    "B1": "Blue Grey",
-    "B2": "Cobalt Blue",
-    "B3": "Cobalt Blue",
-    "B4": "Ice Blue",
-    "B5": "Turquoise",
-    "B6": "Navy Blue",
-    "B7": "Royal Purple",
-    "B8": "Cyan",
-    "G0": "Green",
-    "G1": "Grass Green",
-    "G2": "Mistletoe Green",
-    "G3": "Bright Green",
-    "G6": "Bambu Green",
-    "G7": "Dark Green",
-    "N0": "Brown",
-    "N1": "Peanut Brown",
-    "N2": "Dark Brown",
-    "N3": "Caramel",
-    "D0": "Gray",
-    "D1": "Silver",
-    "D2": "Light Gray",
-    "D3": "Dark Gray",
-    "D4": "White Marble",
-    "D5": "Slate Gray",
-    "D6": "Lava Gray",
-    "K0": "Black",
-    "K1": "Charcoal",
-    "K2": "Onyx Black",
-    "C0": "Clear Black",
-    "C1": "Transparent",
-}
-
-
-def resolve_bambu_color_name(tray_id_name: str) -> str | None:
-    """Resolve a Bambu Lab tray_id_name code to a human-readable color name.
-
-    Tries exact match first, then falls back to color code suffix lookup.
-    Returns None if the code cannot be resolved.
-    """
-    if not tray_id_name:
-        return None
-
-    # Exact match
-    name = BAMBU_FILAMENT_COLORS.get(tray_id_name)
-    if name:
-        return name
-
-    # Fallback: use color code suffix (e.g. "D0" from "A06-D0")
-    parts = tray_id_name.split("-")
-    if len(parts) >= 2:
-        return BAMBU_COLOR_CODE_FALLBACK.get(parts[1])
-
-    return None

+ 7 - 4
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.2.2"
+APP_VERSION = "0.2.3"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
@@ -47,8 +47,11 @@ def _migrate_database() -> Path:
     return new_db if new_db.exists() or not old_db.exists() else old_db
 
 
-# Determine database path (handles migration)
-_db_path = _migrate_database()
+# External DATABASE_URL takes priority (PostgreSQL support)
+_external_db_url = os.environ.get("DATABASE_URL")
+
+# Determine database path (handles migration) — only used for SQLite
+_db_path = _migrate_database() if not _external_db_url else None
 
 
 class Settings(BaseSettings):
@@ -61,7 +64,7 @@ class Settings(BaseSettings):
     plate_calibration_dir: Path = _plate_cal_dir  # Plate detection references
     static_dir: Path = _app_dir / "static"  # Static files are part of app, not data
     log_dir: Path = _log_dir
-    database_url: str = f"sqlite+aiosqlite:///{_db_path}"
+    database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
 
     # Logging
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true

Fichier diff supprimé car celui-ci est trop grand
+ 838 - 1081
backend/app/core/database.py


+ 48 - 0
backend/app/core/db_dialect.py

@@ -0,0 +1,48 @@
+"""Database dialect helpers for SQLite/PostgreSQL dual support.
+
+Bambuddy defaults to SQLite (zero-config). When DATABASE_URL points to PostgreSQL,
+these helpers ensure dialect-specific operations use the correct SQL.
+"""
+
+from sqlalchemy import func, text
+
+
+def is_postgres() -> bool:
+    """Check if using PostgreSQL based on DATABASE_URL."""
+    from backend.app.core.config import settings
+
+    return settings.database_url.startswith("postgresql")
+
+
+def is_sqlite() -> bool:
+    """Check if using SQLite based on DATABASE_URL."""
+    from backend.app.core.config import settings
+
+    return settings.database_url.startswith("sqlite")
+
+
+async def upsert_setting(db, model, key: str, value: str):
+    """Dialect-aware INSERT ... ON CONFLICT UPDATE for the Settings table."""
+    if is_postgres():
+        from sqlalchemy.dialects.postgresql import insert as pg_insert
+
+        stmt = pg_insert(model).values(key=key, value=value)
+        stmt = stmt.on_conflict_do_update(
+            index_elements=["key"],
+            set_={"value": value, "updated_at": func.now()},
+        )
+    else:
+        from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+        stmt = sqlite_insert(model).values(key=key, value=value)
+        stmt = stmt.on_conflict_do_update(
+            index_elements=["key"],
+            set_={"value": value, "updated_at": func.now()},
+        )
+    await db.execute(stmt)
+
+
+async def run_pragma(conn, pragma_sql: str):
+    """Run a PRAGMA statement only on SQLite (no-op on PostgreSQL)."""
+    if is_sqlite():
+        await conn.execute(text(pragma_sql))

+ 88 - 0
backend/app/core/encryption.py

@@ -0,0 +1,88 @@
+"""At-rest encryption for high-value secrets (TOTP keys, OIDC client_secret).
+
+Set the ``MFA_ENCRYPTION_KEY`` environment variable to a URL-safe base64-encoded
+32-byte key (generate with ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"``)
+to enable Fernet symmetric encryption.
+
+When the key is not set, values are stored as plaintext and a warning is emitted.
+Existing plaintext values are read back correctly even after the key is added
+(values without the ``fernet:`` prefix are treated as legacy plaintext).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+logger = logging.getLogger(__name__)
+
+_FERNET_PREFIX = "fernet:"
+_fernet_instance = None
+_warn_shown = False
+
+
+def _get_fernet():
+    global _fernet_instance, _warn_shown
+
+    if _fernet_instance is not None:
+        return _fernet_instance
+
+    key = os.environ.get("MFA_ENCRYPTION_KEY")
+    if key:
+        from cryptography.fernet import Fernet
+
+        _fernet_instance = Fernet(key.encode() if isinstance(key, str) else key)
+        return _fernet_instance
+
+    if not _warn_shown:
+        logger.warning(
+            "MFA_ENCRYPTION_KEY is not set — TOTP secrets and OIDC client_secrets are "
+            "stored in plaintext. Generate a key with: "
+            'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
+        )
+        _warn_shown = True
+    return None
+
+
+def mfa_encrypt(plaintext: str) -> str:
+    """Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix,
+    or the original plaintext if ``MFA_ENCRYPTION_KEY`` is not configured."""
+    f = _get_fernet()
+    if f is None:
+        return plaintext
+    return _FERNET_PREFIX + f.encrypt(plaintext.encode()).decode()
+
+
+def mfa_decrypt(value: str) -> str:
+    """Decrypt a value previously encrypted with ``mfa_encrypt``.
+
+    Values without the ``fernet:`` prefix are returned as-is (legacy plaintext).
+    Raises ``RuntimeError`` if the prefix is present but no key is configured.
+    """
+    if not value.startswith(_FERNET_PREFIX):
+        # Nit6: Warn when a key IS configured but the stored value is plaintext.
+        # This surfaces rows that were written before encryption was enabled so
+        # operators know they need a migration / re-enroll cycle.
+        if _get_fernet() is not None:
+            logger.warning(
+                "mfa_decrypt: MFA_ENCRYPTION_KEY is set but the stored value has no "
+                "'fernet:' prefix — returning legacy plaintext. Consider re-enrolling "
+                "this secret to store it encrypted."
+            )
+        return value  # Legacy plaintext — backward compatible
+
+    f = _get_fernet()
+    if f is None:
+        raise RuntimeError(
+            "MFA_ENCRYPTION_KEY must be set to decrypt MFA secrets that were stored with encryption enabled."
+        )
+    from cryptography.fernet import InvalidToken
+
+    try:
+        return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()
+    except InvalidToken:
+        raise RuntimeError(
+            "MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. "
+            "Key rotation is not currently supported — restore the previous key "
+            "or have users re-enroll."
+        )

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

@@ -120,6 +120,7 @@ class Permission(StrEnum):
 
     # Stats/Metrics
     STATS_READ = "stats:read"
+    STATS_FILTER_BY_USER = "stats:filter_by_user"
 
     # System Info
     SYSTEM_READ = "system:read"
@@ -264,6 +265,7 @@ PERMISSION_CATEGORIES = {
     "Stats & History": [
         Permission.AMS_HISTORY_READ,
         Permission.STATS_READ,
+        Permission.STATS_FILTER_BY_USER,
     ],
     "System": [
         Permission.SYSTEM_READ,

Fichier diff supprimé car celui-ci est trop grand
+ 463 - 267
backend/app/main.py


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

@@ -2,6 +2,7 @@ from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
@@ -12,12 +13,15 @@ from backend.app.models.local_preset import LocalPreset
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
@@ -26,6 +30,8 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
 from backend.app.models.user_email_pref import UserEmailPreference
+from backend.app.models.user_otp_code import UserOTPCode
+from backend.app.models.user_totp import UserTOTP
 
 __all__ = [
     "Printer",
@@ -33,6 +39,7 @@ __all__ = [
     "Filament",
     "Settings",
     "SmartPlug",
+    "SmartPlugEnergySnapshot",
     "MaintenanceType",
     "PrinterMaintenance",
     "MaintenanceHistory",
@@ -44,6 +51,7 @@ __all__ = [
     "AMSSensorHistory",
     "AmsLabel",
     "PendingUpload",
+    "PrintBatch",
     "LibraryFolder",
     "LibraryFile",
     "User",
@@ -52,6 +60,8 @@ __all__ = [
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "LocalPreset",
+    "OIDCProvider",
+    "UserOIDCLink",
     "OrcaBaseProfile",
     "Spool",
     "SpoolKProfile",
@@ -61,4 +71,8 @@ __all__ = [
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "UserEmailPreference",
+    "UserOTPCode",
+    "UserTOTP",
+    "AuthEphemeralToken",
+    "AuthRateLimitEvent",
 ]

+ 2 - 2
backend/app/models/api_key.py

@@ -13,8 +13,8 @@ class APIKey(Base):
 
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))  # User-friendly name
-    key_hash: Mapped[str] = mapped_column(String(64))  # SHA256 hash of the key
-    key_prefix: Mapped[str] = mapped_column(String(8))  # First 8 chars for identification
+    key_hash: Mapped[str] = mapped_column(String(255))  # bcrypt hash of the key
+    key_prefix: Mapped[str] = mapped_column(String(20))  # First 8 chars + "..." for display
 
     # Permissions
     can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue

+ 10 - 1
backend/app/models/archive.py

@@ -28,7 +28,7 @@ class PrintArchive(Base):
     print_time_seconds: Mapped[int | None] = mapped_column(Integer)
     filament_used_grams: Mapped[float | None] = mapped_column(Float)
     filament_type: Mapped[str | None] = mapped_column(String(50))
-    filament_color: Mapped[str | None] = mapped_column(String(50))
+    filament_color: Mapped[str | None] = mapped_column(String(200))
     layer_height: Mapped[float | None] = mapped_column(Float)
     total_layers: Mapped[int | None] = mapped_column(Integer)
     nozzle_diameter: Mapped[float | None] = mapped_column(Float)
@@ -43,6 +43,12 @@ class PrintArchive(Base):
     started_at: Mapped[datetime | None] = mapped_column(DateTime)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime)
 
+    # Printer-assigned subtask identifier from MQTT. Used to resume the same
+    # archive row across a backend restart during a long-running print (#972):
+    # if the same subtask_id reappears after restart, we know it's the same
+    # print and keep the original row instead of cancel-then-create.
+    subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 
@@ -65,6 +71,9 @@ class PrintArchive(Base):
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
+    # Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.
+    # Persisted so per-print tracking survives backend restarts mid-print (#941).
+    energy_start_kwh: Mapped[float | None] = mapped_column(Float)
 
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 199 - 0
backend/app/models/auth_ephemeral.py

@@ -0,0 +1,199 @@
+"""Ephemeral authentication tokens and rate-limit events.
+
+These tables replace the module-level in-memory dicts in mfa.py, making
+the 2FA / OIDC flow compatible with multi-worker deployments and persistent
+across server restarts.
+
+Tables
+------
+AuthEphemeralToken
+    Short-lived, single-use tokens for:
+    - pre_auth   : issued after password check, consumed when 2FA is verified
+    - oidc_state : CSRF nonce for the OIDC authorization-code flow
+    - oidc_exchange : short bridge token from the OIDC callback to the SPA
+
+AuthRateLimitEvent
+    Timestamped events used for sliding-window rate limiting:
+    - 2fa_attempt  : each failed 2FA verification attempt
+    - email_send   : each OTP email sent (prevents email flooding)
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from enum import Enum
+
+from sqlalchemy import DateTime, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class TokenType(str, Enum):
+    """T3: Enumerated token types for AuthEphemeralToken.token_type.
+
+    Using str-based Enum keeps the stored values human-readable and
+    backward-compatible with existing rows.
+    """
+
+    PRE_AUTH = "pre_auth"
+    OIDC_STATE = "oidc_state"
+    OIDC_EXCHANGE = "oidc_exchange"
+    PASSWORD_RESET = "password_reset"
+    EMAIL_OTP_SETUP = "email_otp_setup"
+    SLICER_DOWNLOAD = "slicer_download"
+
+
+class EventType(str, Enum):
+    """T3: Enumerated event types for AuthRateLimitEvent.event_type.
+
+    Using str-based Enum keeps the stored values human-readable and
+    backward-compatible with existing rows.
+    """
+
+    TWO_FA_ATTEMPT = "2fa_attempt"
+    EMAIL_SEND = "email_send"
+    LOGIN_ATTEMPT = "login_attempt"
+    LOGIN_IP = "login_ip"
+    PASSWORD_RESET_SEND = "password_reset_send"
+    PASSWORD_RESET_IP = "password_reset_ip"
+
+
+class AuthEphemeralToken(Base):
+    """Single-use, time-limited token for pre-auth / OIDC flows."""
+
+    __tablename__ = "auth_ephemeral_tokens"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
+    token_type: Mapped[str] = mapped_column(String(20), nullable=False)  # 'pre_auth' | 'oidc_state' | 'oidc_exchange'
+
+    # pre_auth + oidc_exchange: which user this session belongs to
+    username: Mapped[str | None] = mapped_column(String(150), nullable=True)
+
+    # oidc_state: which provider initiated the flow
+    provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+    # oidc_state: replay-protection nonce embedded in the ID token
+    nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+    # oidc_state: PKCE code verifier (S256 method)
+    code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+    # pre_auth: HttpOnly cookie value bound to this token to prevent token theft
+    # (XSS can read JS memory but cannot read HttpOnly cookies).
+    challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        default=lambda: datetime.now(timezone.utc),
+    )
+
+    # ------------------------------------------------------------------
+    # T1: Classmethod factories — enforce required fields per token type
+    # and prevent accidentally leaving optional fields at their defaults.
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def new_pre_auth(
+        cls,
+        token: str,
+        username: str,
+        expires_at: datetime,
+        challenge_id: str | None = None,
+    ) -> AuthEphemeralToken:
+        """Create a pre-auth token (issued after password check, before 2FA)."""
+        return cls(
+            token=token,
+            token_type=TokenType.PRE_AUTH,
+            username=username,
+            expires_at=expires_at,
+            challenge_id=challenge_id,
+        )
+
+    @classmethod
+    def new_oidc_state(
+        cls,
+        token: str,
+        provider_id: int,
+        nonce: str,
+        code_verifier: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create an OIDC state token (CSRF protection + PKCE for authorize redirect)."""
+        return cls(
+            token=token,
+            token_type=TokenType.OIDC_STATE,
+            provider_id=provider_id,
+            nonce=nonce,
+            code_verifier=code_verifier,
+            expires_at=expires_at,
+        )
+
+    @classmethod
+    def new_oidc_exchange(
+        cls,
+        token: str,
+        username: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create an OIDC exchange token (bridge from callback to SPA)."""
+        return cls(
+            token=token,
+            token_type=TokenType.OIDC_EXCHANGE,
+            username=username,
+            expires_at=expires_at,
+        )
+
+    @classmethod
+    def new_password_reset(
+        cls,
+        token: str,
+        username: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create a password-reset token (single-use link emailed to the user)."""
+        return cls(
+            token=token,
+            token_type=TokenType.PASSWORD_RESET,
+            username=username,
+            expires_at=expires_at,
+        )
+
+    @classmethod
+    def new_email_otp_setup(
+        cls,
+        token: str,
+        username: str,
+        code_hash: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create an email-OTP setup token.
+
+        The ``code_hash`` is stored in the ``nonce`` column (field reuse
+        documented inline in the enable_email_otp endpoint).
+        """
+        return cls(
+            token=token,
+            token_type=TokenType.EMAIL_OTP_SETUP,
+            username=username,
+            nonce=code_hash,
+            expires_at=expires_at,
+        )
+
+
+class AuthRateLimitEvent(Base):
+    """Timestamped events used for sliding-window rate limiting."""
+
+    __tablename__ = "auth_rate_limit_events"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
+    event_type: Mapped[str] = mapped_column(String(20), nullable=False)  # '2fa_attempt' | 'email_send'
+    occurred_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        default=lambda: datetime.now(timezone.utc),
+    )

+ 2 - 0
backend/app/models/github_backup.py

@@ -27,6 +27,8 @@ class GitHubBackupConfig(Base):
     backup_kprofiles: Mapped[bool] = mapped_column(Boolean, default=True)
     backup_cloud_profiles: Mapped[bool] = mapped_column(Boolean, default=True)
     backup_settings: Mapped[bool] = mapped_column(Boolean, default=False)
+    backup_spools: Mapped[bool] = mapped_column(Boolean, default=False)
+    backup_archives: Mapped[bool] = mapped_column(Boolean, default=False)
 
     # Status tracking
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)

+ 93 - 0
backend/app/models/oidc_provider.py

@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+from backend.app.core.encryption import mfa_decrypt, mfa_encrypt
+
+
+class OIDCProvider(Base):
+    """OpenID Connect provider configuration.
+
+    Supports any standards-compliant OIDC provider such as PocketID,
+    Authentik, Keycloak, Authelia, Google, etc.
+
+    The issuer_url must point to the root issuer (e.g. ``https://id.example.com``).
+    The OIDC discovery document is fetched from
+    ``{issuer_url}/.well-known/openid-configuration`` at runtime.
+    """
+
+    __tablename__ = "oidc_providers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    # Human-readable name shown on the login button (e.g. "PocketID", "Google")
+    name: Mapped[str] = mapped_column(String(100), unique=True)
+    # Full OIDC issuer URL (e.g. "https://id.example.com")
+    issuer_url: Mapped[str] = mapped_column(String(500))
+    client_id: Mapped[str] = mapped_column(String(255))
+    # Encrypted at rest when MFA_ENCRYPTION_KEY is set.
+    # Use .client_secret / .client_secret setter rather than _client_secret_enc directly.
+    _client_secret_enc: Mapped[str] = mapped_column("client_secret", String(512))
+
+    @property
+    def client_secret(self) -> str:
+        return mfa_decrypt(self._client_secret_enc)
+
+    @client_secret.setter
+    def client_secret(self, value: str) -> None:
+        self._client_secret_enc = mfa_encrypt(value)
+
+    # Space-separated scopes; must include "openid"
+    scopes: Mapped[str] = mapped_column(String(500), default="openid email profile")
+    is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    # When True, a new local user is created automatically on first OIDC login
+    auto_create_users: Mapped[bool] = mapped_column(Boolean, default=False)
+    # When True, an existing local user whose email matches the OIDC claim is
+    # automatically linked on first SSO login.  Default is False (conservative):
+    # operators must explicitly opt-in to prevent an attacker-controlled IdP from
+    # silently hijacking local accounts via email matching (M-2 fix).
+    auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional icon URL (SVG/PNG) shown on the login button
+    icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to linked user accounts
+    user_links: Mapped[list[UserOIDCLink]] = relationship(
+        "UserOIDCLink",
+        back_populates="provider",
+        cascade="all, delete-orphan",
+    )
+
+    def __repr__(self) -> str:
+        return f"<OIDCProvider {self.name!r}>"
+
+
+class UserOIDCLink(Base):
+    """Links a local Bambuddy user account to an identity at an OIDC provider."""
+
+    __tablename__ = "user_oidc_links"
+    __table_args__ = (
+        # T2: Prevent duplicate OIDC identities and duplicate provider links.
+        # (provider_id, provider_user_id) — one OIDC sub per provider maps to at most one local user.
+        UniqueConstraint("provider_id", "provider_user_id", name="uq_oidc_link_provider_sub"),
+        # (user_id, provider_id) — one local user can link to each provider at most once.
+        UniqueConstraint("user_id", "provider_id", name="uq_oidc_link_user_provider"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("oidc_providers.id", ondelete="CASCADE"), index=True)
+    # The "sub" claim from the OIDC ID token — stable identifier for the user
+    provider_user_id: Mapped[str] = mapped_column(String(500))
+    # Email returned by the provider (informational; may differ from local email)
+    provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    provider: Mapped[OIDCProvider] = relationship("OIDCProvider", back_populates="user_links")
+
+    def __repr__(self) -> str:
+        return f"<UserOIDCLink user_id={self.user_id} provider_id={self.provider_id}>"

+ 45 - 0
backend/app/models/print_batch.py

@@ -0,0 +1,45 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PrintBatch(Base):
+    """Batch grouping for multiple queue items created from the same file."""
+
+    __tablename__ = "print_batches"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    # Source file (one of these)
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
+
+    # Total requested quantity (for display — actual items may differ if cancelled)
+    quantity: Mapped[int] = mapped_column(Integer, default=1)
+
+    # Status: active, completed, cancelled
+    status: Mapped[str] = mapped_column(String(20), default="active")
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    # User tracking
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    # Relationships
+    archive: Mapped["PrintArchive | None"] = relationship()
+    library_file: Mapped["LibraryFile | None"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
+    queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.library import LibraryFile  # noqa: E402
+from backend.app.models.print_queue import PrintQueueItem  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

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

@@ -33,6 +33,7 @@ class PrintQueueItem(Base):
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
     )
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
 
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
@@ -57,6 +58,13 @@ class PrintQueueItem(Base):
     # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
     plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
+    # Shortest-job-first scheduling
+    print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Cached from archive/library
+    been_jumped: Mapped[bool] = mapped_column(Boolean, default=False)  # Starvation guard for SJF
+
+    # Auto-print G-code injection (#422)
+    gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Print options
     bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
     flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -84,11 +92,13 @@ class PrintQueueItem(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
+    batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
+from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
 from backend.app.models.user import User  # noqa: E402

+ 3 - 0
backend/app/models/printer.py

@@ -36,6 +36,9 @@ class Printer(Base):
     plate_detection_roi_y: Mapped[float | None] = mapped_column(Float, nullable=True)  # Y start %
     plate_detection_roi_w: Mapped[float | None] = mapped_column(Float, nullable=True)  # Width %
     plate_detection_roi_h: Mapped[float | None] = mapped_column(Float, nullable=True)  # Height %
+    # Queue: True after a print finishes/fails, until user acknowledges the plate is cleared.
+    # Persisted so the gate survives crashes and power cycles (issue #961).
+    awaiting_plate_clear: Mapped[bool] = mapped_column(Boolean, default=False)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 

+ 25 - 3
backend/app/models/smart_plug.py

@@ -1,13 +1,13 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
 
 class SmartPlug(Base):
-    """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT)."""
+    """Smart plug for printer power control (Tasmota, Home Assistant, MQTT, or REST)."""
 
     __tablename__ = "smart_plugs"
 
@@ -15,7 +15,7 @@ class SmartPlug(Base):
     name: Mapped[str] = mapped_column(String(100))
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
 
-    # Plug type: "tasmota" (default), "homeassistant", or "mqtt"
+    # Plug type: "tasmota" (default), "homeassistant", "mqtt", or "rest"
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -50,6 +50,28 @@ class SmartPlug(Base):
     # Legacy multiplier - kept for backward compatibility
     mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
 
+    # REST/Webhook plug fields (required when plug_type="rest")
+    # Control URLs
+    rest_on_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Full URL to turn ON
+    rest_on_body: Mapped[str | None] = mapped_column(Text, nullable=True)  # Request body for ON
+    rest_off_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Full URL to turn OFF
+    rest_off_body: Mapped[str | None] = mapped_column(Text, nullable=True)  # Request body for OFF
+    rest_method: Mapped[str | None] = mapped_column(String(10), nullable=True)  # HTTP method: POST, PUT, GET
+    rest_headers: Mapped[str | None] = mapped_column(Text, nullable=True)  # JSON string of custom headers
+    # Status polling (optional)
+    rest_status_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # GET endpoint for state
+    rest_status_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path to state value
+    rest_status_on_value: Mapped[str | None] = mapped_column(String(50), nullable=True)  # What value means ON
+    # Energy monitoring (optional — can use separate URLs or extract from status response)
+    rest_power_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for power data
+    rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for power (watts)
+    rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")  # Unit conversion for power
+    rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for energy data
+    rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for energy (kWh)
+    rest_energy_multiplier: Mapped[float] = mapped_column(
+        Float, server_default="1.0"
+    )  # Unit conversion (e.g., 0.001 for Wh→kWh)
+
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 

+ 22 - 0
backend/app/models/smart_plug_energy_snapshot.py

@@ -0,0 +1,22 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SmartPlugEnergySnapshot(Base):
+    """Hourly snapshot of a smart plug's lifetime energy counter.
+
+    Powers date-range queries in "total consumption" energy mode. For a given
+    range we sum `(last_snapshot_in_range - last_snapshot_before_range)` per plug.
+    """
+
+    __tablename__ = "smart_plug_energy_snapshots"
+    __table_args__ = (Index("ix_plug_energy_snapshots_plug_time", "plug_id", "recorded_at"),)
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True)
+    plug_id: Mapped[int] = mapped_column(ForeignKey("smart_plugs.id", ondelete="CASCADE"), nullable=False)
+    recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    lifetime_kwh: Mapped[float] = mapped_column(Float, nullable=False)

+ 8 - 1
backend/app/models/user.py

@@ -26,17 +26,24 @@ class User(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
-    password_hash: Mapped[str] = mapped_column(String(255))
+    password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
     role: Mapped[str] = mapped_column(
         String(20), default="user"
     )  # "admin" or "user" (legacy, kept for backward compat)
+    auth_source: Mapped[str] = mapped_column(String(20), default="local")  # "local", "ldap", or "oidc"
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
+    # Set whenever the local password is changed/reset — used to invalidate JWTs
+    # issued before the change (M-R7-B).  NULL means no password change recorded yet.
+    password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
+    # "global" or "china"; NULL treated as "global" for legacy rows.
+    cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
 
     # Relationship to groups through association table
     groups: Mapped[list[Group]] = relationship(

+ 55 - 0
backend/app/models/user_otp_code.py

@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class UserOTPCode(Base):
+    """Temporary email OTP (One-Time Password) code for 2FA verification.
+
+    Each record represents a single sent OTP code.  Codes expire after
+    OTP_TTL_MINUTES and are invalidated after MAX_ATTEMPTS failed attempts
+    or after successful verification.
+    """
+
+    __tablename__ = "user_otp_codes"
+
+    OTP_TTL_MINUTES = 10
+    MAX_ATTEMPTS = 5
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    # pbkdf2_sha256 hash of the 6-digit code
+    code_hash: Mapped[str] = mapped_column(String(255))
+    # Number of failed verification attempts for this code
+    attempts: Mapped[int] = mapped_column(Integer, default=0)
+    # True once the code has been successfully used or explicitly invalidated
+    used: Mapped[bool] = mapped_column(Boolean, default=False)
+    expires_at: Mapped[datetime] = mapped_column(DateTime)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    def consume(self) -> None:
+        """T4: Mark this OTP as used, enforcing preconditions.
+
+        Raises ``ValueError`` if the code is already used or expired so callers
+        cannot silently re-use an invalidated code.  The caller is responsible
+        for flushing/committing the change to the DB.
+        """
+        now = datetime.now(timezone.utc)
+        exp = self.expires_at
+        if exp.tzinfo is None:
+            from datetime import timezone as _tz
+
+            exp = exp.replace(tzinfo=_tz.utc)
+        if self.used:
+            raise ValueError("OTP code has already been used")
+        if exp < now:
+            raise ValueError("OTP code has expired")
+        self.used = True
+
+    def __repr__(self) -> str:
+        return f"<UserOTPCode user_id={self.user_id} used={self.used}>"

+ 84 - 0
backend/app/models/user_totp.py

@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+from fastapi import HTTPException, status
+from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+from backend.app.core.encryption import mfa_decrypt, mfa_encrypt
+
+
+class UserTOTP(Base):
+    """TOTP (Time-based One-Time Password) secret for a user.
+
+    Stores the TOTP secret used by authenticator apps (Google Authenticator,
+    Proton Authenticator, Aegis, etc.). One record per user; is_enabled=False
+    while the setup is pending confirmation.
+    """
+
+    __tablename__ = "user_totp"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
+    # TOTP secret — encrypted at rest when MFA_ENCRYPTION_KEY is set.
+    # Use .secret / .set_secret() rather than accessing _secret_enc directly.
+    _secret_enc: Mapped[str] = mapped_column("secret", String(512))
+    is_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Hashed backup codes stored as JSON array of strings
+    # Each entry is a hashed one-time-use recovery code
+    backup_codes_json: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
+    # TOTP replay protection: stores the 30-second time-step counter of the last
+    # accepted code so the same code cannot be used twice within one window.
+    last_totp_counter: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    @property
+    def secret(self) -> str:
+        """Return the decrypted TOTP secret."""
+        return mfa_decrypt(self._secret_enc)
+
+    @secret.setter
+    def secret(self, value: str) -> None:
+        """Store the TOTP secret, encrypting it when MFA_ENCRYPTION_KEY is set."""
+        self._secret_enc = mfa_encrypt(value)
+
+    @property
+    def backup_code_hashes(self) -> list[str]:
+        """T5: Get stored backup-code hashes as a list.
+
+        The name makes clear that these are *hashes*, not plaintext codes,
+        so callers know they must verify with a password-hashing library
+        rather than compare directly.
+        """
+        if not self.backup_codes_json:
+            return []
+        return json.loads(self.backup_codes_json)
+
+    @backup_code_hashes.setter
+    def backup_code_hashes(self, hashes: list[str]) -> None:
+        """Persist backup-code hashes as a JSON array."""
+        self.backup_codes_json = json.dumps(hashes)
+
+    def accept_counter(self, new_counter: int) -> None:
+        """T4: Record an accepted TOTP time-step counter, rejecting backward movement.
+
+        Raises ``HTTPException(400)`` if ``new_counter`` is not strictly greater
+        than ``last_totp_counter``, preventing counter roll-back attacks (e.g. an
+        attacker who replays a previously accepted code after the counter wraps or
+        the clock is skewed backward).
+
+        The caller is responsible for flushing/committing the change to the DB.
+        """
+        if self.last_totp_counter is not None and new_counter <= self.last_totp_counter:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="TOTP code already used",
+            )
+        self.last_totp_counter = new_counter
+
+    def __repr__(self) -> str:
+        return f"<UserTOTP user_id={self.user_id} enabled={self.is_enabled}>"

+ 3 - 1
backend/app/models/virtual_printer.py

@@ -15,7 +15,9 @@ class VirtualPrinter(Base):
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     mode: Mapped[str] = mapped_column(String(20), default="immediate")  # immediate|review|print_queue|proxy
-    auto_dispatch: Mapped[bool] = mapped_column(Boolean, default=True)  # print_queue mode: auto-start or manual
+    auto_dispatch: Mapped[bool] = mapped_column(
+        Boolean, server_default="true"
+    )  # print_queue mode: auto-start or manual
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(

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

@@ -149,6 +149,10 @@ class ArchiveStats(BaseModel):
     # Energy stats
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
+    # Set when the date-range query in "total consumption" mode is running on
+    # incomplete snapshot history — e.g. right after a fresh upgrade before the
+    # hourly snapshot loop has built up a baseline. Frontend shows a tooltip.
+    energy_data_warming_up: bool = False
 
 
 class ProjectPageImage(BaseModel):

+ 345 - 16
backend/app/schemas/auth.py

@@ -1,4 +1,24 @@
-from pydantic import BaseModel
+import re
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+
+def _validate_password_complexity(v: str) -> str:
+    """Enforce minimum password complexity (M-C).
+
+    Requires at least one uppercase letter, one lowercase letter, one digit,
+    and one special character in addition to the min_length=8 Field constraint.
+    """
+    if not re.search(r"[A-Z]", v):
+        raise ValueError("Password must contain at least one uppercase letter")
+    if not re.search(r"[a-z]", v):
+        raise ValueError("Password must contain at least one lowercase letter")
+    if not re.search(r"\d", v):
+        raise ValueError("Password must contain at least one digit")
+    if not re.search(r"[^A-Za-z0-9]", v):
+        raise ValueError("Password must contain at least one special character")
+    return v
 
 
 class GroupBrief(BaseModel):
@@ -12,32 +32,50 @@ class GroupBrief(BaseModel):
 
 
 class LoginRequest(BaseModel):
-    username: str
-    password: str
+    username: str = Field(..., max_length=150)
+    password: str = Field(..., max_length=256)
 
 
 class LoginResponse(BaseModel):
-    access_token: str
+    access_token: str | None = None
     token_type: str = "bearer"
-    user: "UserResponse"
+    user: "UserResponse | None" = None
+    # Set when 2FA is required; the frontend must call /auth/2fa/verify
+    requires_2fa: bool = False
+    pre_auth_token: str | None = None
+    two_fa_methods: list[str] = []
 
 
 class UserCreate(BaseModel):
-    username: str
-    password: str | None = None  # Optional when advanced auth is enabled
-    email: str | None = None
+    username: str = Field(..., max_length=150)
+    password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2
+    email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max
     role: str = "user"
     group_ids: list[int] | None = None
 
+    @field_validator("password")
+    @classmethod
+    def validate_password(cls, v: str | None) -> str | None:
+        if v is not None:
+            _validate_password_complexity(v)
+        return v
+
 
 class UserUpdate(BaseModel):
-    username: str | None = None
-    password: str | None = None
-    email: str | None = None
+    username: str | None = Field(default=None, max_length=150)
+    password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2
+    email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max
     role: str | None = None
     is_active: bool | None = None
     group_ids: list[int] | None = None
 
+    @field_validator("password")
+    @classmethod
+    def validate_password(cls, v: str | None) -> str | None:
+        if v is not None:
+            _validate_password_complexity(v)
+        return v
+
 
 class UserResponse(BaseModel):
     id: int
@@ -46,6 +84,7 @@ class UserResponse(BaseModel):
     role: str  # Deprecated, kept for backward compatibility
     is_active: bool
     is_admin: bool  # Computed from role and group membership
+    auth_source: str = "local"  # "local" or "ldap"
     groups: list[GroupBrief] = []
     permissions: list[str] = []  # All permissions from groups
     created_at: str
@@ -55,14 +94,26 @@ class UserResponse(BaseModel):
 
 
 class ChangePasswordRequest(BaseModel):
-    current_password: str
-    new_password: str
+    current_password: str = Field(..., max_length=256)  # M-NEW-3: cap before pbkdf2
+    new_password: str = Field(..., min_length=8, max_length=256)
+
+    @field_validator("new_password")
+    @classmethod
+    def validate_new_password(cls, v: str) -> str:
+        return _validate_password_complexity(v)
 
 
 class SetupRequest(BaseModel):
     auth_enabled: bool
-    admin_username: str | None = None
-    admin_password: str | None = None
+    admin_username: str | None = Field(default=None, max_length=150)
+    admin_password: str | None = Field(default=None, max_length=256)
+
+    @field_validator("admin_password")
+    @classmethod
+    def validate_admin_password(cls, v: str | None) -> str | None:
+        if v is not None:
+            _validate_password_complexity(v)
+        return v
 
 
 class SetupResponse(BaseModel):
@@ -71,7 +122,17 @@ class SetupResponse(BaseModel):
 
 
 class ForgotPasswordRequest(BaseModel):
-    email: str
+    email: str = Field(..., max_length=254)  # L-NEW-1: RFC 5321 max; caps memory/CPU before lookup
+
+
+class ForgotPasswordConfirmRequest(BaseModel):
+    token: str = Field(..., max_length=128)
+    new_password: str = Field(..., min_length=8, max_length=256)
+
+    @field_validator("new_password")
+    @classmethod
+    def validate_new_password(cls, v: str) -> str:
+        return _validate_password_complexity(v)
 
 
 class ForgotPasswordResponse(BaseModel):
@@ -106,3 +167,271 @@ class TestSMTPRequest(BaseModel):
 class TestSMTPResponse(BaseModel):
     success: bool
     message: str
+
+
+# ---------------------------------------------------------------------------
+# 2FA / MFA schemas
+# ---------------------------------------------------------------------------
+
+
+class TwoFAStatusResponse(BaseModel):
+    totp_enabled: bool
+    email_otp_enabled: bool
+    backup_codes_remaining: int
+
+
+class TOTPSetupResponse(BaseModel):
+    """Returned when a user initiates TOTP setup.  The frontend should display
+    the QR code image (base64 PNG) and ask the user to scan it, then call
+    /auth/2fa/totp/enable with a valid code to confirm."""
+
+    secret: str  # base32 secret (shown as fallback text)
+    qr_code_b64: str  # base64-encoded PNG of the QR code
+    issuer: str
+
+
+class TOTPSetupRequest(BaseModel):
+    """Optional body for POST /auth/2fa/totp/setup.
+
+    Only required when re-initialising setup while an active TOTP record exists.
+    Provide the current TOTP code (from the existing authenticator app) to
+    confirm intent — mirrors the verification requirement in disable_totp.
+    """
+
+    code: str | None = Field(default=None, max_length=8)  # L-NEW-2: bound before pyotp
+
+
+class TOTPEnableRequest(BaseModel):
+    code: str  # 6-digit TOTP code from the authenticator app
+
+    @field_validator("code")
+    @classmethod
+    def validate_code(cls, v: str) -> str:
+        v = v.strip()
+        if not v.isdigit() or len(v) != 6:
+            raise ValueError("TOTP code must be exactly 6 digits")
+        return v
+
+
+class TOTPEnableResponse(BaseModel):
+    message: str
+    backup_codes: list[str]  # plain-text codes shown once; user must save them
+
+
+class TOTPDisableRequest(BaseModel):
+    """Requires a valid TOTP code OR a backup code to disable TOTP."""
+
+    code: str = Field(..., max_length=128)
+
+
+class BackupCodesResponse(BaseModel):
+    backup_codes: list[str]
+    message: str
+
+
+class EmailOTPEnableRequest(BaseModel):
+    """No body required — email is taken from the authenticated user's profile."""
+
+    pass
+
+
+class TwoFAVerifyRequest(BaseModel):
+    pre_auth_token: str = Field(..., max_length=128)
+    # TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.
+    # max_length=8 prevents excessively long inputs from reaching pbkdf2/pyotp.
+    code: str = Field(..., min_length=6, max_length=8)
+    method: Literal["totp", "email", "backup"] = "totp"
+
+    @field_validator("code")
+    @classmethod
+    def validate_code_format(cls, v: str) -> str:
+        v = v.strip()
+        if not re.match(r"^[A-Za-z0-9]{6,8}$", v):
+            raise ValueError("Code must be 6–8 alphanumeric characters")
+        return v.upper()  # normalise backup codes to uppercase
+
+
+class TwoFAVerifyResponse(BaseModel):
+    access_token: str
+    token_type: str = "bearer"
+    user: "UserResponse"
+
+
+class EmailOTPSendRequest(BaseModel):
+    pre_auth_token: str = Field(..., max_length=128)
+
+
+class EmailOTPEnableConfirmRequest(BaseModel):
+    """Body for the second step of email OTP enable: verify the proof-of-possession code."""
+
+    setup_token: str = Field(..., max_length=128)
+    # L-NEW-3: email OTP setup codes are always exactly 6 digits; reject anything else.
+    code: str = Field(..., min_length=6, max_length=6)
+
+    @field_validator("code")
+    @classmethod
+    def validate_code_digits(cls, v: str) -> str:
+        v = v.strip()
+        if not v.isdigit() or len(v) != 6:
+            raise ValueError("Email OTP setup code must be exactly 6 digits")
+        return v
+
+
+class EmailOTPDisableRequest(BaseModel):
+    """Requires the account password to disable email OTP."""
+
+    password: str = Field(..., max_length=256)
+
+
+class AdminDisable2FARequest(BaseModel):
+    """Admin must supply their own password as re-auth before disabling 2FA for another user.
+
+    OIDC/LDAP-only admins (no local password_hash) are exempt from this check.
+    """
+
+    admin_password: str | None = Field(default=None, max_length=256)
+
+
+# ---------------------------------------------------------------------------
+# OIDC schemas
+# ---------------------------------------------------------------------------
+
+
+def _validate_icon_url(v: str | None) -> str | None:
+    """Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
+    if v is None:
+        return v
+    if not v.startswith("https://"):
+        raise ValueError("icon_url must start with https://")
+    return v
+
+
+def _validate_issuer_url(v: str | None) -> str | None:
+    """Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
+
+    HTTP is no longer accepted — OIDC providers must be reachable over TLS.
+    Private-network and loopback addresses are rejected to prevent SSRF attacks
+    where an admin-supplied URL could reach internal services.
+    """
+    import ipaddress
+    from urllib.parse import urlparse
+
+    if v is None:
+        return v
+    if not v.startswith("https://"):
+        raise ValueError("issuer_url must start with https://")
+    host = urlparse(v).hostname or ""
+    try:
+        addr = ipaddress.ip_address(host)
+        if addr.is_private or addr.is_loopback or addr.is_link_local:
+            raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
+    except ValueError as exc:
+        if "issuer_url" in str(exc):
+            raise
+        # hostname is a domain name, not a bare IP — that's fine
+    return v
+
+
+def _validate_scopes(v: str | None) -> str | None:
+    """Nit5: Require that the 'openid' scope is present.
+
+    The OpenID Connect spec mandates the 'openid' scope; without it the
+    response is plain OAuth2, not OIDC, and claims like sub/email are not
+    guaranteed.
+    """
+    if v is None:
+        return v
+    scope_list = v.split()
+    if "openid" not in scope_list:
+        raise ValueError("scopes must include 'openid'")
+    return v
+
+
+class OIDCProviderCreate(BaseModel):
+    name: str = Field(..., max_length=100)  # L-NEW-4
+    issuer_url: str
+    client_id: str = Field(..., max_length=256)  # L-NEW-4
+    client_secret: str = Field(..., max_length=512)  # L-NEW-4: Fernet input bounded
+    scopes: str = Field(default="openid email profile", max_length=256)  # L-NEW-4
+    is_enabled: bool = True
+    auto_create_users: bool = False
+    auto_link_existing_accounts: bool = False  # M-2: conservative default, opt-in only
+    icon_url: str | None = None
+
+    @field_validator("issuer_url")
+    @classmethod
+    def validate_issuer_url(cls, v: str) -> str:
+        result = _validate_issuer_url(v)
+        assert result is not None
+        return result
+
+    @field_validator("scopes")
+    @classmethod
+    def validate_scopes(cls, v: str) -> str:
+        result = _validate_scopes(v)
+        assert result is not None
+        return result
+
+    @field_validator("icon_url")
+    @classmethod
+    def validate_icon_url(cls, v: str | None) -> str | None:
+        return _validate_icon_url(v)
+
+
+class OIDCProviderUpdate(BaseModel):
+    name: str | None = Field(default=None, max_length=100)
+    issuer_url: str | None = None
+
+    @field_validator("issuer_url")
+    @classmethod
+    def validate_issuer_url(cls, v: str | None) -> str | None:
+        return _validate_issuer_url(v)
+
+    client_id: str | None = Field(default=None, max_length=256)
+    client_secret: str | None = Field(default=None, max_length=512)
+    scopes: str | None = Field(default=None, max_length=256)
+    is_enabled: bool | None = None
+    auto_create_users: bool | None = None
+    auto_link_existing_accounts: bool | None = None
+    icon_url: str | None = None
+
+    @field_validator("scopes")
+    @classmethod
+    def validate_scopes(cls, v: str | None) -> str | None:
+        return _validate_scopes(v)
+
+    @field_validator("icon_url")
+    @classmethod
+    def validate_icon_url(cls, v: str | None) -> str | None:
+        return _validate_icon_url(v)
+
+
+class OIDCProviderResponse(BaseModel):
+    id: int
+    name: str
+    issuer_url: str
+    client_id: str
+    scopes: str
+    is_enabled: bool
+    auto_create_users: bool
+    auto_link_existing_accounts: bool = False
+    icon_url: str | None = None
+
+    class Config:
+        from_attributes = True
+
+
+class OIDCAuthorizeResponse(BaseModel):
+    auth_url: str
+
+
+class OIDCExchangeRequest(BaseModel):
+    oidc_token: str = Field(..., max_length=128)
+
+
+class OIDCLinkResponse(BaseModel):
+    id: int
+    provider_id: int
+    provider_name: str
+    provider_email: str | None = None
+    created_at: str

+ 8 - 1
backend/app/schemas/cloud.py

@@ -1,12 +1,16 @@
+from typing import Literal
+
 from pydantic import BaseModel, Field
 
+Region = Literal["global", "china"]
+
 
 class CloudLoginRequest(BaseModel):
     """Request to initiate cloud login."""
 
     email: str = Field(..., description="Bambu Lab account email")
     password: str = Field(..., description="Account password")
-    region: str = Field(default="global", description="Region: 'global' or 'china'")
+    region: Region = Field(default="global", description="Region: 'global' or 'china'")
 
 
 class CloudVerifyRequest(BaseModel):
@@ -15,6 +19,7 @@ class CloudVerifyRequest(BaseModel):
     email: str = Field(..., description="Bambu Lab account email")
     code: str = Field(..., description="6-digit verification code")
     tfa_key: str | None = Field(None, description="TFA key for TOTP verification (from login response)")
+    region: Region = Field(default="global", description="Region: 'global' or 'china'")
 
 
 class CloudLoginResponse(BaseModel):
@@ -32,12 +37,14 @@ class CloudAuthStatus(BaseModel):
 
     is_authenticated: bool
     email: str | None = None
+    region: Region | None = None
 
 
 class CloudTokenRequest(BaseModel):
     """Request to set access token directly."""
 
     access_token: str = Field(..., description="Bambu Lab access token")
+    region: Region = Field(default="global", description="Region: 'global' or 'china'")
 
 
 class SlicerSetting(BaseModel):

+ 6 - 0
backend/app/schemas/github_backup.py

@@ -29,6 +29,8 @@ class GitHubBackupConfigCreate(BaseModel):
     backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
     backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
     backup_settings: bool = Field(default=False, description="Backup app settings")
+    backup_spools: bool = Field(default=False, description="Backup spool inventory")
+    backup_archives: bool = Field(default=False, description="Backup print archive history")
 
     enabled: bool = Field(default=True, description="Enable backup feature")
 
@@ -60,6 +62,8 @@ class GitHubBackupConfigUpdate(BaseModel):
     backup_kprofiles: bool | None = None
     backup_cloud_profiles: bool | None = None
     backup_settings: bool | None = None
+    backup_spools: bool | None = None
+    backup_archives: bool | None = None
 
     enabled: bool | None = None
 
@@ -92,6 +96,8 @@ class GitHubBackupConfigResponse(BaseModel):
     backup_kprofiles: bool
     backup_cloud_profiles: bool
     backup_settings: bool
+    backup_spools: bool
+    backup_archives: bool
 
     enabled: bool
     last_backup_at: datetime | None

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

@@ -208,6 +208,8 @@ class FilePrintRequest(BaseModel):
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
+    # Project to associate the resulting archive with
+    project_id: int | None = None
 
 
 class FileUploadResponse(BaseModel):

+ 2 - 2
backend/app/schemas/notification_template.py

@@ -260,14 +260,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
     # User management notifications
     "user_created": {
         "username": "john_doe",
-        "password": "TempPass123!",
+        "password": "<generated-password>",
         "login_url": "https://bambuddy.example.com/login",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },
     "password_reset": {
         "username": "john_doe",
-        "password": "NewPass456!",
+        "password": "<new-password>",
         "login_url": "https://bambuddy.example.com/login",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

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

@@ -40,6 +40,12 @@ class PrintQueueItemCreate(BaseModel):
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
+    # Auto-print G-code injection
+    gcode_injection: bool = False
+    # Batch: create multiple copies (creates a batch if > 1)
+    quantity: int = 1
+    # Project to associate the resulting archive with
+    project_id: int | None = None
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -61,6 +67,8 @@ class PrintQueueItemUpdate(BaseModel):
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
+    # Auto-print G-code injection
+    gcode_injection: bool | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -111,6 +119,16 @@ class PrintQueueItemResponse(BaseModel):
     created_by_id: int | None = None
     created_by_username: str | None = None
 
+    # Batch grouping
+    batch_id: int | None = None
+    batch_name: str | None = None
+
+    # Shortest-job-first scheduling
+    been_jumped: bool = False
+
+    # Auto-print G-code injection
+    gcode_injection: bool = False
+
     class Config:
         from_attributes = True
 
@@ -141,6 +159,8 @@ class PrintQueueBulkUpdate(BaseModel):
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
+    # Auto-print G-code injection
+    gcode_injection: bool | None = None
 
 
 class PrintQueueBulkUpdateResponse(BaseModel):
@@ -149,3 +169,26 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     updated_count: int
     skipped_count: int  # Items that were not pending
     message: str
+
+
+class PrintBatchResponse(BaseModel):
+    """Response for a print batch with progress stats."""
+
+    id: int
+    name: str
+    archive_id: int | None = None
+    library_file_id: int | None = None
+    quantity: int
+    status: str
+    created_at: UTCDatetime
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+    # Derived counts
+    pending_count: int = 0
+    printing_count: int = 0
+    completed_count: int = 0
+    failed_count: int = 0
+    cancelled_count: int = 0
+
+    class Config:
+        from_attributes = True

+ 4 - 2
backend/app/schemas/printer.py

@@ -225,6 +225,7 @@ class PrinterStatus(BaseModel):
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected
+    door_open: bool = False  # Enclosure door open (X1/P1S/P2S/H2*)
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     nozzle_rack: list[NozzleRackSlot] = []  # H2C 6-nozzle tool-changer rack
     print_options: PrintOptionsResponse | None = None  # AI detection and print options
@@ -267,7 +268,8 @@ class PrinterStatus(BaseModel):
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
     developer_mode: bool | None = None
-    # Queue: user has acknowledged plate is cleared for next queued print
-    plate_cleared: bool = False
+    # Queue: printer is awaiting the user to acknowledge the build plate is cleared
+    # after a finished/failed print. Persisted across restarts (#961).
+    awaiting_plate_clear: bool = False
     # AMS drying support
     supports_drying: bool = False

+ 184 - 0
backend/app/schemas/settings.py

@@ -37,6 +37,10 @@ class AppSettings(BaseModel):
         default=False,
         description="Disable insufficient filament warnings when printing or queueing prints",
     )
+    prefer_lowest_filament: bool = Field(
+        default=False,
+        description="When multiple AMS spools match, prefer the one with lowest remaining filament",
+    )
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -80,6 +84,19 @@ class AppSettings(BaseModel):
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
     )
 
+    # Auto-print G-code injection (#422)
+    gcode_snippets: str = Field(
+        default="",
+        description="JSON: per-model G-code injection snippets {model: {start_gcode, end_gcode}}",
+    )
+
+    # Scheduled local backup (#884)
+    local_backup_enabled: bool = Field(default=False, description="Enable scheduled local backups")
+    local_backup_schedule: str = Field(default="daily", description="Backup frequency: hourly, daily, weekly")
+    local_backup_time: str = Field(default="03:00", description="Time of day for daily/weekly backups (HH:MM, 24h)")
+    local_backup_retention: int = Field(default=5, description="Number of backup files to keep (1-100)")
+    local_backup_path: str = Field(default="", description="Backup output directory (empty = DATA_DIR/backups)")
+
     # Print modal settings
     per_printer_mapping_expanded: bool = Field(
         default=False, description="Expand custom filament mapping by default in print modal"
@@ -186,6 +203,84 @@ class AppSettings(BaseModel):
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
     )
 
+    # Default print options
+    default_bed_levelling: bool = Field(default=True, description="Default bed levelling option for new prints")
+    default_flow_cali: bool = Field(default=False, description="Default flow calibration option for new prints")
+    default_vibration_cali: bool = Field(
+        default=True, description="Default vibration calibration option for new prints"
+    )
+    default_layer_inspect: bool = Field(
+        default=False, description="Default first layer inspection option for new prints"
+    )
+    default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
+
+    # Staggered batch start for multi-printer jobs
+    stagger_group_size: int = Field(
+        default=2, ge=1, le=50, description="Number of printers to start simultaneously in staggered mode"
+    )
+    stagger_interval_minutes: int = Field(
+        default=5, ge=1, le=60, description="Minutes between staggered printer groups"
+    )
+
+    # Plate-clear confirmation for queue scheduling
+    require_plate_clear: bool = Field(
+        default=False,
+        description="Require per-printer plate-clear confirmation before starting queued prints on finished printers",
+    )
+    queue_shortest_first: bool = Field(
+        default=False,
+        description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
+    )
+
+    # LDAP authentication (#794)
+    ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
+    ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
+    ldap_bind_dn: str = Field(default="", description="Bind DN for LDAP searches (e.g., cn=admin,dc=example,dc=com)")
+    ldap_bind_password: str = Field(default="", description="Bind password for LDAP searches")
+    ldap_search_base: str = Field(default="", description="Search base DN (e.g., ou=users,dc=example,dc=com)")
+    ldap_user_filter: str = Field(
+        default="(sAMAccountName={username})",
+        description="LDAP user search filter. {username} is replaced with the login username",
+    )
+    ldap_security: str = Field(default="starttls", description="LDAP security: 'starttls' or 'ldaps'")
+    ldap_group_mapping: str = Field(
+        default="",
+        description="JSON: LDAP group to BamBuddy group mapping {ldap_group_dn: bambuddy_group_name}",
+    )
+    ldap_auto_provision: bool = Field(
+        default=False,
+        description="Auto-create BamBuddy user on first successful LDAP login",
+    )
+    ldap_default_group: str = Field(
+        default="",
+        description="Fallback BamBuddy group name assigned when an LDAP user authenticates but has no mapped groups. Empty = no fallback.",
+    )
+
+    # Obico AI failure detection (#172)
+    obico_enabled: bool = Field(default=False, description="Enable Obico AI print failure detection")
+    obico_ml_url: str = Field(
+        default="",
+        description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
+    )
+    obico_sensitivity: str = Field(
+        default="medium",
+        description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
+    )
+    obico_action: str = Field(
+        default="notify",
+        description="Action on detected failure: 'notify', 'pause', or 'pause_and_off'",
+    )
+    obico_poll_interval: int = Field(
+        default=10,
+        ge=5,
+        le=120,
+        description="Seconds between detection checks while a print is running",
+    )
+    obico_enabled_printers: str = Field(
+        default="",
+        description="JSON array of printer IDs to monitor (empty = all connected printers)",
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -209,6 +304,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_disable_weight_sync: bool | None = None
     spoolman_report_partial_usage: bool | None = None
     disable_filament_warnings: bool | None = None
+    prefer_lowest_filament: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
@@ -260,8 +356,96 @@ class AppSettingsUpdate(BaseModel):
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     user_notifications_enabled: bool | None = None
+    default_bed_levelling: bool | None = None
+    default_flow_cali: bool | None = None
+    default_vibration_cali: bool | None = None
+    default_layer_inspect: bool | None = None
+    default_timelapse: bool | None = None
+    stagger_group_size: int | None = Field(default=None, ge=1, le=50)
+    stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
+    require_plate_clear: bool | None = None
+    queue_shortest_first: bool | None = None
+    gcode_snippets: str | None = None
+    local_backup_enabled: bool | None = None
+    local_backup_schedule: str | None = None
+    local_backup_time: str | None = None
+    local_backup_retention: int | None = None
+    local_backup_path: str | None = None
+    ldap_enabled: bool | None = None
+    ldap_server_url: str | None = None
+    ldap_bind_dn: str | None = None
+    ldap_bind_password: str | None = None
+    ldap_search_base: str | None = None
+    ldap_user_filter: str | None = None
+    ldap_security: str | None = None
+    ldap_group_mapping: str | None = None
+    ldap_auto_provision: bool | None = None
+    ldap_default_group: str | None = None
+    obico_enabled: bool | None = None
+    obico_ml_url: str | None = None
+    obico_sensitivity: str | None = None
+    obico_action: str | None = None
+    obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
+    obico_enabled_printers: str | None = None
     default_sidebar_order: str | None = None
 
+    @field_validator("gcode_snippets")
+    @classmethod
+    def validate_gcode_snippets(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("gcode_snippets must be valid JSON or empty")
+        if not isinstance(parsed, dict):
+            raise ValueError("gcode_snippets must be a JSON object keyed by printer model")
+        return v
+
+    @field_validator("ldap_group_mapping")
+    @classmethod
+    def validate_ldap_group_mapping(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("ldap_group_mapping must be valid JSON or empty")
+        if not isinstance(parsed, dict):
+            raise ValueError("ldap_group_mapping must be a JSON object mapping LDAP group DNs to BamBuddy group names")
+        return v
+
+    @field_validator("obico_enabled_printers")
+    @classmethod
+    def validate_obico_enabled_printers(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("obico_enabled_printers must be valid JSON or empty")
+        if not isinstance(parsed, list) or not all(isinstance(item, int) for item in parsed):
+            raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
+        return v
+
+    @field_validator("obico_sensitivity")
+    @classmethod
+    def validate_obico_sensitivity(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        if v not in ("low", "medium", "high"):
+            raise ValueError("obico_sensitivity must be 'low', 'medium', or 'high'")
+        return v
+
+    @field_validator("obico_action")
+    @classmethod
+    def validate_obico_action(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        if v not in ("notify", "pause", "pause_and_off"):
+            raise ValueError("obico_action must be 'notify', 'pause', or 'pause_and_off'")
+        return v
+
     @field_validator("default_sidebar_order")
     @classmethod
     def validate_default_sidebar_order(cls, v: str | None) -> str | None:

+ 53 - 2
backend/app/schemas/smart_plug.py

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
 
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
-    plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
+    plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] = "tasmota"
 
     # Tasmota fields (required when plug_type="tasmota")
     ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
@@ -44,6 +44,23 @@ class SmartPlugBase(BaseModel):
     # Legacy multiplier - kept for backward compatibility
     mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier
 
+    # REST/Webhook fields (required when plug_type="rest")
+    rest_on_url: str | None = Field(default=None, max_length=500)
+    rest_on_body: str | None = None
+    rest_off_url: str | None = Field(default=None, max_length=500)
+    rest_off_body: str | None = None
+    rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
+    rest_headers: str | None = None  # JSON string of custom headers
+    rest_status_url: str | None = Field(default=None, max_length=500)
+    rest_status_path: str | None = Field(default=None, max_length=200)
+    rest_status_on_value: str | None = Field(default=None, max_length=50)
+    rest_power_url: str | None = Field(default=None, max_length=500)
+    rest_power_path: str | None = Field(default=None, max_length=200)
+    rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    rest_energy_url: str | None = Field(default=None, max_length=500)
+    rest_energy_path: str | None = Field(default=None, max_length=200)
+    rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+
     printer_id: int | None = None
     enabled: bool = True
     auto_on: bool = True
@@ -81,6 +98,9 @@ class SmartPlugBase(BaseModel):
             # At least one data source must be configured (path is optional)
             if not has_power and not has_energy and not has_state:
                 raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
+        if self.plug_type == "rest":
+            if not self.rest_on_url and not self.rest_off_url:
+                raise ValueError("At least one of ON URL or OFF URL is required for REST plugs")
         return self
 
 
@@ -90,7 +110,7 @@ class SmartPlugCreate(SmartPlugBase):
 
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
-    plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
+    plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None
     ip_address: str | None = None
     ha_entity_id: str | None = None
     # Home Assistant energy sensor entities (optional)
@@ -112,6 +132,22 @@ class SmartPlugUpdate(BaseModel):
     mqtt_state_topic: str | None = None
     mqtt_state_path: str | None = None
     mqtt_state_on_value: str | None = None
+    # REST fields
+    rest_on_url: str | None = None
+    rest_on_body: str | None = None
+    rest_off_url: str | None = None
+    rest_off_body: str | None = None
+    rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
+    rest_headers: str | None = None
+    rest_status_url: str | None = None
+    rest_status_path: str | None = None
+    rest_status_on_value: str | None = None
+    rest_power_url: str | None = None
+    rest_power_path: str | None = None
+    rest_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_url: str | None = None
+    rest_energy_path: str | None = None
+    rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
@@ -211,3 +247,18 @@ class HASensorEntity(BaseModel):
     friendly_name: str
     state: str | None = None
     unit_of_measurement: str | None = None  # "W", "kW", "kWh", "Wh"
+
+
+class RESTTestConnectionRequest(BaseModel):
+    """Request to test a REST smart plug connection."""
+
+    url: str = Field(..., min_length=1)
+    method: str = Field(default="GET")
+    headers: str | None = None  # JSON string of custom headers
+
+
+class RESTTestConnectionResponse(BaseModel):
+    """Response from REST connection test."""
+
+    success: bool
+    error: str | None = None

+ 4 - 0
backend/app/schemas/spoolbuddy.py

@@ -152,6 +152,10 @@ class SystemConfigRequest(BaseModel):
     api_key: str | None = Field(default=None, max_length=255)
 
 
+class SystemCommandRequest(BaseModel):
+    command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser")
+
+
 class SystemCommandResultRequest(BaseModel):
     command: str
     success: bool

+ 24 - 7
backend/app/services/archive.py

@@ -8,7 +8,7 @@ from datetime import date, datetime, time, timezone
 from pathlib import Path
 
 from defusedxml import ElementTree as ET
-from sqlalchemy import and_, or_, select
+from sqlalchemy import and_, or_, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
@@ -811,13 +811,21 @@ class ArchiveService:
                     # Fallback for archives without hash data: match by print name only.
                     name_conditions.append(PrintArchive.print_name.ilike(print_name))
             if makerworld_model_id:
-                # Match by MakerWorld model ID stored in extra_data (same design from MakerWorld)
-                # Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
-                from sqlalchemy import func
+                # Match by MakerWorld model ID stored in extra_data
+                from backend.app.core.db_dialect import is_sqlite
 
-                name_conditions.append(
-                    func.json_extract(PrintArchive.extra_data, "$.makerworld_model_id") == str(makerworld_model_id)
-                )
+                if is_sqlite():
+                    from sqlalchemy import func
+
+                    name_conditions.append(
+                        func.json_extract(PrintArchive.extra_data, "$.makerworld_model_id") == str(makerworld_model_id)
+                    )
+                else:
+                    name_conditions.append(
+                        text("(extra_data::jsonb->>'makerworld_model_id') = :mw_id").bindparams(
+                            mw_id=str(makerworld_model_id)
+                        )
+                    )
 
             if name_conditions:
                 conditions.append(or_(*name_conditions))
@@ -846,6 +854,8 @@ class ArchiveService:
         print_data: dict | None = None,
         created_by_id: int | None = None,
         original_filename: str | None = None,
+        project_id: int | None = None,
+        subtask_id: str | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -856,6 +866,11 @@ class ArchiveService:
             created_by_id: User ID who created this archive (optional, for user tracking)
             original_filename: Original human-readable filename (optional, for library files
                 stored with UUID names)
+            project_id: Project to associate this archive with (optional, set when triggered
+                from the project view)
+            subtask_id: MQTT-provided task identifier (optional). Used to match an
+                existing archive across a backend restart mid-print so the
+                original row can be resumed instead of cancelled (#972).
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -966,6 +981,8 @@ class ArchiveService:
             quantity=quantity,
             extra_data=metadata,
             created_by_id=created_by_id,
+            project_id=project_id,
+            subtask_id=subtask_id,
         )
 
         self.db.add(archive)

+ 14 - 0
backend/app/services/background_dispatch.py

@@ -54,6 +54,7 @@ class PrintDispatchJob:
     options: dict[str, Any] = field(default_factory=dict)
     requested_by_user_id: int | None = None
     requested_by_username: str | None = None
+    project_id: int | None = None
 
 
 @dataclass(slots=True)
@@ -160,6 +161,7 @@ class BackgroundDispatchService:
         options: dict[str, Any],
         requested_by_user_id: int | None,
         requested_by_username: str | None,
+        project_id: int | None = None,
     ) -> dict[str, Any]:
         return await self._dispatch(
             kind="print_library_file",
@@ -170,6 +172,7 @@ class BackgroundDispatchService:
             options=options,
             requested_by_user_id=requested_by_user_id,
             requested_by_username=requested_by_username,
+            project_id=project_id,
         )
 
     async def cancel_job(self, job_id: int) -> dict[str, Any]:
@@ -257,6 +260,7 @@ class BackgroundDispatchService:
         options: dict[str, Any],
         requested_by_user_id: int | None,
         requested_by_username: str | None,
+        project_id: int | None = None,
     ) -> dict[str, Any]:
         async with self._lock:
             has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
@@ -279,6 +283,7 @@ class BackgroundDispatchService:
                 options=options,
                 requested_by_user_id=requested_by_user_id,
                 requested_by_username=requested_by_username,
+                project_id=project_id,
             )
             self._next_job_id += 1
             self._batch_total += 1
@@ -722,6 +727,7 @@ class BackgroundDispatchService:
                 printer_id=job.printer_id,
                 source_file=file_path,
                 original_filename=lib_file.filename,
+                project_id=job.project_id,
             )
             if not archive:
                 raise RuntimeError("Failed to create archive")
@@ -884,6 +890,14 @@ class BackgroundDispatchService:
             timeout,
             pre_state,
         )
+        # Strong signal the MQTT session is half-broken (#887, #936): telemetry
+        # still arrives but our publishes don't reach the printer. Force a fresh
+        # session so the next dispatch can land without a power cycle.
+        client = printer_manager.get_client(printer_id)
+        if client:
+            client.force_reconnect_stale_session(
+                f"print command unacknowledged after {timeout:.0f}s (state still {pre_state})"
+            )
 
     @staticmethod
     async def _cleanup_sd_card_file(

+ 41 - 16
backend/app/services/bambu_cloud.py

@@ -27,15 +27,41 @@ class BambuCloudAuthError(BambuCloudError):
     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
+    ``BambuCloudService`` instances can reuse its connection pool.
+
+    Pass ``None`` during shutdown to unregister. The service only holds a
+    reference (never closes a client it does not own), so region + token
+    state still stays per-request — this only shares the transport pool.
+    """
+    global _shared_http_client
+    _shared_http_client = client
+
+
 class BambuCloudService:
     """Service for interacting with Bambu Lab Cloud API."""
 
-    def __init__(self, region: str = "global"):
+    def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
         self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
         self.access_token: str | None = None
         self.refresh_token: str | None = None
         self.token_expiry: datetime | None = None
-        self._client = httpx.AsyncClient(timeout=30.0)
+        # Prefer an explicitly-injected client (tests), else fall back to the
+        # app-scoped shared client (production), and finally create our own so
+        # scripts / tests that skip the lifespan still get a working service.
+        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:
@@ -511,17 +537,16 @@ class BambuCloudService:
             raise BambuCloudError(f"Request failed: {e}")
 
     async def close(self):
-        """Close the HTTP client."""
-        await self._client.aclose()
-
-
-# Singleton instance
-_cloud_service: BambuCloudService | None = None
-
-
-def get_cloud_service() -> BambuCloudService:
-    """Get the singleton cloud service instance."""
-    global _cloud_service
-    if _cloud_service is None:
-        _cloud_service = BambuCloudService()
-    return _cloud_service
+        """Close the HTTP client we own. No-op when sharing an app-scoped client."""
+        if self._owns_client:
+            await self._client.aclose()
+
+
+# Previously this module exposed a process-wide ``_cloud_service`` singleton
+# via ``get_cloud_service()`` / ``reset_cloud_service()``. That pattern leaked
+# region and token state across users (a China-region login would pin the
+# singleton to api.bambulab.cn until the next explicit reset), so the singleton
+# has been removed. Callers should construct a per-request
+# ``BambuCloudService(region=...)`` from the stored region and ``await
+# cloud.close()`` it when done. See ``routes.cloud.build_authenticated_cloud``
+# for the standard pattern.

+ 148 - 26
backend/app/services/bambu_ftp.py

@@ -16,6 +16,17 @@ logger = logging.getLogger(__name__)
 T = TypeVar("T")
 
 
+class FileNotOnPrinterError(Exception):
+    """Raised when a remote FTP path returns 550 (file not found).
+
+    550 means the file does not exist at that path — retrying the same path
+    will never succeed. Callers use this sentinel with with_ftp_retry's
+    non_retry_exceptions to immediately move on to the next candidate path
+    instead of burning the full retry budget (up to 11 × 30s per path) on
+    a lookup that cannot recover.
+    """
+
+
 class ImplicitFTP_TLS(FTP_TLS):
     """FTP_TLS subclass for implicit FTPS (port 990) with model-specific SSL handling.
 
@@ -280,14 +291,21 @@ class BambuFTPClient:
             logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
             return True
         except (OSError, ftplib.Error) as e:
-            # Log at INFO level so we can see failures in normal logs
-            logger.info("FTP download failed for %s: %s", remote_path, e)
             # Clean up partial file if it exists
             if local_path.exists():
                 try:
                     local_path.unlink()
                 except OSError:
                     pass  # Best-effort partial file cleanup; not critical if removal fails
+            # 550 means the file is not at this path. Surface as a sentinel so
+            # with_ftp_retry can abandon this path immediately and the caller
+            # can advance to the next candidate instead of retrying 11× at
+            # 30s intervals (the pattern that cost #972's reporter ~48min).
+            if isinstance(e, ftplib.error_perm) and str(e).startswith("550"):
+                logger.info("FTP download failed for %s: %s (not on printer)", remote_path, e)
+                raise FileNotOnPrinterError(f"{remote_path}: {e}") from e
+            # Log at INFO level so we can see failures in normal logs
+            logger.info("FTP download failed for %s: %s", remote_path, e)
             return False
 
     def diagnose_storage(self) -> dict:
@@ -597,6 +615,79 @@ class BambuFTPClient:
         return result if result else None
 
 
+# Shared 3MF download cache (#972).
+#
+# Both the cover thumbnail endpoint (api/routes/printers.py) and the archive
+# metadata flow (main.py) fetch the same 3MF file over FTP during a print.
+# On slow / contended links (A1 Wi-Fi, large files) the duplicate transfers
+# compete for the printer's single FTP socket and trigger 425 "can't open
+# data channel" errors, feeding back into cause-2's retry storm.
+#
+# This cache stores the local path of a successfully-downloaded 3MF keyed
+# by (printer_id, normalized_name). Whichever flow downloads first populates
+# the cache; the other flow reuses the file read-only. Evicted on print
+# completion so a later print with the same name re-downloads fresh bytes.
+_threemf_path_cache: dict[tuple[int, str], Path] = {}
+
+
+def normalize_3mf_name(name: str) -> str:
+    """Collapse various 3MF filename variants to a cache key.
+
+    Bambu tooling produces names as bare subtask ("Part"), with .3mf, with
+    .gcode.3mf, or (Studio-normalized) with spaces → underscores. All of
+    these refer to the same print job on the same printer, so they must
+    hash to the same cache key.
+    """
+    # Lowercase first so .3MF / .GCODE.3MF variants strip cleanly — a
+    # real-world case since Windows-side tooling sometimes uppercases
+    # extensions.
+    cleaned = name.strip().lower().replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
+    return cleaned.replace(" ", "_")
+
+
+def cache_3mf_download(printer_id: int, name: str, local_path: Path) -> None:
+    """Record a successfully-downloaded 3MF so a sibling flow can reuse it."""
+    _threemf_path_cache[(printer_id, normalize_3mf_name(name))] = local_path
+
+
+def get_cached_3mf(printer_id: int, name: str) -> Path | None:
+    """Return a cached 3MF path for this printer/name if the file still exists."""
+    key = (printer_id, normalize_3mf_name(name))
+    cached = _threemf_path_cache.get(key)
+    if cached and cached.exists() and cached.stat().st_size > 0:
+        return cached
+    # Evict dead entry — the file was cleaned up (temp dir clean, manual
+    # deletion, restart) so the cache value is no longer usable.
+    if cached:
+        _threemf_path_cache.pop(key, None)
+    return None
+
+
+def clear_3mf_cache(printer_id: int | None = None, delete_files: bool = True) -> None:
+    """Drop cache entries for one printer (or all with None).
+
+    When ``delete_files`` is True (default) the on-disk 3MF is removed as well
+    — called from on_print_complete so temp files don't accumulate across
+    prints. Tests that want to inspect the cache contents disable this.
+    """
+
+    def _maybe_unlink(path: Path) -> None:
+        if delete_files and path.exists():
+            try:
+                path.unlink()
+            except OSError as exc:
+                logger.debug("3MF cache cleanup skipped %s: %s", path, exc)
+
+    if printer_id is None:
+        for path in list(_threemf_path_cache.values()):
+            _maybe_unlink(path)
+        _threemf_path_cache.clear()
+        return
+    for key in [k for k in _threemf_path_cache if k[0] == printer_id]:
+        _maybe_unlink(_threemf_path_cache[key])
+        _threemf_path_cache.pop(key, None)
+
+
 async def download_file_async(
     ip_address: str,
     access_code: str,
@@ -623,7 +714,15 @@ async def download_file_async(
     loop = asyncio.get_event_loop()
     is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
 
-    def _download(force_prot_c: bool = False) -> bool:
+    # Per-attempt completion state: asyncio.wait_for cannot cancel
+    # run_in_executor threads, so on timeout the executor may still complete
+    # the download after we stop waiting. The thread flips `success` to True
+    # ONLY after the file is fully written — a post-timeout check lets us
+    # salvage the download without mistaking an in-progress partial write
+    # for a completed one. Each attempt gets its own dict so a zombie from
+    # an earlier attempt can't flip the flag for a later one.
+
+    def _download(force_prot_c: bool, completion: dict) -> bool:
         mode_str = "prot_c" if force_prot_c else "prot_p"
         client = BambuFTPClient(
             ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
@@ -632,39 +731,53 @@ async def download_file_async(
             try:
                 result = client.download_to_file(remote_path, local_path)
                 if result:
-                    # Cache the working mode
                     BambuFTPClient.cache_mode(ip_address, mode_str)
+                    completion["success"] = True
                 return result
             finally:
                 client.disconnect()
         return False
 
-    try:
-        # Check if we have a cached mode for this printer
-        cached_mode = BambuFTPClient._mode_cache.get(ip_address)
+    async def _run(force_prot_c: bool) -> bool:
+        completion = {"success": False}
+        try:
+            return await asyncio.wait_for(
+                loop.run_in_executor(None, lambda: _download(force_prot_c, completion)), timeout=timeout
+            )
+        except TimeoutError:
+            # Give the zombie executor thread a brief moment to finish if it
+            # was already close to done. Only salvage when the thread has
+            # signalled genuine success — checking file size alone would
+            # mistake an in-progress partial write for a completed download.
+            await asyncio.sleep(0.5)
+            if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
+                logger.info(
+                    "FTP download wait_for timed out after %ss for %s, but thread completed (%s bytes) — salvaging",
+                    timeout,
+                    remote_path,
+                    local_path.stat().st_size,
+                )
+                return True
+            logger.warning("FTP download timed out after %ss for %s", timeout, remote_path)
+            return False
 
-        if cached_mode:
-            # Use cached mode
-            force_prot_c = cached_mode == "prot_c"
-            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(force_prot_c)), timeout=timeout)
+    # Check if we have a cached mode for this printer
+    cached_mode = BambuFTPClient._mode_cache.get(ip_address)
 
-        # No cached mode - try prot_p first
-        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(False)), timeout=timeout)
+    if cached_mode:
+        force_prot_c = cached_mode == "prot_c"
+        return await _run(force_prot_c)
 
-        if result:
-            return True
-
-        # Download failed - for A1 models, try prot_c fallback
-        if is_a1:
-            logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
-            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(True)), timeout=timeout)
-            return result
+    # No cached mode - try prot_p first
+    if await _run(False):
+        return True
 
-        return False
+    # Download failed - for A1 models, try prot_c fallback
+    if is_a1:
+        logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
+        return await _run(True)
 
-    except TimeoutError:
-        logger.warning("FTP download timed out after %ss for %s", timeout, remote_path)
-        return False
+    return False
 
 
 async def download_file_try_paths_async(
@@ -689,7 +802,16 @@ async def download_file_try_paths_async(
             return False
 
         try:
-            return any(client.download_to_file(remote_path, local_path) for remote_path in remote_paths)
+            # FileNotOnPrinterError signals "try the next path", not "give up" —
+            # this function's whole purpose is to walk a list of candidates
+            # over one connection. Only a real transport error should bubble.
+            for remote_path in remote_paths:
+                try:
+                    if client.download_to_file(remote_path, local_path):
+                        return True
+                except FileNotOnPrinterError:
+                    continue
+            return False
         finally:
             client.disconnect()
 

+ 309 - 32
backend/app/services/bambu_mqtt.py

@@ -122,6 +122,7 @@ class PrinterState:
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
+    door_open: bool = False  # Enclosure door open (home_flag bit 23, X1/P1S/P2S/H2*)
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
@@ -283,6 +284,7 @@ class BambuMQTTClient:
         on_print_complete: Callable[[dict], None] | None = None,
         on_ams_change: Callable[[list], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
+        on_bed_temp_update: Callable[[float], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -293,6 +295,7 @@ class BambuMQTTClient:
         self.on_print_complete = on_print_complete
         self.on_ams_change = on_ams_change
         self.on_layer_change = on_layer_change
+        self.on_bed_temp_update = on_bed_temp_update
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -347,6 +350,16 @@ class BambuMQTTClient:
         self._request_topic_sub_time: float = 0.0
         self._request_topic_confirmed: bool = False
 
+        # Developer mode probe: when the "fun" field is absent (A1/P1 printers),
+        # we probe by sending an ams_filament_setting and checking the response.
+        # "mqtt message verify failed" → dev mode OFF, success → dev mode ON.
+        self._dev_mode_probed: bool = False
+        self._dev_mode_needs_probe: bool = False  # True after seeing a pushall without "fun"
+        self._dev_mode_probe_seq: str | None = None
+        self._dev_mode_probe_time: float = 0.0  # monotonic timestamp when probe was sent
+        self._dev_mode_probe_failures: int = 0  # consecutive unanswered probes
+        self._connect_time: float = 0.0  # monotonic timestamp of last _on_connect
+
         # Set when check_staleness() force-closes the socket to trigger reconnect.
         # Prevents _on_disconnect from redundantly broadcasting state (already done).
         self._stale_reconnecting: bool = False
@@ -354,6 +367,12 @@ class BambuMQTTClient:
         # when the frontend polls status faster than paho can reconnect.
         self._last_stale_reconnect: float = 0.0
 
+        # Zombie session detection via ams_filament_setting response tracking (#887).
+        # The dev-mode probe only runs on first connect; this catches zombie sessions
+        # that develop later (telemetry flows but publishes silently fail).
+        self._last_ams_cmd_time: float = 0.0  # monotonic time of last published command
+        self._ams_cmd_unanswered: int = 0  # consecutive commands with no response
+
     @property
     def topic_subscribe(self) -> str:
         return f"device/{self.serial_number}/report"
@@ -409,12 +428,43 @@ class BambuMQTTClient:
                     pass  # Best-effort; paho loop will reconnect on next iteration
         return self.state.connected
 
+    def force_reconnect_stale_session(self, reason: str) -> None:
+        # Heals the #887 half-broken session: telemetry keeps arriving but our
+        # publishes no longer reach the printer. Closing the socket makes paho
+        # drop and re-establish with a fresh session.
+        logger.warning("[%s] Forcing MQTT reconnect: %s", self.serial_number, reason)
+        self._stale_reconnecting = True
+        self.state.connected = False
+        if self.on_state_change:
+            self.on_state_change(self.state)
+        if self._client:
+            try:
+                sock = self._client.socket()
+                if sock:
+                    sock.close()
+            except Exception:
+                pass
+
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
             self.state.connected = True
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             # Reset per-connection warning state so warnings fire once per (re)connection
             self._ams_version_warned = set()
+            # Preserve cached developer_mode across auto-reconnects to avoid
+            # re-probing on every reconnect.  The probe (ams_filament_setting to
+            # ext slot) can destabilize some firmware MQTT brokers, causing a
+            # reconnect → probe → disconnect feedback loop (#887).  Only probe
+            # once when developer_mode is truly unknown (first connect).
+            # Reset probe tracking so stale timeout state doesn't carry over.
+            self._dev_mode_probed = False
+            self._dev_mode_needs_probe = False
+            self._dev_mode_probe_seq = None
+            self._dev_mode_probe_time = 0.0
+            self._dev_mode_probe_failures = 0
+            self._connect_time = time.monotonic()
+            self._last_ams_cmd_time = 0.0
+            self._ams_cmd_unanswered = 0
             client.subscribe(self.topic_subscribe)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             if self._request_topic_supported:
@@ -624,7 +674,7 @@ class BambuMQTTClient:
 
         # Parse developer LAN mode from top-level "fun" field
         # Some firmware versions send "fun" at the top level, others inside "print"
-        if "fun" in payload and self.state.developer_mode is None:
+        if "fun" in payload:
             try:
                 fun_val = payload["fun"]
                 fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)
@@ -716,12 +766,23 @@ class BambuMQTTClient:
                     f"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})"
                 )
 
-            # Check for K-profile response (extrusion_cali)
+            # Check for command responses
             if "command" in print_data:
                 cmd = print_data.get("command")
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
                 if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
+                # Check for developer mode probe response
+                if (
+                    cmd == "ams_filament_setting"
+                    and self._dev_mode_probe_seq is not None
+                    and print_data.get("sequence_id") == self._dev_mode_probe_seq
+                ):
+                    self._handle_dev_mode_probe_response(print_data)
+                # Track user-initiated ams_filament_setting responses (#887 zombie detection)
+                elif cmd == "ams_filament_setting" and self._last_ams_cmd_time > 0:
+                    self._last_ams_cmd_time = 0.0
+                    self._ams_cmd_unanswered = 0
             if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
                 self._handle_kprofile_response(print_data)
 
@@ -2089,6 +2150,10 @@ class BambuMQTTClient:
             for key, value in temps.items():
                 self.state.temperatures[key] = value
 
+            # Notify bed temperature updates (used by event-driven bed cooldown monitor)
+            if "bed" in temps and self.on_bed_temp_update:
+                self.on_bed_temp_update(temps["bed"])
+
             # Calculate chamber_heating after all targets are known
             # Priority: local target (if recent) > explicit target (chamber_target) > 0
             if "chamber" in temps and "chamber_heating" not in temps:
@@ -2201,17 +2266,30 @@ class BambuMQTTClient:
                             )
                         )
 
-        # Parse SD card status
-        if "sdcard" in data:
-            self.state.sdcard = data["sdcard"] is True
-
-        # Parse home_flag for "Store Sent Files on External Storage" setting (bit 11)
+        # Parse home_flag first so SD-card detection below can prefer it.
+        # Bit 8 = HAS_SDCARD_NORMAL, bit 9 = HAS_SDCARD_ABNORMAL, bit 11 = store-to-SD,
+        # bit 23 = door-open (X1 family only).
+        home_flag = None
         if "home_flag" in data:
             home_flag = data["home_flag"]
-            # Bit 11 controls "Store Sent Files on External Storage"
-            # Convert to unsigned 32-bit if negative
             if home_flag < 0:
                 home_flag = home_flag & 0xFFFFFFFF
+
+        # SD card presence: the only remaining consumer is the firmware-update
+        # precondition check (firmware_update.py). Use the top-level `sdcard`
+        # field when present with a permissive truthy check covering the
+        # bool/int/"HAS_SDCARD_NORMAL" variants real firmware emits. We do NOT
+        # derive this from home_flag — heartbeat pushes clear bits 8-9 even
+        # when a card is inserted, which caused the badge to flap before the
+        # badge was removed entirely.
+        if "sdcard" in data:
+            raw_sdcard = data["sdcard"]
+            if isinstance(raw_sdcard, str):
+                self.state.sdcard = "HAS_SDCARD" in raw_sdcard.upper() or raw_sdcard.lower() in ("true", "normal", "1")
+            else:
+                self.state.sdcard = bool(raw_sdcard)
+
+        if home_flag is not None:
             store_to_sdcard = bool((home_flag >> 11) & 1)
             if store_to_sdcard != self.state.store_to_sdcard:
                 logger.debug(
@@ -2219,6 +2297,39 @@ class BambuMQTTClient:
                 )
             self.state.store_to_sdcard = store_to_sdcard
 
+        # Door open detection — source depends on printer family:
+        #   X1 series (X1, X1C, X1E): home_flag bit 23
+        #   All others (P1/P2/H2/A1/N-series): top-level `stat` field (hex string), bit 23
+        # Both share the same bitmask (0x00800000) but live in different fields.
+        model_upper = (self.model or "").upper().strip()
+        is_x1_family = model_upper in ("X1", "X1C", "X1E")
+        if is_x1_family and home_flag is not None:
+            door_open = (home_flag & 0x00800000) != 0
+            if door_open != self.state.door_open:
+                logger.debug(
+                    "[%s] door_open changed: %s -> %s (home_flag=0x%08X)",
+                    self.serial_number,
+                    self.state.door_open,
+                    door_open,
+                    home_flag,
+                )
+            self.state.door_open = door_open
+        elif not is_x1_family and "stat" in data:
+            try:
+                stat_value = int(data["stat"], 16) if isinstance(data["stat"], str) else int(data["stat"])
+                door_open = (stat_value & 0x00800000) != 0
+                if door_open != self.state.door_open:
+                    logger.debug(
+                        "[%s] door_open changed: %s -> %s (stat=0x%08X)",
+                        self.serial_number,
+                        self.state.door_open,
+                        door_open,
+                        stat_value,
+                    )
+                self.state.door_open = door_open
+            except (ValueError, TypeError):
+                logger.debug("[%s] could not parse stat field: %r", self.serial_number, data["stat"])
+
         # Parse timelapse status (recording active during print)
         if "timelapse" in data:
             logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"])
@@ -2384,16 +2495,18 @@ class BambuMQTTClient:
         vt_tray_data = self.state.raw_data.get("vt_tray")
         ams_extruder_map_data = self.state.raw_data.get("ams_extruder_map")
         mapping_data = self.state.raw_data.get("mapping")
+
+        # Normalize vt_tray in data before assigning to raw_data: MQTT sends it
+        # as a dict but consumers expect a list.  Without this, the dev mode probe
+        # below can release the GIL (via publish), letting the event-loop thread
+        # read raw_data["vt_tray"] as a dict and crash iterating over string keys.
+        if "vt_tray" in data and isinstance(data["vt_tray"], dict):
+            data["vt_tray"] = [data["vt_tray"]]
+
         self.state.raw_data = data
 
-        # Parse developer LAN mode from "fun" field
-        if "fun" in data:
-            try:
-                fun_val = data["fun"]
-                fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)
-                self.state.developer_mode = (fun_int & 0x20000000) == 0
-            except (ValueError, TypeError):
-                pass
+        # Restore preserved fields BEFORE any work that may release the GIL
+        # (e.g. _probe_developer_mode publishes an MQTT message).
         if ams_data is not None:
             self.state.raw_data["ams"] = ams_data
         if vt_tray_data is not None:
@@ -2403,6 +2516,68 @@ class BambuMQTTClient:
         if mapping_data is not None and "mapping" not in data:
             self.state.raw_data["mapping"] = mapping_data
 
+        # Parse developer LAN mode from "fun" field
+        if "fun" in data:
+            try:
+                fun_val = data["fun"]
+                fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)
+                self.state.developer_mode = (fun_int & 0x20000000) == 0
+            except (ValueError, TypeError):
+                pass
+        elif self.state.developer_mode is None and not self._dev_mode_probed:
+            # No "fun" field — A1/P1 series never send it, so we need to probe.
+            # Two gates: (1) wait for a full pushall (30+ keys) so we don't probe
+            # before a pushall that might contain "fun" arrives, and (2) delay 5s
+            # after connect to let the MQTT session stabilize — probing too early
+            # can destabilize some firmware MQTT brokers (#887).
+            if not self._dev_mode_needs_probe and len(data) > 30:
+                # First full status without "fun" — mark that probe is needed
+                self._dev_mode_needs_probe = True
+            if self._dev_mode_needs_probe and time.monotonic() - self._connect_time >= 5.0:
+                self._probe_developer_mode()
+            elif self._dev_mode_needs_probe:
+                logger.debug(
+                    "[%s] Deferring developer mode probe (%.1fs since connect, need 5s)",
+                    self.serial_number,
+                    time.monotonic() - self._connect_time,
+                )
+        elif self._dev_mode_probed and self._dev_mode_probe_seq is not None:
+            # Probe was sent but no response yet — check for timeout.
+            # A half-broken MQTT session (e.g. after keep-alive timeout reconnect)
+            # may deliver status pushes but silently drop commands (#887).
+            elapsed = time.monotonic() - self._dev_mode_probe_time
+            if elapsed > 10.0:
+                self._dev_mode_probe_failures += 1
+                logger.warning(
+                    "[%s] Developer mode probe timed out after %.0fs (attempt %d)",
+                    self.serial_number,
+                    elapsed,
+                    self._dev_mode_probe_failures,
+                )
+                self._dev_mode_probe_seq = None
+                if self._dev_mode_probe_failures >= 2:
+                    self.force_reconnect_stale_session("developer mode probe unanswered 2×")
+                else:
+                    # Allow retry on next full status message
+                    self._dev_mode_probed = False
+
+        # Zombie session detection: if an ams_filament_setting command has been
+        # pending for >10s with no response, the publish path is likely dead (#887).
+        if self._last_ams_cmd_time > 0:
+            elapsed = time.monotonic() - self._last_ams_cmd_time
+            if elapsed > 10.0:
+                self._ams_cmd_unanswered += 1
+                logger.warning(
+                    "[%s] ams_filament_setting unanswered for %.0fs (count=%d)",
+                    self.serial_number,
+                    elapsed,
+                    self._ams_cmd_unanswered,
+                )
+                self._last_ams_cmd_time = 0.0  # don't re-trigger on next push_status
+                if self._ams_cmd_unanswered >= 2:
+                    self.force_reconnect_stale_session("ams_filament_setting unanswered 2\u00d7")
+                    self._ams_cmd_unanswered = 0
+
         # Log mapping data when received (for usage tracking debugging)
         if "mapping" in data:
             logger.debug("[%s] MQTT mapping field: %s", self.serial_number, data["mapping"])
@@ -2575,6 +2750,74 @@ class BambuMQTTClient:
             message = {"pushing": {"command": "pushall"}}
             self._client.publish(self.topic_publish, json.dumps(message), qos=1)
 
+    def _probe_developer_mode(self):
+        """Probe developer mode by sending an ams_filament_setting for the external slot.
+
+        Some printers (A1/P1 series) never send the "fun" field in MQTT status.
+        For these, we detect developer mode by sending a harmless command and
+        checking whether the printer accepts or rejects it:
+        - result="success" → developer mode ON (commands accepted)
+        - result="failed", reason="mqtt message verify failed" → developer mode OFF
+
+        The probe re-sends the current external slot configuration so it's a no-op
+        when the command succeeds. If there's no external slot data yet, we send a
+        reset (empty filament) which is also safe.
+        """
+        if not self._client or not self.state.connected:
+            return
+        self._dev_mode_probed = True
+        self._dev_mode_probe_time = time.monotonic()
+        self._sequence_id += 1
+        seq = str(self._sequence_id)
+        self._dev_mode_probe_seq = seq
+
+        # Build probe command: re-send current external slot config (no-op on success)
+        vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+        current = vt_tray[0] if vt_tray else {}
+
+        command = {
+            "print": {
+                "command": "ams_filament_setting",
+                "ams_id": 255,
+                "tray_id": 0,
+                "slot_id": 0,
+                "tray_info_idx": current.get("tray_info_idx", ""),
+                "tray_type": current.get("tray_type", ""),
+                "tray_sub_brands": current.get("tray_sub_brands", ""),
+                "tray_color": current.get("tray_color", "00000000"),
+                "nozzle_temp_min": current.get("nozzle_temp_min", 0),
+                "nozzle_temp_max": current.get("nozzle_temp_max", 0),
+                "sequence_id": seq,
+            }
+        }
+        setting_id = current.get("setting_id")
+        if setting_id:
+            command["print"]["setting_id"] = setting_id
+
+        logger.info("[%s] Probing developer mode via ams_filament_setting (seq=%s)", self.serial_number, seq)
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+
+    def _handle_dev_mode_probe_response(self, data: dict):
+        """Handle response to the developer mode probe command.
+
+        Sets developer_mode based on whether the printer accepted or rejected the command.
+        """
+        self._dev_mode_probe_seq = None  # One-shot: don't match future responses
+        self._dev_mode_probe_failures = 0  # Reset on any response
+        result = data.get("result", "")
+        reason = data.get("reason", "")
+
+        if result == "failed" and "verify failed" in reason:
+            self.state.developer_mode = False
+            logger.info("[%s] Developer mode probe: DISABLED (reason=%r)", self.serial_number, reason)
+        else:
+            # Success or any other response — commands are accepted
+            self.state.developer_mode = True
+            logger.info("[%s] Developer mode probe: ENABLED (result=%r)", self.serial_number, result)
+
+        if self.on_state_change:
+            self.on_state_change(self.state)
+
     def _request_version(self):
         """Request firmware version info from printer."""
         if self._client:
@@ -2709,6 +2952,12 @@ class BambuMQTTClient:
         """
         if self._client and self.state.connected:
             # Bambu print command format - matches Bambu Studio's format
+            # H2D series requires integer values (0/1) for calibration/leveling fields
+            # but use_ams MUST remain boolean — H2D Pro firmware interprets integer
+            # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
+            # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
+            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")
+
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
             # BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
@@ -2725,13 +2974,19 @@ class BambuMQTTClient:
                         flat_ams_mapping.append(-1)
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                     elif tray_id >= 254:
-                        # External/virtual spool: each virtual tray is its own AMS unit
-                        # with a single slot (slot 0). BambuStudio convention:
-                        #   255 = VIRTUAL_TRAY_MAIN_ID (main/left nozzle)
-                        #   254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/right nozzle)
+                        # External/virtual spool. BambuStudio convention:
+                        #   255 = VIRTUAL_TRAY_MAIN_ID (main/right nozzle)
+                        #   254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/left nozzle)
                         # Flat mapping must use -1 (firmware doesn't accept raw 254/255).
+                        # Single-nozzle printers (X1C, P1S, A1, etc.) report tray_now=254
+                        # for external spool, but BambuStudio always sends ams_id=255
+                        # (VIRTUAL_TRAY_MAIN_ID) in ams_mapping2. Sending 254 causes the
+                        # firmware to target AMS tray 0 instead of external spool, leading
+                        # to 07FF_8012 "Failed to get AMS mapping table" or stuck prints.
+                        # Only H2D dual-nozzle printers use 254 (deputy/left nozzle).
                         flat_ams_mapping.append(-1)
-                        ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
+                        ext_ams_id = tray_id if is_h2d else 255
+                        ams_mapping2.append({"ams_id": ext_ams_id, "slot_id": 0})
                     elif tray_id >= 128:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         flat_ams_mapping.append(tray_id)
@@ -2743,11 +2998,29 @@ class BambuMQTTClient:
                         flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
-            # H2D series requires integer values (0/1) for calibration/leveling fields
-            # but use_ams MUST remain boolean — H2D Pro firmware interprets integer
-            # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
-            # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
-            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S")
+            # If all mapped slots are external spool (no real AMS trays), force use_ams=False.
+            # P1S/P1P with no AMS rejects use_ams=True with "Failed to get AMS mapping table".
+            # Skip for H2D series — use_ams controls nozzle routing on those printers.
+            if ams_mapping and use_ams and not is_h2d:
+                if all(t is None or int(t) < 0 or int(t) >= 254 for t in ams_mapping):
+                    use_ams = False
+                    logger.info(
+                        "[%s] All filament slots use external spool — setting use_ams=False",
+                        self.serial_number,
+                    )
+
+            # Unique per-submission identity fields. Hardcoded "0" values caused
+            # third-party MQTT observers (OctoEverywhere, etc.) to see reprints as
+            # continuations of the same job: the printer reuses gcode_start_time
+            # from the prior print with task_id=0, so observers latch onto a stale
+            # timestamp and report compounding durations on repeat replays (#1011).
+            # BambuStudio mints fresh IDs per submission; matching that behavior
+            # makes the printer emit a clean state-transition for each job.
+            # md5 is left empty — firmware historically accepts "" as "skip
+            # validation" (unlike Studio, we don't have the file's real md5 here
+            # without re-reading the upload, and sending a synthetic wrong digest
+            # risks activation of md5 verification on some firmwares).
+            submission_id = str(int(time.time() * 1000))
 
             command = {
                 "print": {
@@ -2771,9 +3044,9 @@ class BambuMQTTClient:
                     "nozzle_offset_cali": 2,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "profile_id": "0",
-                    "project_id": "0",
-                    "subtask_id": "0",
-                    "task_id": "0",
+                    "project_id": submission_id,
+                    "subtask_id": submission_id,
+                    "task_id": submission_id,
                 }
             }
 
@@ -3452,8 +3725,10 @@ class BambuMQTTClient:
         self._sequence_id += 1
 
         # Detect printer type by serial number prefix
-        # H2D series (dual nozzle): serial starts with "094"
-        is_dual_nozzle = self.serial_number.startswith("094")
+        # Dual-nozzle families:
+        #   H2D series: serial starts with "094"
+        #   X2D series: serial starts with "20P9"
+        is_dual_nozzle = self.serial_number.startswith(("094", "20P9"))
 
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
@@ -4095,6 +4370,7 @@ class BambuMQTTClient:
         )
         logger.debug("[%s] ams_filament_setting command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
+        self._last_ams_cmd_time = time.monotonic()
         return True
 
     def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool:
@@ -4152,6 +4428,7 @@ class BambuMQTTClient:
         logger.info("[%s] Resetting AMS slot: AMS %s, tray %s", self.serial_number, ams_id, tray_id)
         logger.debug("[%s] reset_ams_slot command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
+        self._last_ams_cmd_time = time.monotonic()
         return True
 
     def extrusion_cali_sel(

+ 20 - 8
backend/app/services/camera.py

@@ -1,12 +1,13 @@
 """Camera capture service for Bambu Lab printers.
 
 Supports two camera protocols:
-- RTSP: Used by X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S (port 322)
+- RTSP: Used by X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S (port 322)
 - Chamber Image: Used by A1, A1MINI, P1P, P1S (port 6000, custom binary protocol)
 """
 
 import asyncio
 import logging
+import os
 import shutil
 import ssl
 import struct
@@ -23,6 +24,10 @@ JPEG_END = b"\xff\xd9"
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 
+# Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
+# The cleanup task in routes/camera.py checks this set to avoid killing active captures.
+_active_capture_pids: set[int] = set()
+
 
 def get_ffmpeg_path() -> str | None:
     """Find the ffmpeg executable path.
@@ -64,13 +69,14 @@ def get_ffmpeg_path() -> str | None:
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
 
-    RTSP supported: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
+    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
     Chamber image only: A1, A1MINI, P1P, P1S
 
     Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
     Internal codes from MQTT/SSDP:
       - BL-P001: X1/X1C
       - C13: X1E
+      - N6: X2D
       - O1D: H2D
       - O1C, O1C2: H2C
       - O1S: H2S
@@ -79,11 +85,11 @@ def supports_rtsp(model: str | None) -> bool:
     """
     if model:
         model_upper = model.upper()
-        # Display names: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
-        if model_upper.startswith(("X1", "H2", "P2")):
+        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
+        if model_upper.startswith(("X1", "X2", "H2", "P2")):
             return True
         # Internal codes for RTSP models
-        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
+        if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
             return True
     # A1/P1 and unknown models use chamber image protocol
     return False
@@ -92,7 +98,7 @@ def supports_rtsp(model: str | None) -> bool:
 def get_camera_port(model: str | None) -> int:
     """Get the camera port based on printer model.
 
-    X1/H2/P2 series use RTSP on port 322.
+    X1/X2/H2/P2 series use RTSP on port 322.
     A1/P1 series use chamber image protocol on port 6000.
     """
     if supports_rtsp(model):
@@ -504,6 +510,7 @@ async def capture_camera_frame_bytes(
 
     logger.info("Capturing camera frame bytes from %s using RTSP (model: %s)", ip_address, model)
 
+    process = None
     try:
         process = await asyncio.create_subprocess_exec(
             *cmd,
@@ -511,6 +518,7 @@ async def capture_camera_frame_bytes(
             stderr=asyncio.subprocess.PIPE,
         )
 
+        _active_capture_pids.add(process.pid)
         try:
             stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         except TimeoutError:
@@ -534,6 +542,8 @@ async def capture_camera_frame_bytes(
         logger.exception("Camera frame bytes capture failed: %s", e)
         return None
     finally:
+        if process is not None:
+            _active_capture_pids.discard(process.pid)
         proxy_server.close()
         await proxy_server.wait_closed()
 
@@ -593,8 +603,10 @@ async def test_camera_connection(
     """
     import tempfile
 
-    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
-        test_path = Path(f.name)
+    fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
+    os.close(fd)
+    test_path = Path(tmp_name)
+    test_path.chmod(0o600)
 
     try:
         success = await capture_camera_frame(

+ 69 - 12
backend/app/services/email_service.py

@@ -32,8 +32,6 @@ def generate_secure_password(length: int = 16) -> str:
     Returns:
         A secure random password containing uppercase, lowercase, digits, and special characters
     """
-    import random
-
     # Define character sets
     lowercase = string.ascii_lowercase
     uppercase = string.ascii_uppercase
@@ -52,8 +50,8 @@ def generate_secure_password(length: int = 16) -> str:
     all_chars = lowercase + uppercase + digits + special
     password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
 
-    # Shuffle to avoid predictable patterns
-    random.shuffle(password_chars)
+    # Shuffle with CSPRNG — random.shuffle() is seeded from time and not cryptographically safe
+    secrets.SystemRandom().shuffle(password_chars)
 
     return "".join(password_chars)
 
@@ -152,8 +150,7 @@ async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> N
         db: Database session
         smtp_settings: SMTP settings to save
     """
-    from sqlalchemy import func
-    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+    from backend.app.core.db_dialect import upsert_setting
 
     settings_data = {
         "smtp_host": smtp_settings.smtp_host,
@@ -173,12 +170,7 @@ async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> N
         settings_data["smtp_password"] = smtp_settings.smtp_password
 
     for key, value in settings_data.items():
-        stmt = sqlite_insert(Settings).values(key=key, value=value)
-        stmt = stmt.on_conflict_do_update(
-            index_elements=["key"],
-            set_={"value": value, "updated_at": func.now()},
-        )
-        await db.execute(stmt)
+        await upsert_setting(db, Settings, key, value)
 
 
 def send_email(
@@ -387,6 +379,71 @@ BamBuddy Team
     return subject, text_body, html_body
 
 
+def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
+    """Create a password-reset email that contains a secure link (not a plaintext password)."""
+    subject = "BamBuddy - Password Reset Request"
+
+    text_body = f"""A password reset was requested for your BamBuddy account.
+
+Username: {username}
+
+Click the link below to set a new password (valid for 1 hour):
+{reset_url}
+
+If you did not request this reset, you can safely ignore this email.
+
+Best regards,
+BamBuddy Team
+"""
+
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px;">Password Reset Request</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <p style="font-size: 16px;">A password reset was requested for your BamBuddy account (<strong>{username}</strong>).</p>
+        <p>Click the button below to set a new password. This link is valid for <strong>1 hour</strong>.</p>
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{reset_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
+        </div>
+        <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
+            <p style="margin: 0; font-size: 14px; color: #856404;">
+                <strong>Did not request this?</strong> You can safely ignore this email. Your password has not been changed.
+            </p>
+        </div>
+        <p style="font-size: 14px; color: #999; margin-top: 30px;">
+            Best regards,<br>BamBuddy Team
+        </p>
+    </div>
+</body>
+</html>
+"""
+    return subject, text_body, html_body
+
+
+async def create_password_reset_link_email_from_template(
+    db: AsyncSession, username: str, reset_url: str
+) -> tuple[str, str, str]:
+    """Create password-reset link email, using DB template if configured."""
+    template = await get_notification_template(db, "password_reset_link")
+    if template:
+        variables = {"username": username, "reset_url": reset_url}
+        subject = render_template(template.subject or "BamBuddy - Password Reset Request", variables)
+        text_body = render_template(template.body or "", variables)
+        html_body = render_template(template.html_body or "", variables) if template.html_body else None
+        if not html_body:
+            _, text_body, html_body = create_password_reset_link_email(username, reset_url)
+            return subject, text_body, html_body
+        return subject, text_body, html_body
+    return create_password_reset_link_email(username, reset_url)
+
+
 async def create_welcome_email_from_template(
     db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
 ) -> tuple[str, str, str]:

+ 3 - 0
backend/app/services/export.py

@@ -158,6 +158,7 @@ class ExportService:
         days: int = 30,
         printer_id: int | None = None,
         project_id: int | None = None,
+        created_by_id: int | None = None,
     ) -> tuple[bytes, str, str]:
         """Export statistics summary to CSV or Excel format.
 
@@ -166,6 +167,7 @@ class ExportService:
             days: Number of days to include in stats
             printer_id: Filter by printer
             project_id: Filter by project
+            created_by_id: Filter by user who created the print (-1 for no user)
 
         Returns:
             Tuple of (file_bytes, filename, content_type)
@@ -178,6 +180,7 @@ class ExportService:
             days=days,
             printer_id=printer_id,
             project_id=project_id,
+            created_by_id=created_by_id,
         )
 
         # Build stats rows

+ 6 - 0
backend/app/services/failure_analysis.py

@@ -21,6 +21,7 @@ class FailureAnalysisService:
         date_to: date | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
+        created_by_id: int | None = None,
     ) -> dict:
         """Analyze failure patterns across archives.
 
@@ -56,6 +57,11 @@ class FailureAnalysisService:
             non_date_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:
             non_date_filter.append(PrintArchive.project_id == project_id)
+        if created_by_id is not None:
+            if created_by_id == -1:
+                non_date_filter.append(PrintArchive.created_by_id.is_(None))
+            else:
+                non_date_filter.append(PrintArchive.created_by_id == created_by_id)
         base_filter.extend(non_date_filter)
 
         # Total counts

+ 167 - 41
backend/app/services/firmware_check.py

@@ -46,6 +46,7 @@ MODEL_TO_API_KEY = {
     "H2S": "h2s",
     "P2S": "p2s",
     "X1E": "x1e",
+    "X2D": "x2d",
     "H2D Pro": "h2d-pro",
     "H2D-Pro": "h2d-pro",
     "H2DPRO": "h2d-pro",
@@ -64,6 +65,7 @@ MODEL_TO_API_KEY = {
     "C13": "p2s",
     "N2S": "a1",
     "N1": "a1-mini",
+    "N6": "x2d",
     "N7": "p2s",
 }
 
@@ -78,6 +80,7 @@ API_KEY_TO_DEV_MODEL = {
     "h2s": "O1S",
     "p2s": "N7",
     "x1e": "C13",
+    "x2d": "N6",
     "h2d-pro": "O1E",
 }
 
@@ -92,6 +95,7 @@ API_KEY_TO_WIKI_PATH = {
     "h2c": "/en/h2c/manual/h2c-firmware-release-history",
     "h2s": "/en/h2s/manual/h2s-firmware-release-history",
     "p2s": "/en/p2s/manual/p2s-firmware-release-history",
+    "x2d": "/en/x2d/manual/x2d-firmware-release-history",
     "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
 }
 
@@ -113,6 +117,7 @@ class FirmwareCheckService:
         self._build_id: str | None = None
         self._build_id_time: float = 0
         self._version_cache: dict[str, FirmwareVersion] = {}
+        self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
         self._cache_time: float = 0
         self._client = httpx.AsyncClient(
             timeout=30.0,
@@ -145,33 +150,63 @@ class FirmwareCheckService:
 
     async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
         """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
+        versions = await self._fetch_all_versions_from_wiki(api_key)
+        if versions:
+            logger.debug("Wiki firmware for %s: %s", api_key, versions[0][0])
+            return versions[0][0]
+        return None
+
+    async def _fetch_all_versions_from_wiki(self, api_key: str) -> list[tuple[str, str | None]]:
+        """
+        Fetch all firmware versions from the wiki release history page.
+
+        Only extracts versions that appear in section-heading anchors
+        (e.g. `id="h-01030000-20260303"`) — this excludes version-like
+        numbers mentioned incidentally in release-note text.
+
+        Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
+        """
         wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
         if not wiki_path:
-            return None
+            return []
 
         try:
             url = f"{BAMBU_WIKI_BASE}{wiki_path}"
             response = await self._client.get(url, follow_redirects=True)
-
-            if response.status_code == 200:
-                # Extract version strings (format: XX.XX.XX.XX), first match is the latest
-                versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
-                if versions:
-                    logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
-                    return versions[0]
-            else:
-                logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
-
+            if response.status_code != 200:
+                return []
+
+            # Primary: heading anchor ids like id="h-01030000-20260303"
+            anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-(\d{8})"', response.text)
+            seen: set[str] = set()
+            versions: list[tuple[str, str | None]] = []
+            for a, b, c, d, date in anchor_matches:
+                v = f"{a}.{b}.{c}.{d}"
+                if v in seen:
+                    continue
+                seen.add(v)
+                versions.append((v, date))
+
+            if versions:
+                return versions
+
+            # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)"
+            text_matches = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*\((\d{8})\)", response.text)
+            for v, date in text_matches:
+                if v in seen:
+                    continue
+                seen.add(v)
+                versions.append((v, date))
+            return versions
         except Exception as e:
-            logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
-
-        return None
+            logger.debug("Error fetching wiki firmware list for %s: %s", api_key, e)
+        return []
 
-    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
-        """Fetch firmware info from Bambu Lab's download page (has download URLs)."""
+    async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
+        """Fetch all firmware versions from Bambu Lab's download page (newest first)."""
         build_id = await self._get_build_id()
         if not build_id:
-            return None
+            return []
 
         try:
             url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
@@ -183,20 +218,26 @@ class FirmwareCheckService:
                 printer_map = page_props.get("printerMap", {})
                 printer_data = printer_map.get(api_key, {})
                 versions = printer_data.get("versions", [])
-
-                if versions:
-                    latest = versions[0]
-                    return FirmwareVersion(
-                        version=latest.get("version", ""),
-                        download_url=latest.get("url", ""),
-                        release_notes=latest.get("release_notes_en"),
-                        release_time=latest.get("release_time"),
+                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")
+                ]
 
         except Exception as e:
             logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
 
-        return None
+        return []
+
+    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch the latest firmware info from Bambu Lab's download page (has download URLs)."""
+        versions = await self._fetch_all_versions_from_download_page(api_key)
+        return versions[0] if versions else None
 
     async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
         """Fetch firmware version info, using wiki as primary source and download page as fallback."""
@@ -266,6 +307,72 @@ class FirmwareCheckService:
 
         return version
 
+    def _resolve_api_key(self, model: str) -> str | None:
+        """Resolve a model name to its Bambu API key."""
+        model_upper = model.upper().replace(" ", "").replace("-", "")
+        for name, key in MODEL_TO_API_KEY.items():
+            if name.upper().replace(" ", "").replace("-", "") == model_upper:
+                return key
+        return MODEL_TO_API_KEY.get(model)
+
+    @staticmethod
+    def _version_tuple(v: str) -> tuple[int, ...]:
+        parts = [int(x) for x in v.split(".")]
+        while len(parts) < 4:
+            parts.append(0)
+        return tuple(parts)
+
+    async def get_available_versions(self, model: str) -> list[FirmwareVersion]:
+        """
+        Get all announced firmware versions for a model, newest first.
+
+        Merges the wiki release history (list of version strings) with the
+        download page JSON (which provides download URLs + release notes).
+        Versions present only on the wiki have an empty download_url and
+        should be treated as "unavailable" for file-based installation.
+        """
+        api_key = self._resolve_api_key(model)
+        if not api_key:
+            return []
+
+        if api_key in self._versions_list_cache and (time.time() - self._cache_time) < CACHE_TTL:
+            return self._versions_list_cache[api_key]
+
+        wiki_versions = await self._fetch_all_versions_from_wiki(api_key)
+        download_versions = await self._fetch_all_versions_from_download_page(api_key)
+        by_version: dict[str, FirmwareVersion] = {d.version: d for d in download_versions if d.version}
+
+        merged: list[FirmwareVersion] = []
+        seen: set[str] = set()
+        for v, wiki_date in wiki_versions:
+            if v in seen:
+                continue
+            seen.add(v)
+            if v in by_version:
+                merged.append(by_version[v])
+            else:
+                merged.append(FirmwareVersion(version=v, download_url="", release_time=wiki_date))
+        for d in download_versions:
+            if d.version and d.version not in seen:
+                seen.add(d.version)
+                merged.append(d)
+
+        try:
+            merged.sort(key=lambda fv: self._version_tuple(fv.version), reverse=True)
+        except (ValueError, AttributeError):
+            pass
+
+        self._versions_list_cache[api_key] = merged
+        self._cache_time = time.time()
+        return merged
+
+    async def get_version_info(self, model: str, version: str) -> FirmwareVersion | None:
+        """Find a specific version's info (including download URL) for a model."""
+        for v in await self.get_available_versions(model):
+            if v.version == version:
+                return v
+        return None
+
     async def check_for_update(self, model: str, current_version: str) -> dict:
         """
         Check if a firmware update is available for a printer.
@@ -288,17 +395,30 @@ class FirmwareCheckService:
             "latest_version": None,
             "download_url": None,
             "release_notes": None,
+            "available_versions": [],
         }
 
+        available = await self.get_available_versions(model)
+        result["available_versions"] = [
+            {
+                "version": v.version,
+                "download_url": v.download_url or None,
+                "file_available": bool(v.download_url),
+                "release_notes": v.release_notes,
+                "release_time": v.release_time,
+            }
+            for v in available
+        ]
+
         if not current_version:
             return result
 
-        latest = await self.get_latest_version(model)
+        latest = available[0] if available else await self.get_latest_version(model)
         if not latest:
             return result
 
         result["latest_version"] = latest.version
-        result["download_url"] = latest.download_url
+        result["download_url"] = latest.download_url or None
         result["release_notes"] = latest.release_notes
 
         # Compare versions (format: XX.XX.XX.XX)
@@ -340,32 +460,35 @@ class FirmwareCheckService:
         cache_dir.mkdir(parents=True, exist_ok=True)
         return cache_dir
 
-    async def get_firmware_file_info(self, model: str) -> dict | None:
+    async def get_firmware_file_info(self, model: str, version: str | None = None) -> dict | None:
         """
-        Get information about the firmware file for a model.
+        Get information about a firmware file for a model.
 
-        Returns:
-            Dict with download_url, version, filename, and estimated_size (if available)
+        If `version` is provided, returns info for that specific version (must be
+        available on the download page). Otherwise returns info for the latest version.
         """
-        latest = await self.get_latest_version(model)
-        if not latest or not latest.download_url:
+        if version:
+            target = await self.get_version_info(model, version)
+        else:
+            target = await self.get_latest_version(model)
+        if not target or not target.download_url:
             return None
 
-        # Extract filename from URL
-        url_parts = latest.download_url.split("/")
+        url_parts = target.download_url.split("/")
         filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
 
         return {
-            "download_url": latest.download_url,
-            "version": latest.version,
+            "download_url": target.download_url,
+            "version": target.version,
             "filename": filename,
-            "release_notes": latest.release_notes,
+            "release_notes": target.release_notes,
         }
 
     async def download_firmware(
         self,
         model: str,
         progress_callback: Callable[[int, int, str], None] | None = None,
+        version: str | None = None,
     ) -> Path | None:
         """
         Download firmware file for a printer model.
@@ -377,9 +500,12 @@ class FirmwareCheckService:
         Returns:
             Path to downloaded firmware file, or None on failure
         """
-        latest = await self.get_latest_version(model)
+        if version:
+            latest = await self.get_version_info(model, version)
+        else:
+            latest = await self.get_latest_version(model)
         if not latest or not latest.download_url:
-            logger.warning("No firmware download URL available for model: %s", model)
+            logger.warning("No firmware download URL available for model %s version %s", model, version)
             return None
 
         # Extract original filename from URL (must preserve for SD card update)

+ 24 - 9
backend/app/services/firmware_update.py

@@ -79,6 +79,7 @@ class FirmwareUpdateService:
         self,
         printer_id: int,
         db: AsyncSession,
+        target_version: str | None = None,
     ) -> dict:
         """
         Check prerequisites for firmware update.
@@ -105,6 +106,7 @@ class FirmwareUpdateService:
             "update_available": False,
             "current_version": None,
             "latest_version": None,
+            "target_version": target_version,
             "firmware_filename": None,
             "errors": [],
         }
@@ -162,16 +164,23 @@ class FirmwareUpdateService:
                 result["latest_version"] = latest.version
                 result["update_available"] = True  # Assume update needed
 
-        if not result["update_available"]:
-            result["errors"].append("Firmware is already up to date")
-
-        # Get firmware file info
-        file_info = await firmware_service.get_firmware_file_info(model)
+        # Get firmware file info (for target_version if specified, else latest)
+        file_info = await firmware_service.get_firmware_file_info(model, version=target_version)
         if file_info:
             result["firmware_filename"] = file_info["filename"]
             # Estimate size (typical firmware is 50-150MB)
             # We'll get actual size during download
             result["firmware_size"] = 100 * 1024 * 1024  # 100MB estimate
+        elif target_version:
+            # Requested specific version has no download URL
+            result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
+
+        # If a target version is requested, allow proceeding even if it equals or
+        # is older than the current version (explicit downgrade/reinstall).
+        if target_version:
+            result["update_available"] = bool(file_info)
+        elif not result["update_available"]:
+            result["errors"].append("Firmware is already up to date")
 
         # Check space
         if result["sd_card_free_space"] > 0:
@@ -201,6 +210,7 @@ class FirmwareUpdateService:
         self,
         printer_id: int,
         db: AsyncSession,
+        target_version: str | None = None,
     ) -> bool:
         """
         Start the firmware upload process.
@@ -242,6 +252,7 @@ class FirmwareUpdateService:
                 ip_address=printer.ip_address,
                 access_code=printer.access_code,
                 model=model,
+                target_version=target_version,
             )
         )
 
@@ -253,6 +264,7 @@ class FirmwareUpdateService:
         ip_address: str,
         access_code: str,
         model: str,
+        target_version: str | None = None,
     ):
         """Perform the actual firmware download and upload."""
         state = get_upload_state(printer_id)
@@ -265,7 +277,7 @@ class FirmwareUpdateService:
             state.message = "Preparing firmware..."
             await self._broadcast_progress(printer_id, state)
 
-            firmware_path = await firmware_service.download_firmware(model)
+            firmware_path = await firmware_service.download_firmware(model, version=target_version)
 
             if not firmware_path:
                 raise Exception("Failed to download firmware")
@@ -273,9 +285,12 @@ class FirmwareUpdateService:
             state.firmware_filename = firmware_path.name
 
             # Get firmware version for state
-            latest = await firmware_service.get_latest_version(model)
-            if latest:
-                state.firmware_version = latest.version
+            if target_version:
+                state.firmware_version = target_version
+            else:
+                latest = await firmware_service.get_latest_version(model)
+                if latest:
+                    state.firmware_version = latest.version
 
             # Upload to printer (0-100% progress shown here)
             state.status = FirmwareUploadStatus.UPLOADING

+ 148 - 11
backend/app/services/github_backup.py

@@ -16,10 +16,12 @@ from sqlalchemy import desc, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
-from backend.app.services.bambu_cloud import get_cloud_service
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
@@ -331,6 +333,8 @@ class GitHubBackupService:
                 "kprofiles": config.backup_kprofiles,
                 "cloud_profiles": config.backup_cloud_profiles,
                 "settings": config.backup_settings,
+                "spools": config.backup_spools,
+                "archives": config.backup_archives,
             },
         }
         files["backup_metadata.json"] = metadata
@@ -350,6 +354,16 @@ class GitHubBackupService:
             self._backup_progress = "Collecting app settings..."
             await self._collect_settings(db, files)
 
+        # Collect spool inventory
+        if config.backup_spools:
+            self._backup_progress = "Collecting spool inventory..."
+            await self._collect_spools(db, files)
+
+        # Collect print archives
+        if config.backup_archives:
+            self._backup_progress = "Collecting print archives..."
+            await self._collect_archives(db, files)
+
         return files
 
     async def _collect_kprofiles(self, db: AsyncSession, files: dict):
@@ -400,16 +414,15 @@ class GitHubBackupService:
 
     async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
         """Collect Bambu Cloud profiles if authenticated."""
-        # Check if cloud is authenticated
-        cloud = get_cloud_service()
-
-        # Try to restore token from DB
-        result = await db.execute(select(Settings).where(Settings.key == "bambu_cloud_token"))
-        setting = result.scalar_one_or_none()
-        if setting and setting.value:
-            cloud.set_token(setting.value)
-
-        if not cloud.is_authenticated:
+        # Backup runs without a user context, so fall back to the auth-disabled
+        # Settings storage. ``build_authenticated_cloud`` honours the stored
+        # region so China-region tokens are validated against api.bambulab.cn.
+        from backend.app.api.routes.cloud import build_authenticated_cloud
+
+        cloud = await build_authenticated_cloud(db, user=None)
+        if cloud is None or not cloud.is_authenticated:
+            if cloud is not None:
+                await cloud.close()
             logger.info("Cloud not authenticated, skipping cloud profiles")
             return
 
@@ -457,6 +470,8 @@ class GitHubBackupService:
 
         except Exception as e:
             logger.warning("Failed to collect cloud profiles: %s", e)
+        finally:
+            await cloud.close()
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """Collect app settings."""
@@ -472,6 +487,128 @@ class GitHubBackupService:
             "settings": settings_data,
         }
 
+    async def _collect_spools(self, db: AsyncSession, files: dict):
+        """Collect spool inventory data."""
+        result = await db.execute(select(Spool))
+        spools = result.scalars().all()
+
+        if not spools:
+            return
+
+        spool_list = []
+        for s in spools:
+            spool_data = {
+                "id": s.id,
+                "material": s.material,
+                "subtype": s.subtype,
+                "color_name": s.color_name,
+                "rgba": s.rgba,
+                "brand": s.brand,
+                "label_weight": s.label_weight,
+                "core_weight": s.core_weight,
+                "weight_used": s.weight_used,
+                "weight_locked": s.weight_locked,
+                "slicer_filament": s.slicer_filament,
+                "slicer_filament_name": s.slicer_filament_name,
+                "nozzle_temp_min": s.nozzle_temp_min,
+                "nozzle_temp_max": s.nozzle_temp_max,
+                "note": s.note,
+                "cost_per_kg": s.cost_per_kg,
+                "tag_uid": s.tag_uid,
+                "tray_uuid": s.tray_uuid,
+                "data_origin": s.data_origin,
+                "tag_type": s.tag_type,
+                "archived_at": str(s.archived_at) if s.archived_at else None,
+                "created_at": str(s.created_at) if s.created_at else None,
+            }
+            spool_list.append(spool_data)
+
+        files["spools/inventory.json"] = {
+            "version": "1.0",
+            "spools": spool_list,
+        }
+
+        # Collect usage history
+        usage_result = await db.execute(select(SpoolUsageHistory))
+        usages = usage_result.scalars().all()
+
+        if usages:
+            usage_list = []
+            for u in usages:
+                usage_list.append(
+                    {
+                        "id": u.id,
+                        "spool_id": u.spool_id,
+                        "printer_id": u.printer_id,
+                        "print_name": u.print_name,
+                        "archive_id": u.archive_id,
+                        "weight_used": u.weight_used,
+                        "percent_used": u.percent_used,
+                        "status": u.status,
+                        "cost": u.cost,
+                        "created_at": str(u.created_at) if u.created_at else None,
+                    }
+                )
+            files["spools/usage_history.json"] = {
+                "version": "1.0",
+                "usage_history": usage_list,
+            }
+
+        logger.info("Collected %d spools and %d usage records", len(spool_list), len(usages))
+
+    async def _collect_archives(self, db: AsyncSession, files: dict):
+        """Collect print archive metadata (no binary files)."""
+        result = await db.execute(select(PrintArchive))
+        archives = result.scalars().all()
+
+        if not archives:
+            return
+
+        archive_list = []
+        for a in archives:
+            archive_data = {
+                "id": a.id,
+                "printer_id": a.printer_id,
+                "project_id": a.project_id,
+                "filename": a.filename,
+                "file_size": a.file_size,
+                "content_hash": a.content_hash,
+                "print_name": a.print_name,
+                "print_time_seconds": a.print_time_seconds,
+                "filament_used_grams": a.filament_used_grams,
+                "filament_type": a.filament_type,
+                "filament_color": a.filament_color,
+                "layer_height": a.layer_height,
+                "total_layers": a.total_layers,
+                "nozzle_diameter": a.nozzle_diameter,
+                "bed_temperature": a.bed_temperature,
+                "nozzle_temperature": a.nozzle_temperature,
+                "sliced_for_model": a.sliced_for_model,
+                "status": a.status,
+                "started_at": str(a.started_at) if a.started_at else None,
+                "completed_at": str(a.completed_at) if a.completed_at else None,
+                "makerworld_url": a.makerworld_url,
+                "designer": a.designer,
+                "external_url": a.external_url,
+                "is_favorite": a.is_favorite,
+                "tags": a.tags,
+                "notes": a.notes,
+                "cost": a.cost,
+                "failure_reason": a.failure_reason,
+                "quantity": a.quantity,
+                "energy_kwh": a.energy_kwh,
+                "energy_cost": a.energy_cost,
+                "created_at": str(a.created_at) if a.created_at else None,
+            }
+            archive_list.append(archive_data)
+
+        files["archives/print_history.json"] = {
+            "version": "1.0",
+            "archives": archive_list,
+        }
+
+        logger.info("Collected %d print archives", len(archive_list))
+
     async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
         """Push files to GitHub using the GitHub API.
 

+ 293 - 0
backend/app/services/ldap_service.py

@@ -0,0 +1,293 @@
+"""LDAP authentication service for BamBuddy (#794).
+
+Supports:
+- LDAP bind authentication (simple bind with user's credentials)
+- StartTLS, LDAPS, and plaintext connections
+- User search with configurable filter
+- Group membership resolution for role mapping
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+
+from ldap3 import ALL, SUBTREE, Connection, Server, Tls
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class LDAPUserInfo:
+    """User information retrieved from LDAP after successful authentication."""
+
+    username: str
+    email: str | None
+    display_name: str | None
+    groups: list[str]  # List of group DNs the user belongs to
+
+
+@dataclass
+class LDAPConfig:
+    """LDAP configuration parsed from settings."""
+
+    server_url: str
+    bind_dn: str
+    bind_password: str
+    search_base: str
+    user_filter: str  # e.g. "(sAMAccountName={username})"
+    security: str  # "none", "starttls", "ldaps"
+    group_mapping: dict[str, str]  # LDAP group DN -> BamBuddy group name
+    auto_provision: bool
+    ca_cert_path: str  # Path to CA certificate file (empty = skip verification)
+    default_group: str  # Fallback BamBuddy group assigned when user has no mapped groups (empty = no fallback)
+
+
+def parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:
+    """Parse LDAP config from settings key-value pairs. Returns None if LDAP not enabled."""
+    if settings.get("ldap_enabled", "false").lower() != "true":
+        return None
+
+    server_url = settings.get("ldap_server_url", "").strip()
+    if not server_url:
+        return None
+
+    group_mapping_raw = settings.get("ldap_group_mapping", "")
+    try:
+        group_mapping = json.loads(group_mapping_raw) if group_mapping_raw else {}
+    except json.JSONDecodeError:
+        group_mapping = {}
+
+    return LDAPConfig(
+        server_url=server_url,
+        bind_dn=settings.get("ldap_bind_dn", "").strip(),
+        bind_password=settings.get("ldap_bind_password", ""),
+        search_base=settings.get("ldap_search_base", "").strip(),
+        user_filter=settings.get("ldap_user_filter", "(sAMAccountName={username})").strip(),
+        security=settings.get("ldap_security", "starttls").strip(),
+        group_mapping=group_mapping if isinstance(group_mapping, dict) else {},
+        auto_provision=settings.get("ldap_auto_provision", "false").lower() == "true",
+        ca_cert_path=settings.get("ldap_ca_cert_path", "").strip(),
+        default_group=settings.get("ldap_default_group", "").strip(),
+    )
+
+
+def _create_server(config: LDAPConfig) -> Server:
+    """Create an ldap3 Server instance from config.
+
+    Always uses TLS — either LDAPS (TLS from start) or StartTLS (upgrade after connect).
+    Plaintext LDAP is not supported.
+    """
+    import ssl
+
+    use_ssl = config.security == "ldaps" or config.server_url.startswith("ldaps://")
+
+    if config.ca_cert_path:
+        tls = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=config.ca_cert_path)
+    else:
+        tls = Tls(validate=ssl.CERT_NONE)
+
+    return Server(config.server_url, use_ssl=use_ssl, tls=tls, get_info=ALL, connect_timeout=10)
+
+
+def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) -> LDAPUserInfo | None:
+    """Authenticate a user via LDAP bind.
+
+    1. Bind with service account to search for the user DN
+    2. Attempt bind with the user's DN and provided password
+    3. On success, retrieve user attributes and group memberships
+
+    Returns LDAPUserInfo on success, None on failure.
+    """
+    if not password:
+        return None
+
+    server = _create_server(config)
+
+    # Step 1: Service account bind + user search
+    try:
+        service_conn = Connection(
+            server,
+            user=config.bind_dn,
+            password=config.bind_password,
+            auto_bind=False,
+            raise_exceptions=True,
+            read_only=True,
+        )
+        service_conn.open()
+        if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+            service_conn.start_tls()
+        service_conn.bind()
+    except Exception as e:
+        logger.warning("LDAP service account bind failed: %s", e)
+        return None
+
+    try:
+        # Search for the user
+        search_filter = config.user_filter.replace("{username}", _ldap_escape(username))
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=search_filter,
+            search_scope=SUBTREE,
+            attributes=["*"],
+        )
+
+        if not service_conn.entries:
+            logger.info("LDAP user not found: %s", username)
+            return None
+
+        user_entry = service_conn.entries[0]
+        user_dn = str(user_entry.entry_dn)
+
+        # Step 2: Bind as the user to verify password
+        try:
+            user_conn = Connection(
+                server,
+                user=user_dn,
+                password=password,
+                auto_bind=False,
+                raise_exceptions=True,
+                read_only=True,
+            )
+            user_conn.open()
+            if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+                user_conn.start_tls()
+            user_conn.bind()
+            user_conn.unbind()
+        except Exception as e:
+            logger.info("LDAP bind failed for user %s: %s", username, e)
+            return None
+
+        # Step 3: Extract user info
+        email = str(user_entry.mail) if hasattr(user_entry, "mail") and user_entry.mail else None
+        display_name = (
+            str(user_entry.displayName) if hasattr(user_entry, "displayName") and user_entry.displayName else None
+        )
+
+        # Collect groups from memberOf attribute (Active Directory / groupOfNames)
+        groups = (
+            [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
+        )
+
+        # Also search for POSIX groups (memberUid-based) using the service account
+        canonical_username = username
+        if hasattr(user_entry, "sAMAccountName") and user_entry.sAMAccountName:
+            canonical_username = str(user_entry.sAMAccountName)
+        elif hasattr(user_entry, "uid") and user_entry.uid:
+            canonical_username = str(user_entry.uid)
+
+        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=posix_filter,
+            search_scope=SUBTREE,
+            attributes=["cn"],
+        )
+        for entry in service_conn.entries:
+            groups.append(str(entry.entry_dn))
+
+        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+        # Standard Unix semantics treat this as full group membership, so we need
+        # to resolve it to a group DN alongside the memberUid results.
+        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+            primary_gid = str(user_entry.gidNumber)
+            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+            service_conn.search(
+                search_base=config.search_base,
+                search_filter=primary_filter,
+                search_scope=SUBTREE,
+                attributes=["cn"],
+            )
+            for entry in service_conn.entries:
+                groups.append(str(entry.entry_dn))
+
+        # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
+        # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
+        seen_lower: set[str] = set()
+        deduped_groups: list[str] = []
+        for g in groups:
+            key = g.lower()
+            if key not in seen_lower:
+                seen_lower.add(key)
+                deduped_groups.append(g)
+        groups = deduped_groups
+
+        logger.info(
+            "LDAP authentication successful for user: %s (DN: %s, groups: %d)", canonical_username, user_dn, len(groups)
+        )
+
+        return LDAPUserInfo(
+            username=canonical_username,
+            email=email,
+            display_name=display_name,
+            groups=groups,
+        )
+    finally:
+        service_conn.unbind()
+
+
+def resolve_group_mapping(ldap_groups: list[str], group_mapping: dict[str, str]) -> list[str]:
+    """Map LDAP group DNs to BamBuddy group names.
+
+    Returns list of BamBuddy group names that the user should be added to.
+    Comparison is case-insensitive on the LDAP group DN.
+    """
+    if not group_mapping:
+        return []
+
+    # Build case-insensitive lookup
+    mapping_lower = {k.lower(): v for k, v in group_mapping.items()}
+    result = []
+    for ldap_group in ldap_groups:
+        bambuddy_group = mapping_lower.get(ldap_group.lower())
+        if bambuddy_group:
+            result.append(bambuddy_group)
+    return result
+
+
+def test_ldap_connection(config: LDAPConfig) -> tuple[bool, str]:
+    """Test LDAP connection and service account bind.
+
+    Returns (success, message).
+    """
+    try:
+        server = _create_server(config)
+        conn = Connection(
+            server,
+            user=config.bind_dn,
+            password=config.bind_password,
+            auto_bind=False,
+            raise_exceptions=True,
+            read_only=True,
+        )
+        conn.open()
+        if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+            conn.start_tls()
+        conn.bind()
+
+        # Try a search to verify search base
+        conn.search(
+            search_base=config.search_base,
+            search_filter="(objectClass=*)",
+            search_scope=SUBTREE,
+            size_limit=1,
+        )
+        conn.unbind()
+        return True, "LDAP connection successful"
+    except Exception as e:
+        return False, f"LDAP connection failed: {e}"
+
+
+def _ldap_escape(value: str) -> str:
+    """Escape special characters in LDAP search filter values (RFC 4515)."""
+    replacements = {
+        "\\": "\\5c",
+        "*": "\\2a",
+        "(": "\\28",
+        ")": "\\29",
+        "\x00": "\\00",
+    }
+    for char, escaped in replacements.items():
+        value = value.replace(char, escaped)
+    return value

+ 270 - 0
backend/app/services/local_backup.py

@@ -0,0 +1,270 @@
+"""Scheduled local backup service.
+
+Creates ZIP snapshots of the full Bambuddy data (database + data directories)
+on a configurable schedule with retention management.
+"""
+
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from sqlalchemy import select
+
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+SCHEDULE_INTERVALS = {
+    "hourly": 3600,
+    "daily": 86400,
+    "weekly": 604800,
+}
+
+
+def _default_backup_dir() -> Path:
+    return app_settings.base_dir / "backups"
+
+
+class LocalBackupService:
+    """Manages scheduled local backup snapshots with retention."""
+
+    def __init__(self):
+        self._scheduler_task: asyncio.Task | None = None
+        self._check_interval = 60
+        self._running: bool = False
+        self._last_backup_at: str | None = None
+        self._last_status: str | None = None
+        self._last_message: str | None = None
+        self._next_run: datetime | None = None
+
+    async def start_scheduler(self):
+        """Start the background scheduler loop."""
+        if self._scheduler_task is not None:
+            return
+        logger.info("Starting local backup scheduler")
+        # Seed next_run from settings so the first check has a target
+        await self._seed_next_run()
+        self._scheduler_task = asyncio.create_task(self._scheduler_loop())
+
+    def stop_scheduler(self):
+        """Stop the scheduler."""
+        if self._scheduler_task:
+            self._scheduler_task.cancel()
+            self._scheduler_task = None
+            logger.info("Stopped local backup scheduler")
+
+    async def _scheduler_loop(self):
+        """Main scheduler loop — checks for due backups every minute."""
+        while True:
+            try:
+                await asyncio.sleep(self._check_interval)
+                await self._check_scheduled_backup()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.error("Error in local backup scheduler: %s", e)
+                await asyncio.sleep(60)
+
+    async def _seed_next_run(self):
+        """Load settings and calculate initial next_run."""
+        try:
+            settings = await self._load_settings()
+            if settings.get("enabled"):
+                self._next_run = self._calculate_next_run(
+                    settings.get("schedule", "daily"),
+                    settings.get("time", "03:00"),
+                )
+        except Exception as e:
+            logger.debug("Could not seed local backup next_run: %s", e)
+
+    async def _load_settings(self) -> dict:
+        """Read local backup settings from the DB."""
+        async with async_session() as db:
+            keys = [
+                "local_backup_enabled",
+                "local_backup_schedule",
+                "local_backup_time",
+                "local_backup_retention",
+                "local_backup_path",
+            ]
+            result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
+            rows = {r.key: r.value for r in result.scalars().all()}
+        return {
+            "enabled": rows.get("local_backup_enabled", "false").lower() == "true",
+            "schedule": rows.get("local_backup_schedule", "daily"),
+            "time": rows.get("local_backup_time", "03:00"),
+            "retention": int(rows.get("local_backup_retention", "5")),
+            "path": rows.get("local_backup_path", ""),
+        }
+
+    async def _check_scheduled_backup(self):
+        """Check if a scheduled backup is due and run it."""
+        settings = await self._load_settings()
+        if not settings["enabled"]:
+            self._next_run = None
+            return
+
+        now = datetime.now(timezone.utc)
+
+        # If no next_run set, schedule one
+        if self._next_run is None:
+            self._next_run = self._calculate_next_run(settings["schedule"], settings["time"])
+            return
+
+        if self._next_run <= now:
+            logger.info("Running scheduled local backup")
+            await self.run_backup(settings)
+            self._next_run = self._calculate_next_run(settings["schedule"], settings["time"])
+
+    def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime:
+        """Calculate the next scheduled run time.
+
+        For hourly: next full hour.
+        For daily/weekly: next occurrence of the configured time (HH:MM).
+        """
+        now = datetime.now(timezone.utc)
+
+        if schedule_type == "hourly":
+            # Next full hour
+            next_run = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
+            return next_run
+
+        # Parse HH:MM time
+        try:
+            parts = time_str.strip().split(":")
+            hour = int(parts[0])
+            minute = int(parts[1]) if len(parts) > 1 else 0
+        except (ValueError, IndexError):
+            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)
+
+        if schedule_type == "weekly":
+            next_run += timedelta(weeks=1)
+
+        return next_run
+
+    def _resolve_backup_dir(self, path_setting: str) -> Path:
+        """Resolve the backup output directory from settings."""
+        if path_setting.strip():
+            return Path(path_setting.strip())
+        return _default_backup_dir()
+
+    async def run_backup(self, settings: dict | None = None) -> dict:
+        """Run a backup now. Returns {success, message, filename}."""
+        if self._running:
+            return {"success": False, "message": "Backup already in progress"}
+
+        self._running = True
+        try:
+            if settings is None:
+                settings = await self._load_settings()
+
+            backup_dir = self._resolve_backup_dir(settings["path"])
+            backup_dir.mkdir(parents=True, exist_ok=True)
+
+            from backend.app.api.routes.settings import create_backup_zip
+
+            zip_path, filename = await create_backup_zip(output_path=backup_dir)
+
+            # Prune old backups
+            retention = max(1, settings["retention"])
+            self._prune_backups(backup_dir, retention)
+
+            self._last_backup_at = datetime.now(timezone.utc).isoformat()
+            self._last_status = "success"
+            self._last_message = filename
+            logger.info("Local backup created: %s", zip_path)
+            return {"success": True, "message": "Backup created", "filename": filename}
+
+        except Exception as e:
+            self._last_backup_at = datetime.now(timezone.utc).isoformat()
+            self._last_status = "failed"
+            self._last_message = str(e)
+            logger.error("Local backup failed: %s", e, exc_info=True)
+            return {"success": False, "message": f"Backup failed: {e}"}
+        finally:
+            self._running = False
+
+    def _prune_backups(self, backup_dir: Path, retention: int):
+        """Delete oldest backups exceeding the retention count."""
+        backups = sorted(
+            backup_dir.glob("bambuddy-backup-*.zip"),
+            key=lambda p: p.stat().st_mtime,
+            reverse=True,
+        )
+        for old_backup in backups[retention:]:
+            try:
+                old_backup.unlink()
+                logger.info("Pruned old backup: %s", old_backup.name)
+            except OSError as e:
+                logger.warning("Could not delete old backup %s: %s", old_backup.name, e)
+
+    def get_status(self) -> dict:
+        """Return current scheduler status."""
+        return {
+            "is_running": self._running,
+            "last_backup_at": self._last_backup_at,
+            "last_status": self._last_status,
+            "last_message": self._last_message,
+            "next_run": self._next_run.isoformat() if self._next_run else None,
+        }
+
+    def resolve_backup_file(self, path_setting: str, filename: str) -> Path | None:
+        """Resolve a backup filename to a full path, with safety checks."""
+        if "/" in filename or "\\" in filename or ".." in filename:
+            return None
+        if not filename.startswith("bambuddy-backup-") or not filename.endswith(".zip"):
+            return None
+        backup_dir = self._resolve_backup_dir(path_setting)
+        target = backup_dir / filename
+        if not target.exists():
+            return None
+        return target
+
+    def list_backups(self, path_setting: str) -> list[dict]:
+        """List backup ZIP files in the backup directory."""
+        backup_dir = self._resolve_backup_dir(path_setting)
+        if not backup_dir.exists():
+            return []
+
+        backups = []
+        for f in sorted(backup_dir.glob("bambuddy-backup-*.zip"), key=lambda p: p.stat().st_mtime, reverse=True):
+            stat = f.stat()
+            backups.append(
+                {
+                    "filename": f.name,
+                    "size": stat.st_size,
+                    "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
+                }
+            )
+        return backups
+
+    def delete_backup(self, path_setting: str, filename: str) -> dict:
+        """Delete a specific backup file. Returns {success, message}."""
+        # Path traversal protection
+        if "/" in filename or "\\" in filename or ".." in filename:
+            return {"success": False, "message": "Invalid filename"}
+
+        backup_dir = self._resolve_backup_dir(path_setting)
+        target = backup_dir / filename
+
+        if not target.exists():
+            return {"success": False, "message": "Backup not found"}
+        if not target.name.startswith("bambuddy-backup-") or not target.name.endswith(".zip"):
+            return {"success": False, "message": "Invalid backup file"}
+
+        try:
+            target.unlink()
+            return {"success": True, "message": "Backup deleted"}
+        except OSError as e:
+            return {"success": False, "message": f"Could not delete: {e}"}
+
+
+local_backup_service = LocalBackupService()

+ 146 - 25
backend/app/services/notification_service.py

@@ -430,12 +430,18 @@ class NotificationService:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
     async def _send_webhook(
-        self, config: dict, title: str, message: str, image_data: bytes | None = None
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        image_data: bytes | None = None,
+        event_type: str | None = None,
+        variables: dict | None = None,
     ) -> tuple[bool, str]:
         """Send notification via generic webhook (POST JSON).
 
         Supports two payload formats:
-        - generic: Custom field names with timestamp/source metadata
+        - generic: Custom field names with timestamp/source metadata + structured event data
         - slack: Slack/Mattermost compatible format (just {"text": "..."})
         """
         webhook_url = config.get("webhook_url", "").strip()
@@ -460,6 +466,15 @@ class NotificationService:
                 "source": "Bambuddy",
             }
 
+        # For generic format, include structured event data for automation tools
+        if payload_format != "slack":
+            if event_type:
+                data["event"] = event_type
+            if variables:
+                for key, value in variables.items():
+                    if key not in data:  # Don't overwrite title/message/timestamp/source
+                        data[key] = value
+
         # Attach base64-encoded image when available (generic format only)
         if image_data and payload_format != "slack":
             import base64
@@ -573,6 +588,8 @@ class NotificationService:
         message: str,
         db: AsyncSession | None = None,
         image_data: bytes | None = None,
+        event_type: str | None = None,
+        variables: dict | None = None,
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         # Check quiet hours
@@ -596,7 +613,9 @@ class NotificationService:
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
-                return await self._send_webhook(config, title, message, image_data=image_data)
+                return await self._send_webhook(
+                    config, title, message, image_data=image_data, event_type=event_type, variables=variables
+                )
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
             else:
@@ -681,6 +700,7 @@ class NotificationService:
         printer_name: str | None = None,
         force_immediate: bool = False,
         image_data: bytes | None = None,
+        variables: dict | None = None,
     ):
         """Send notification to multiple providers and log the results.
 
@@ -690,7 +710,9 @@ class NotificationService:
         for provider in providers:
             try:
                 # Always send notification immediately
-                success, error = await self._send_to_provider(provider, title, message, db, image_data=image_data)
+                success, error = await self._send_to_provider(
+                    provider, title, message, db, image_data=image_data, event_type=event_type, variables=variables
+                )
 
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
@@ -807,7 +829,15 @@ class NotificationService:
         logger.info("Found %s providers for print_start: %s", len(providers), [p.name for p in providers])
         title, message = await self._build_message_from_template(db, "print_start", variables)
         await self._send_to_providers(
-            providers, title, message, db, "print_start", printer_id, printer_name, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "print_start",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_print_complete(
@@ -901,7 +931,15 @@ class NotificationService:
         logger.info("Found %s providers for %s: %s", len(providers), event_field, [p.name for p in providers])
         title, message = await self._build_message_from_template(db, event_type, variables)
         await self._send_to_providers(
-            providers, title, message, db, event_type, printer_id, printer_name, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            event_type,
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_print_progress(
@@ -931,7 +969,15 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
         await self._send_to_providers(
-            providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "print_progress",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_print_missing_spool_assignment(
@@ -973,6 +1019,7 @@ class NotificationService:
             printer_id,
             printer_name,
             force_immediate=True,
+            variables=variables,
         )
 
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
@@ -984,7 +1031,9 @@ class NotificationService:
         variables = {"printer": printer_name}
 
         title, message = await self._build_message_from_template(db, "printer_offline", variables)
-        await self._send_to_providers(providers, title, message, db, "printer_offline", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "printer_offline", printer_id, printer_name, variables=variables
+        )
 
     async def on_printer_error(
         self,
@@ -1008,7 +1057,15 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "printer_error", variables)
         await self._send_to_providers(
-            providers, title, message, db, "printer_error", printer_id, printer_name, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "printer_error",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_plate_not_empty(
@@ -1030,7 +1087,15 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "plate_not_empty", variables)
         await self._send_to_providers(
-            providers, title, message, db, "plate_not_empty", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "plate_not_empty",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_filament_low(
@@ -1055,7 +1120,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "filament_low", variables)
-        await self._send_to_providers(providers, title, message, db, "filament_low", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "filament_low", printer_id, printer_name, variables=variables
+        )
 
     async def on_maintenance_due(
         self,
@@ -1087,7 +1154,9 @@ class NotificationService:
 
         logger.info("Found %s providers for maintenance_due: %s", len(providers), [p.name for p in providers])
         title, message = await self._build_message_from_template(db, "maintenance_due", variables)
-        await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "maintenance_due", printer_id, printer_name, variables=variables
+        )
 
     async def on_ams_humidity_high(
         self,
@@ -1113,7 +1182,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_humidity_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_ams_temperature_high(
@@ -1140,7 +1217,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_temperature_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_ams_ht_humidity_high(
@@ -1168,7 +1253,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_ht_humidity_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_ams_ht_temperature_high(
@@ -1196,7 +1289,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_ht_temperature_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_bed_cooled(
@@ -1221,7 +1322,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "bed_cooled", variables)
-        await self._send_to_providers(providers, title, message, db, "bed_cooled", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
+        )
 
     async def on_first_layer_complete(
         self,
@@ -1245,7 +1348,15 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
         await self._send_to_providers(
-            providers, title, message, db, "first_layer_complete", printer_id, printer_name, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "first_layer_complete",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     def clear_template_cache(self):
@@ -1388,7 +1499,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_added", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_added", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_assigned(
         self,
@@ -1410,7 +1523,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_assigned", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_started(
         self,
@@ -1435,7 +1550,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_started", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_started", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_waiting(
         self,
@@ -1456,7 +1573,7 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_waiting")
+        await self._send_to_providers(providers, title, message, db, "queue_job_waiting", variables=variables)
 
     async def on_queue_job_skipped(
         self,
@@ -1478,7 +1595,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_skipped", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_failed(
         self,
@@ -1500,7 +1619,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_failed", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_completed(
         self,
@@ -1517,7 +1638,7 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_completed", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_completed")
+        await self._send_to_providers(providers, title, message, db, "queue_completed", variables=variables)
 
     async def _queue_for_digest(
         self,

+ 83 - 0
backend/app/services/obico_actions.py

@@ -0,0 +1,83 @@
+"""Action dispatch for Obico failure detection.
+
+Separated from the detection loop so actions can be unit-tested and swapped.
+"""
+
+import logging
+
+from sqlalchemy import select
+
+from backend.app.core.database import async_session
+from backend.app.models.printer import Printer
+
+logger = logging.getLogger(__name__)
+
+
+async def execute_action(printer_id: int, action: str, task_name: str, score: float) -> None:
+    """Run the configured action for a detected print failure.
+
+    action: 'notify' | 'pause' | 'pause_and_off'
+    """
+    printer_name = await _get_printer_name(printer_id)
+
+    if action in ("pause", "pause_and_off"):
+        _pause_print(printer_id)
+
+    if action == "pause_and_off":
+        await _turn_off_linked_plugs(printer_id)
+
+    await _notify(printer_id, printer_name, task_name, score, action)
+
+
+async def _get_printer_name(printer_id: int) -> str:
+    async with async_session() as db:
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
+    return printer.name if printer else f"Printer {printer_id}"
+
+
+def _pause_print(printer_id: int) -> None:
+    from backend.app.services.printer_manager import printer_manager
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        logger.warning("Obico pause: no MQTT client for printer %s", printer_id)
+        return
+    if not client.pause_print():
+        logger.warning("Obico pause: pause_print() returned False for printer %s", printer_id)
+
+
+async def _turn_off_linked_plugs(printer_id: int) -> None:
+    from backend.app.services.smart_plug_manager import smart_plug_manager
+
+    async with async_session() as db:
+        plugs = await smart_plug_manager._get_plugs_for_printer(printer_id, db)
+        for plug in plugs:
+            if not plug.enabled:
+                continue
+            try:
+                service = await smart_plug_manager.get_service_for_plug(plug, db)
+                await service.turn_off(plug)
+                logger.info("Obico action: turned off plug %s for printer %s", plug.name, printer_id)
+            except Exception as e:
+                logger.error("Obico action: failed to turn off plug %s: %s", plug.name, e)
+
+
+async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
+    from backend.app.services.notification_service import notification_service
+
+    detail = (
+        f"Possible print failure detected on '{task_name or 'current job'}' "
+        f"(confidence {score:.2f}). Action taken: {action}."
+    )
+    async with async_session() as db:
+        try:
+            await notification_service.on_printer_error(
+                printer_id=printer_id,
+                printer_name=printer_name,
+                error_type="ai_failure_detection",
+                db=db,
+                error_detail=detail,
+            )
+        except Exception as e:
+            logger.error("Obico notify failed for printer %s: %s", printer_id, e)

+ 327 - 0
backend/app/services/obico_detection.py

@@ -0,0 +1,327 @@
+"""Obico AI print-failure detection service.
+
+Polls a self-hosted Obico ML API with snapshots from each monitored printer
+while a print is running, smooths scores over time, and dispatches a configured
+action (notify / pause / pause_and_off) when a sustained failure is detected.
+
+See `obico_smoothing.py` for the per-print EWM + rolling-mean math.
+"""
+
+import asyncio
+import json
+import logging
+import secrets
+import time
+from collections import deque
+from datetime import datetime, timezone
+
+import httpx
+from sqlalchemy import select
+
+from backend.app.core.database import async_session
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.services.obico_smoothing import (
+    PrintState,
+    classify,
+    score_from_detections,
+    thresholds,
+)
+
+logger = logging.getLogger(__name__)
+
+HISTORY_MAX = 50
+HEALTH_TIMEOUT = 5.0
+DETECTION_TIMEOUT = 30.0
+SNAPSHOT_CAPTURE_TIMEOUT = 20  # seconds — we control this, not Obico
+FRAME_CACHE_TTL = 30.0  # seconds — Obico usually fetches within 1s of receiving the URL
+
+# Module-level one-shot frame cache. Obico's ML API is GET-only (/p/?img=URL) and
+# fetches the URL itself with a hardcoded 5s read timeout. We capture locally first,
+# stash the JPEG under a random nonce, and hand Obico a URL that serves the cached
+# bytes instantly — so the 5s ceiling never races RTSP keyframe wait.
+_frame_cache: dict[str, tuple[bytes, float]] = {}
+_frame_cache_lock = asyncio.Lock()
+
+
+def _prune_frame_cache() -> None:
+    """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
+    now = time.monotonic()
+    expired = [k for k, (_b, ts) in _frame_cache.items() if now - ts > FRAME_CACHE_TTL]
+    for k in expired:
+        _frame_cache.pop(k, None)
+
+
+async def stash_frame(data: bytes) -> str:
+    """Store JPEG bytes and return a URL-safe nonce that serves them once."""
+    nonce = secrets.token_urlsafe(32)
+    async with _frame_cache_lock:
+        _prune_frame_cache()
+        _frame_cache[nonce] = (data, time.monotonic())
+    return nonce
+
+
+async def pop_frame(nonce: str) -> bytes | None:
+    """Return and remove a cached frame by nonce; None if missing or expired."""
+    async with _frame_cache_lock:
+        _prune_frame_cache()
+        entry = _frame_cache.pop(nonce, None)
+    if entry is None:
+        return None
+    data, ts = entry
+    if time.monotonic() - ts > FRAME_CACHE_TTL:
+        return None
+    return data
+
+
+class ObicoDetectionService:
+    """Singleton service that polls the ML API and acts on sustained failures."""
+
+    def __init__(self):
+        self._task: asyncio.Task | None = None
+        # printer_id -> PrintState (reset when a new print starts)
+        self._states: dict[int, PrintState] = {}
+        # printer_id -> task_name active when state was created (used to detect new prints)
+        self._state_keys: dict[int, str] = {}
+        # printer_id -> last classification ("safe"/"warning"/"failure")
+        self._last_class: dict[int, str] = {}
+        # printer_id -> whether an action has already been fired for the current print
+        self._action_fired: dict[int, bool] = {}
+        # Global detection event log (most-recent-first)
+        self._history: deque = deque(maxlen=HISTORY_MAX)
+        self._last_error: str | None = None
+
+    # ---- lifecycle ----
+
+    async def start(self):
+        if self._task is not None:
+            return
+        logger.info("Starting Obico detection service")
+        self._task = asyncio.create_task(self._loop())
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Stopped Obico detection service")
+
+    # ---- settings ----
+
+    async def _load_settings(self) -> dict:
+        keys = [
+            "obico_enabled",
+            "obico_ml_url",
+            "obico_sensitivity",
+            "obico_action",
+            "obico_poll_interval",
+            "obico_enabled_printers",
+            "external_url",
+        ]
+        async with async_session() as db:
+            result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
+            rows = {r.key: r.value for r in result.scalars().all()}
+
+        enabled_printers_raw = rows.get("obico_enabled_printers", "")
+        if enabled_printers_raw:
+            try:
+                enabled_printers = set(json.loads(enabled_printers_raw))
+            except json.JSONDecodeError:
+                enabled_printers = set()
+        else:
+            enabled_printers = None  # None = all printers
+
+        return {
+            "enabled": rows.get("obico_enabled", "false").lower() == "true",
+            "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
+            "sensitivity": rows.get("obico_sensitivity", "medium"),
+            "action": rows.get("obico_action", "notify"),
+            "poll_interval": int(rows.get("obico_poll_interval", "10")),
+            "enabled_printers": enabled_printers,
+            "external_url": (rows.get("external_url") or "").rstrip("/"),
+        }
+
+    # ---- main loop ----
+
+    async def _loop(self):
+        """Poll active printers while enabled. Adjusts interval from settings each cycle."""
+        while True:
+            try:
+                settings = await self._load_settings()
+                interval = max(5, settings.get("poll_interval", 10))
+                if not settings["enabled"] or not settings["ml_url"]:
+                    await asyncio.sleep(interval)
+                    continue
+
+                await self._poll_once(settings)
+                await asyncio.sleep(interval)
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.error("Obico detection loop error: %s", e)
+                self._last_error = str(e) or type(e).__name__
+                await asyncio.sleep(30)
+
+    async def _poll_once(self, settings: dict):
+        # Late import to avoid cycles at module load time
+        from backend.app.services.printer_manager import printer_manager
+
+        statuses = printer_manager.get_all_statuses()
+        for printer_id, status in list(statuses.items()):
+            if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
+                continue
+            if not printer_manager.is_connected(printer_id):
+                continue
+            if not status or getattr(status, "state", None) != "RUNNING":
+                # Reset state when not printing so the next print starts fresh
+                self._states.pop(printer_id, None)
+                self._state_keys.pop(printer_id, None)
+                self._action_fired.pop(printer_id, None)
+                continue
+
+            await self._check_printer(printer_id, status, settings)
+
+    async def _capture_frame(self, printer_id: int) -> bytes | None:
+        """Capture one JPEG frame from the printer camera. Returns None on failure."""
+        # Late import to avoid cycles at module load time
+        from backend.app.services.camera import capture_camera_frame_bytes
+        from backend.app.services.external_camera import capture_frame as capture_external_frame
+
+        async with async_session() as db:
+            printer = await db.get(Printer, printer_id)
+        if printer is None:
+            self._last_error = f"Printer {printer_id} not found"
+            return None
+
+        if printer.external_camera_enabled and printer.external_camera_url:
+            return await capture_external_frame(
+                printer.external_camera_url,
+                printer.external_camera_type,
+                timeout=SNAPSHOT_CAPTURE_TIMEOUT,
+            )
+        return await capture_camera_frame_bytes(
+            ip_address=printer.ip_address,
+            access_code=printer.access_code,
+            model=printer.model,
+            timeout=SNAPSHOT_CAPTURE_TIMEOUT,
+        )
+
+    async def _check_printer(self, printer_id: int, status, settings: dict):
+        task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
+        key = f"{task_name}"
+        if self._state_keys.get(printer_id) != key:
+            self._states[printer_id] = PrintState()
+            self._state_keys[printer_id] = key
+            self._action_fired[printer_id] = False
+
+        # Capture locally first, then hand Obico a nonce URL that returns the
+        # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
+        # hardcoded 5s read timeout which would otherwise race our /camera/snapshot
+        # keyframe wait.
+        frame = await self._capture_frame(printer_id)
+        if not frame:
+            self._last_error = f"Failed to capture snapshot for printer {printer_id}"
+            logger.warning(self._last_error)
+            return
+
+        external_url = settings.get("external_url") or ""
+        if not external_url:
+            self._last_error = (
+                "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
+                "Set Settings → General → External URL."
+            )
+            logger.warning(self._last_error)
+            return
+
+        nonce = await stash_frame(frame)
+        snapshot_url = f"{external_url}/api/v1/obico/cached-frame/{nonce}"
+        ml_url = f"{settings['ml_url']}/p/"
+
+        try:
+            async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
+                resp = await client.get(ml_url, params={"img": snapshot_url})
+                resp.raise_for_status()
+                payload = resp.json()
+        except Exception as e:
+            detail = str(e) or type(e).__name__
+            self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
+            logger.warning(self._last_error)
+            return
+
+        detections = payload.get("detections", []) if isinstance(payload, dict) else []
+        current_p = score_from_detections(detections)
+        state = self._states[printer_id]
+        score = state.update(current_p)
+        verdict = classify(score, settings["sensitivity"])
+        self._last_class[printer_id] = verdict
+
+        # Log every non-safe sample — safe samples would flood history
+        if verdict != "safe" or detections:
+            self._history.appendleft(
+                {
+                    "printer_id": printer_id,
+                    "task_name": task_name,
+                    "timestamp": datetime.now(timezone.utc).isoformat(),
+                    "current_p": round(current_p, 4),
+                    "score": round(score, 4),
+                    "class": verdict,
+                    "detections": len(detections),
+                }
+            )
+
+        if verdict == "failure" and not self._action_fired.get(printer_id):
+            self._action_fired[printer_id] = True
+            await self._dispatch_action(printer_id, settings["action"], task_name, score)
+
+    async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):
+        from backend.app.services.obico_actions import execute_action
+
+        logger.warning(
+            "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s",
+            printer_id,
+            task_name,
+            score,
+            action,
+        )
+        try:
+            await execute_action(printer_id, action, task_name, score)
+        except Exception as e:
+            self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
+            logger.error(self._last_error)
+
+    # ---- queries ----
+
+    def get_status(self) -> dict:
+        low, high = thresholds("medium")
+        return {
+            "is_running": self._task is not None and not self._task.done(),
+            "last_error": self._last_error,
+            "per_printer": {
+                pid: {
+                    "class": self._last_class.get(pid, "safe"),
+                    "frame_count": state.frame_count,
+                    "score": round(state.ewm_mean, 4),
+                }
+                for pid, state in self._states.items()
+            },
+            "thresholds": {"low": low, "high": high},
+            "history": list(self._history),
+        }
+
+    async def test_connection(self, url: str) -> dict:
+        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
+        target = f"{url.rstrip('/')}/hc/"
+        try:
+            async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
+                resp = await client.get(target)
+            body = resp.text.strip()
+            return {
+                "ok": resp.status_code == 200 and body.lower() == "ok",
+                "status_code": resp.status_code,
+                "body": body,
+                "error": None,
+            }
+        except Exception as e:
+            return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
+
+
+obico_detection_service = ObicoDetectionService()

+ 105 - 0
backend/app/services/obico_smoothing.py

@@ -0,0 +1,105 @@
+"""Temporal smoothing for Obico ML detection scores.
+
+Ports Obico's failure-detection math:
+- per-frame `current_p` = sum of detection confidences
+- `ewm_mean` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)
+- `rolling_mean_short` = ~310 frames of recent activity (≈52 min at 10s/frame)
+- `rolling_mean_long`  = ~7200 frames of long-term baseline noise
+- First `WARMUP_FRAMES` frames always report "safe" while the state settles
+- Final score = max(ewm_mean, rolling_mean_short - rolling_mean_long)
+- Thresholds: LOW < score < HIGH is "warning", >= HIGH is "failure"
+"""
+
+import math
+from collections import deque
+from dataclasses import dataclass, field
+
+EWM_SPAN = 12
+EWM_ALPHA = 2.0 / (EWM_SPAN + 1)
+ROLLING_SHORT = 310
+ROLLING_LONG = 7200
+WARMUP_FRAMES = 30
+
+# Base thresholds; sensitivity multipliers adjust them
+BASE_LOW = 0.38
+BASE_HIGH = 0.78
+
+SENSITIVITY_MULT = {
+    "low": 1.25,  # harder to trigger — higher thresholds
+    "medium": 1.0,
+    "high": 0.75,  # easier to trigger — lower thresholds
+}
+
+
+def thresholds(sensitivity: str) -> tuple[float, float]:
+    mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
+    return BASE_LOW * mult, BASE_HIGH * mult
+
+
+@dataclass
+class PrintState:
+    """Per-print smoothing state. Reset when a new print starts."""
+
+    frame_count: int = 0
+    ewm_mean: float = 0.0
+    short_sum: float = 0.0
+    long_sum: float = 0.0
+    short_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_SHORT))
+    long_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_LONG))
+
+    def update(self, current_p: float) -> float:
+        """Feed a new per-frame score and return the smoothed score.
+
+        Returns 0.0 during warmup so early noise doesn't trigger actions.
+        """
+        self.frame_count += 1
+
+        if self.frame_count == 1:
+            self.ewm_mean = current_p
+        else:
+            self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean
+
+        if len(self.short_buf) == self.short_buf.maxlen:
+            self.short_sum -= self.short_buf[0]
+        self.short_buf.append(current_p)
+        self.short_sum += current_p
+
+        if len(self.long_buf) == self.long_buf.maxlen:
+            self.long_sum -= self.long_buf[0]
+        self.long_buf.append(current_p)
+        self.long_sum += current_p
+
+        if self.frame_count <= WARMUP_FRAMES:
+            return 0.0
+
+        short_mean = self.short_sum / len(self.short_buf)
+        long_mean = self.long_sum / len(self.long_buf)
+        return max(self.ewm_mean, short_mean - long_mean)
+
+
+def classify(score: float, sensitivity: str) -> str:
+    """Return 'safe', 'warning', or 'failure' for a smoothed score."""
+    low, high = thresholds(sensitivity)
+    if score >= high:
+        return "failure"
+    if score >= low:
+        return "warning"
+    return "safe"
+
+
+def score_from_detections(detections: list) -> float:
+    """Sum confidences from the ML API `detections` array.
+
+    Each detection is `[label, confidence, [x, y, w, h]]`. We only care about
+    the confidence column — label is always "failure" for the single-class model.
+    """
+    total = 0.0
+    for det in detections or []:
+        try:
+            value = float(det[1])
+        except (IndexError, TypeError, ValueError):
+            continue
+        if math.isnan(value) or math.isinf(value):
+            continue
+        total += value
+    return total

+ 5 - 2
backend/app/services/plate_detection.py

@@ -8,6 +8,7 @@ a reference image of the empty plate.
 from __future__ import annotations
 
 import logging
+import os
 from pathlib import Path
 
 logger = logging.getLogger(__name__)
@@ -632,8 +633,10 @@ async def capture_camera_image(
 
             from backend.app.services.camera import capture_camera_frame
 
-            with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
-                tmp_path = Path(tmp.name)
+            fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
+            os.close(fd)
+            tmp_path = Path(tmp_name)
+            tmp_path.chmod(0o600)
 
             try:
                 success = await capture_camera_frame(ip_address, access_code, model, tmp_path, timeout=10)

+ 248 - 42
backend/app/services/print_scheduler.py

@@ -93,17 +93,39 @@ class PrintScheduler:
     async def check_queue(self):
         """Check for prints ready to start."""
         async with async_session() as db:
-            # Get all pending items, ordered by printer and position
-            result = await db.execute(
-                select(PrintQueueItem)
-                .where(PrintQueueItem.status == "pending")
-                .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
-            )
+            # Check if shortest-job-first scheduling is enabled
+            sjf_enabled = await self._get_bool_setting(db, "queue_shortest_first")
+
+            # Get all pending items, ordered by printer and position (or SJF order)
+            if sjf_enabled:
+                # SJF: group by printer (and target_model for model-based jobs),
+                # then items already jumped get top priority (starvation guard),
+                # then sort by print_time ascending. Items with no print time go last.
+                result = await db.execute(
+                    select(PrintQueueItem)
+                    .where(PrintQueueItem.status == "pending")
+                    .order_by(
+                        PrintQueueItem.printer_id,
+                        PrintQueueItem.target_model,
+                        PrintQueueItem.been_jumped.desc(),
+                        PrintQueueItem.print_time_seconds.asc().nullslast(),
+                        PrintQueueItem.position,
+                    )
+                )
+            else:
+                result = await db.execute(
+                    select(PrintQueueItem)
+                    .where(PrintQueueItem.status == "pending")
+                    .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
+                )
             items = list(result.scalars().all())
 
+            # Read plate-clear setting once per queue check
+            require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=True)
+
             if not items:
                 # No pending items — still check auto-drying on idle printers
-                await self._check_auto_drying(db, [], set())
+                await self._check_auto_drying(db, [], set(), require_plate_clear=require_plate_clear)
                 return
 
             logger.info(
@@ -139,18 +161,30 @@ class PrintScheduler:
                         continue
 
                     # Check if printer is idle
-                    printer_idle = self._is_printer_idle(item.printer_id)
+                    printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                     printer_connected = printer_manager.is_connected(item.printer_id)
 
                     # If printer not connected, try to power on via smart plug
                     if not printer_connected:
-                        plug = await self._get_smart_plug(db, item.printer_id)
-                        if plug and plug.auto_on and plug.enabled:
-                            logger.info("Printer %s offline, attempting to power on via smart plug", item.printer_id)
-                            powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
+                        plugs = await self._get_smart_plugs(db, item.printer_id)
+                        auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
+                        if auto_on_plugs:
+                            logger.info("Printer %s offline, attempting to power on via smart plug(s)", item.printer_id)
+                            # Power on using the first auto_on plug (the printer power plug)
+                            powered_on = await self._power_on_and_wait(auto_on_plugs[0], item.printer_id, db)
                             if powered_on:
+                                # Also turn on any remaining auto_on plugs (e.g., filter)
+                                for extra_plug in auto_on_plugs[1:]:
+                                    try:
+                                        service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
+                                        await service.turn_on(extra_plug)
+                                        logger.info(
+                                            "Also powered on plug '%s' for printer %s", extra_plug.name, item.printer_id
+                                        )
+                                    except Exception as e:
+                                        logger.warning("Failed to power on extra plug '%s': %s", extra_plug.name, e)
                                 printer_connected = True
-                                printer_idle = self._is_printer_idle(item.printer_id)
+                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
                                 logger.warning("Could not power on printer %s via smart plug", item.printer_id)
                                 busy_printers.add(item.printer_id)
@@ -173,7 +207,7 @@ class PrintScheduler:
                                 # Print takes priority — stop drying
                                 await self._stop_drying(item.printer_id)
                                 # Re-check idle after stopping drying
-                                printer_idle = self._is_printer_idle(item.printer_id)
+                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                                 if not printer_idle:
                                     busy_printers.add(item.printer_id)
                                     continue
@@ -216,6 +250,23 @@ class PrintScheduler:
                     await self._start_print(db, item)
                     busy_printers.add(item.printer_id)
 
+                    # SJF starvation guard: mark items that were jumped
+                    if sjf_enabled and item.print_time_seconds is not None:
+                        for other in items:
+                            if (
+                                other.id != item.id
+                                and other.status == "pending"
+                                and other.printer_id == item.printer_id
+                                and not other.been_jumped
+                                and other.position < item.position
+                                and (
+                                    other.print_time_seconds is None
+                                    or other.print_time_seconds > item.print_time_seconds
+                                )
+                            ):
+                                other.been_jumped = True
+                        await db.commit()
+
                 elif item.target_model:
                     # Model-based assignment - find any idle printer of matching model
                     # Parse required filament types if present
@@ -249,6 +300,7 @@ class PrintScheduler:
                         effective_types,
                         item.target_location,
                         filament_overrides=filament_overrides,
+                        require_plate_clear=require_plate_clear,
                     )
 
                     # Update waiting_reason if changed and send notification when first waiting
@@ -320,6 +372,25 @@ class PrintScheduler:
                         await self._start_print(db, item)
                         busy_printers.add(printer_id)
 
+                        # SJF starvation guard: mark model-based items that were jumped
+                        if sjf_enabled and item.print_time_seconds is not None:
+                            for other in items:
+                                if (
+                                    other.id != item.id
+                                    and other.status == "pending"
+                                    and other.printer_id is None
+                                    and other.target_model
+                                    and other.target_model.upper() == item.target_model.upper()
+                                    and not other.been_jumped
+                                    and other.position < item.position
+                                    and (
+                                        other.print_time_seconds is None
+                                        or other.print_time_seconds > item.print_time_seconds
+                                    )
+                                ):
+                                    other.been_jumped = True
+                            await db.commit()
+
             # Log summary of skip reasons (helps diagnose why queue items aren't starting)
             if skip_reasons:
                 logger.info("Queue skip summary: %s", skip_reasons)
@@ -328,18 +399,18 @@ class PrintScheduler:
                 for pid in busy_printers:
                     state = printer_manager.get_status(pid)
                     connected = printer_manager.is_connected(pid)
-                    plate_cleared = printer_manager.is_plate_cleared(pid)
+                    awaiting = printer_manager.is_awaiting_plate_clear(pid)
                     state_name = state.state if state else "NO_STATUS"
                     logger.info(
-                        "Queue: printer %d not available — connected=%s, state=%s, plate_cleared=%s",
+                        "Queue: printer %d not available — connected=%s, state=%s, awaiting_plate_clear=%s",
                         pid,
                         connected,
                         state_name,
-                        plate_cleared,
+                        awaiting,
                     )
 
             # Auto-drying: start drying on idle printers that have no pending queue items
-            await self._check_auto_drying(db, items, busy_printers)
+            await self._check_auto_drying(db, items, busy_printers, require_plate_clear=require_plate_clear)
 
     async def _find_idle_printer_for_model(
         self,
@@ -349,6 +420,7 @@ class PrintScheduler:
         required_filament_types: list[str] | None = None,
         target_location: str | None = None,
         filament_overrides: list[dict] | None = None,
+        require_plate_clear: bool = True,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -413,7 +485,7 @@ class PrintScheduler:
                 continue
 
             is_connected = printer_manager.is_connected(printer.id)
-            is_idle = self._is_printer_idle(printer.id) if is_connected else False
+            is_idle = self._is_printer_idle(printer.id, require_plate_clear) if is_connected else False
 
             if not is_connected:
                 printers_offline.append(printer.name)
@@ -698,8 +770,11 @@ class PrintScheduler:
             logger.debug("No filaments loaded on printer %s", printer_id)
             return None
 
+        # Check if user prefers lowest remaining filament when multiple spools match
+        prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
+
         # Compute mapping: match required filaments to available slots
-        return self._match_filaments_to_slots(filament_reqs, loaded_filaments)
+        return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
 
     async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
         """Extract filament requirements from the source 3MF file.
@@ -851,6 +926,7 @@ class PrintScheduler:
                             "is_external": False,
                             "global_tray_id": global_tray_id,
                             "extruder_id": ams_extruder_map.get(str(ams_id)),
+                            "remain": tray.get("remain", -1),
                         }
                     )
 
@@ -870,6 +946,7 @@ class PrintScheduler:
                         "is_external": True,
                         "global_tray_id": tray_id,
                         "extruder_id": (255 - tray_id) if ams_extruder_map else None,
+                        "remain": vt.get("remain", -1),
                     }
                 )
 
@@ -906,7 +983,9 @@ class PrintScheduler:
         except ValueError:
             return False
 
-    def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
+    def _match_filaments_to_slots(
+        self, required: list[dict], loaded: list[dict], prefer_lowest: bool = False
+    ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
         Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
@@ -952,6 +1031,10 @@ class PrintScheduler:
             if req_nozzle_id is not None:
                 available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
+            # Sort by remaining filament (ascending) so lowest-remain spool wins .find()
+            if prefer_lowest:
+                available.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
+
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
                 idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
@@ -968,6 +1051,8 @@ class PrintScheduler:
                         f"Non-unique tray_info_idx={req_tray_info_idx} found in {len(idx_matches)} trays, "
                         f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
                     )
+                    if prefer_lowest:
+                        idx_matches.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
                     # Use color matching within this subset
                     for f in idx_matches:
                         f_color = f.get("color", "")
@@ -1021,7 +1106,7 @@ class PrintScheduler:
 
         return mapping
 
-    def _is_printer_idle(self, printer_id: int) -> bool:
+    def _is_printer_idle(self, printer_id: int, require_plate_clear: bool = True) -> bool:
         """Check if a printer is connected and idle."""
         if not printer_manager.is_connected(printer_id):
             logger.debug("Printer %d: not connected", printer_id)
@@ -1032,20 +1117,30 @@ class PrintScheduler:
             logger.debug("Printer %d: no status available", printer_id)
             return False
 
-        # IDLE = ready for next print
-        # FINISH/FAILED = ready only if user confirmed plate is cleared
-        idle = state.state == "IDLE" or (
-            state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
-        )
-        if not idle:
+        # Plate-clear gate: if the printer finished/failed a previous print and the user
+        # hasn't acknowledged the plate was cleared, the queue must not dispatch the next
+        # job — even if the printer currently reports IDLE. After Auto Off cycles the
+        # printer, it boots back into IDLE with no memory of the previous finish; without
+        # the persisted awaiting flag we'd bypass the confirmation prompt (#961).
+        if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer_id):
             logger.debug(
-                "Printer %d: not idle — state=%s, plate_cleared=%s",
+                "Printer %d: not idle — awaiting plate-clear acknowledgment (state=%s)",
                 printer_id,
                 state.state,
-                printer_manager.is_plate_cleared(printer_id),
             )
+            return False
+
+        idle = state.state in ("IDLE", "FINISH", "FAILED")
+        if not idle:
+            logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
         return idle
 
+    async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
+        """Read a setting value from the database."""
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        return setting.value if setting else None
+
     async def _get_bool_setting(self, db: AsyncSession, key: str, default: bool = False) -> bool:
         """Read a boolean setting from the database."""
         result = await db.execute(select(Settings).where(Settings.key == key))
@@ -1106,7 +1201,14 @@ class PrintScheduler:
             return None
         return (min_temp, max_hours or 12, filament_type)
 
-    async def _check_auto_drying(self, db: AsyncSession, queue_items: list[PrintQueueItem], busy_printers: set[int]):
+    async def _check_auto_drying(
+        self,
+        db: AsyncSession,
+        queue_items: list[PrintQueueItem],
+        busy_printers: set[int],
+        *,
+        require_plate_clear: bool = True,
+    ):
         """Start drying on idle printers based on humidity.
 
         Two modes (can both be enabled):
@@ -1175,7 +1277,7 @@ class PrintScheduler:
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
                 continue
-            if not self._is_printer_idle(pid):
+            if not self._is_printer_idle(pid, require_plate_clear):
                 logger.debug("Auto-drying: printer %d skipped — not idle", pid)
                 continue
 
@@ -1338,10 +1440,10 @@ class PrintScheduler:
                 printer_manager.send_drying_command(printer_id, ams_id, 0, 0, mode=0)
         self._drying_in_progress.pop(printer_id, None)
 
-    async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
-        """Get the smart plug associated with a printer."""
+    async def _get_smart_plugs(self, db: AsyncSession, printer_id: int) -> list[SmartPlug]:
+        """Get all smart plugs associated with a printer."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        return result.scalar_one_or_none()
+        return list(result.scalars().all())
 
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
         """Turn on smart plug and wait for printer to connect.
@@ -1422,14 +1524,26 @@ class PrintScheduler:
         if not item.auto_off_after:
             return
 
-        plug = await self._get_smart_plug(db, item.printer_id)
-        if plug and plug.enabled:
+        plugs = await self._get_smart_plugs(db, item.printer_id)
+        plug_ids = [p.id for p in plugs if p.enabled]
+        if plug_ids:
             logger.info("Auto-off: Waiting for printer %s to cool down before power off...", item.printer_id)
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
-            logger.info("Auto-off: Powering off printer %s", item.printer_id)
-            service = await smart_plug_manager.get_service_for_plug(plug, db)
-            await service.turn_off(plug)
+            # Re-fetch plugs in a fresh session after the long cooldown wait
+            async with async_session() as new_db:
+                for plug_id in plug_ids:
+                    try:
+                        result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+                        plug = result.scalar_one_or_none()
+                        if plug and plug.enabled:
+                            logger.info("Auto-off: Powering off plug '%s' for printer %s", plug.name, item.printer_id)
+                            service = await smart_plug_manager.get_service_for_plug(plug, new_db)
+                            await service.turn_off(plug)
+                    except Exception as e:
+                        logger.warning(
+                            "Auto-off: Failed to power off plug %s for printer %s: %s", plug_id, item.printer_id, e
+                        )
 
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
         """Get a human-readable name for a queue item."""
@@ -1530,6 +1644,7 @@ class PrintScheduler:
                     source_file=file_path,
                     original_filename=filename,
                     created_by_id=item.created_by_id,
+                    project_id=item.project_id,
                 )
                 if archive:
                     item.archive_id = archive.id
@@ -1563,6 +1678,32 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
+        # G-code injection for auto-print systems (#422)
+        injected_path = None
+        if item.gcode_injection:
+            try:
+                snippets_raw = await self._get_setting(db, "gcode_snippets")
+                if snippets_raw:
+                    snippets = json.loads(snippets_raw)
+                    model_snippets = snippets.get(printer.model, {})
+                    start_gc = (model_snippets.get("start_gcode") or "").strip()
+                    end_gc = (model_snippets.get("end_gcode") or "").strip()
+                    if start_gc or end_gc:
+                        from backend.app.utils.threemf_tools import inject_gcode_into_3mf
+
+                        injected_path = inject_gcode_into_3mf(
+                            file_path, item.plate_id or 1, start_gc or None, end_gc or None
+                        )
+                        if injected_path:
+                            file_path = injected_path
+                            logger.info("Queue item %s: G-code injected for model %s", item.id, printer.model)
+                        else:
+                            logger.warning(
+                                "Queue item %s: G-code injection returned no result, using original", item.id
+                            )
+            except Exception as e:
+                logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
+
         # Upload file to printer via FTP
         # Use a clean filename to avoid issues with double extensions like .gcode.3mf
         base_name = filename
@@ -1627,6 +1768,10 @@ class PrintScheduler:
             uploaded = False
             logger.error("Queue item %s: FTP error: %s (type: %s)", item.id, e, type(e).__name__)
 
+        # Clean up injected temp file after upload attempt
+        if injected_path and injected_path.exists():
+            injected_path.unlink(missing_ok=True)
+
         if not uploaded:
             error_msg = (
                 "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
@@ -1683,10 +1828,15 @@ class PrintScheduler:
         item.started_at = datetime.now(timezone.utc)
         await db.commit()
 
-        # Consume the plate-cleared flag now that we're starting a print
-        printer_manager.consume_plate_cleared(item.printer_id)
+        # Clear the awaiting-plate-clear flag now that we're starting a new print
+        printer_manager.set_awaiting_plate_clear(item.printer_id, False)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
+        # Capture state before dispatch so the watchdog can detect whether the
+        # printer actually transitioned (#967).
+        pre_status = printer_manager.get_status(item.printer_id)
+        pre_state = getattr(pre_status, "state", None) if pre_status else None
+
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
             item.printer_id,
@@ -1704,6 +1854,13 @@ class PrintScheduler:
         if started:
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
 
+            # Watchdog: if the printer never transitions out of pre_state, the MQTT
+            # publish was accepted locally but didn't reach the printer (half-broken
+            # session — same shape as #887/#936). Revert the queue item so the next
+            # dispatch can pick it up instead of leaving it stuck in "printing" (#967).
+            if pre_state:
+                asyncio.create_task(self._watchdog_print_start(item.id, item.printer_id, pre_state))
+
             # Get estimated time for notification
             estimated_time = None
             if archive and archive.print_time_seconds:
@@ -1768,6 +1925,55 @@ class PrintScheduler:
 
             await self._power_off_if_needed(db, item)
 
+    @staticmethod
+    async def _watchdog_print_start(
+        queue_item_id: int,
+        printer_id: int,
+        pre_state: str,
+        timeout: float = 45.0,
+        poll_interval: float = 3.0,
+    ) -> None:
+        """Revert a queue item if the printer never acknowledges the start command.
+
+        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).
+        """
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline:
+            await asyncio.sleep(poll_interval)
+            status = printer_manager.get_status(printer_id)
+            if not status:
+                return  # Printer disconnected — don't mess with the DB
+            if status.state != pre_state:
+                return  # Printer picked up the job
+
+        # No transition. Revert the item so the scheduler can retry.
+        async with async_session() as db:
+            item = await db.get(PrintQueueItem, queue_item_id)
+            if not item or item.status != "printing":
+                return  # Already moved on (completed/cancelled/etc.)
+            item.status = "pending"
+            item.started_at = None
+            await db.commit()
+            logger.warning(
+                "Queue item %s: printer %d did not respond to print command within "
+                "%.0fs (state still %s) — reverted to 'pending' for retry (#967)",
+                queue_item_id,
+                printer_id,
+                timeout,
+                pre_state,
+            )
+
+        # Same half-broken-session recovery as background_dispatch: force the
+        # MQTT client to reconnect so the next dispatch lands without a power cycle.
+        client = printer_manager.get_client(printer_id)
+        if client and hasattr(client, "force_reconnect_stale_session"):
+            client.force_reconnect_stale_session(
+                f"queue print command unacknowledged after {timeout:.0f}s (state still {pre_state})"
+            )
+
 
 # Global scheduler instance
 scheduler = PrintScheduler()

+ 80 - 18
backend/app/services/printer_manager.py

@@ -21,6 +21,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "X1",
         "X1C",
         "X1E",  # X1 series
+        "X2D",  # X2 series
         "P2S",  # P2 series
         "H2C",
         "H2D",
@@ -29,6 +30,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         # Internal codes (from MQTT/SSDP)
         "BL-P001",  # X1/X1C
         "C13",  # X1E
+        "N6",  # X2D
         "O1D",  # H2D
         "O1C",  # H2C
         "O1C2",  # H2C (dual nozzle variant)
@@ -100,15 +102,16 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
 _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "H2D": "01.02.30.00",
+    "H2S": "01.02.00.00",
     "X1": "01.09.00.00",
     "X1C": "01.09.00.00",
     "P1P": "01.08.00.00",
     "P1S": "01.08.00.00",
+    "P2S": "01.02.00.00",
+    "N7": "01.02.00.00",  # P2S internal model code
 }
 # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
-_DRYING_UNSUPPORTED_MODELS = frozenset(
-    {"P2S", "A1", "A1MINI", "A1-MINI", "A1 MINI", "H2S", "H2C", "N7", "O1C", "O1C2", "O1S", "N1", "N2S"}
-)
+_DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "H2C", "O1C", "O1C2", "O1S", "N1", "N2S"})
 
 
 def supports_drying(model: str | None, firmware: str | None) -> bool:
@@ -150,11 +153,14 @@ class PrinterManager:
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
+        self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
-        # Track plate-cleared acknowledgments for queue flow
-        self._plate_cleared: set[int] = set()  # printer_ids where user confirmed plate is cleared
+        # Track printers awaiting plate-clear acknowledgment after a finished/failed print.
+        # Persisted to DB (printers.awaiting_plate_clear) so the gate survives restarts/power
+        # cycles — see issue #961. Loaded into this set at startup via load_awaiting_plate_clear_from_db().
+        self._awaiting_plate_clear: set[int] = set()
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
@@ -172,17 +178,53 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         self._current_print_user.pop(printer_id, None)
 
-    def set_plate_cleared(self, printer_id: int):
-        """Mark that user has cleared the build plate for this printer."""
-        self._plate_cleared.add(printer_id)
+    def is_awaiting_plate_clear(self, printer_id: int) -> bool:
+        """Return True when the printer finished/failed a print and is waiting for the
+        user to acknowledge the plate is cleared before the queue may dispatch the next job.
+        """
+        return printer_id in self._awaiting_plate_clear
+
+    def set_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
+        """Set/clear the awaiting-plate-clear gate and persist it to DB.
 
-    def is_plate_cleared(self, printer_id: int) -> bool:
-        """Check if user has confirmed the plate is cleared."""
-        return printer_id in self._plate_cleared
+        Persisted so the gate survives Bambuddy/printer restarts (#961): after Auto Off
+        cycles the printer, the printer boots into IDLE with no memory of the previous
+        finish, and without persistence the queue would bypass the confirmation prompt.
+        """
+        if awaiting:
+            self._awaiting_plate_clear.add(printer_id)
+        else:
+            self._awaiting_plate_clear.discard(printer_id)
+        # Only create the coroutine when there is a loop to run it on — otherwise Python
+        # emits "coroutine was never awaited" warnings (e.g. in sync unit tests).
+        if self._loop and self._loop.is_running():
+            self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
 
-    def consume_plate_cleared(self, printer_id: int):
-        """Clear the plate-cleared flag (called when scheduler starts next print)."""
-        self._plate_cleared.discard(printer_id)
+    async def _persist_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
+        from backend.app.core.database import async_session
+
+        try:
+            async with async_session() as db:
+                printer = await db.get(Printer, printer_id)
+                if printer is not None:
+                    printer.awaiting_plate_clear = awaiting
+                    await db.commit()
+        except Exception as e:
+            logger.warning("Failed to persist awaiting_plate_clear for printer %d: %s", printer_id, e)
+
+    async def load_awaiting_plate_clear_from_db(self):
+        """Rehydrate the awaiting-plate-clear set from the printers table on startup."""
+        from backend.app.core.database import async_session
+
+        try:
+            async with async_session() as db:
+                result = await db.execute(select(Printer.id).where(Printer.awaiting_plate_clear.is_(True)))
+                ids = {row[0] for row in result.all()}
+                self._awaiting_plate_clear = ids
+                if ids:
+                    logger.info("Loaded %d printer(s) awaiting plate-clear acknowledgment: %s", len(ids), sorted(ids))
+        except Exception as e:
+            logger.warning("Failed to load awaiting_plate_clear from DB: %s", e)
 
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
@@ -208,6 +250,10 @@ class PrinterManager:
         """Set callback for layer change events. Receives (printer_id, layer_num)."""
         self._on_layer_change = callback
 
+    def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
+        """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
+        self._on_bed_temp_update = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
 
@@ -255,6 +301,10 @@ class PrinterManager:
             if self._on_layer_change:
                 self._schedule_async(self._on_layer_change(printer_id, layer_num))
 
+        def on_bed_temp_update(bed_temp: float):
+            if self._on_bed_temp_update:
+                self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -265,6 +315,7 @@ class PrinterManager:
             on_print_complete=on_print_complete,
             on_ams_change=on_ams_change,
             on_layer_change=on_layer_change,
+            on_bed_temp_update=on_bed_temp_update,
         )
 
         client.connect()
@@ -520,9 +571,11 @@ def get_derived_status_name(state: PrinterState, model: str | None = None) -> st
         state: The printer state to analyze
         model: Optional printer model for model-specific workarounds
     """
-    # A1/A1 Mini firmware bug: some versions report stg_cur=0 when idle
-    # Only correct this specific case (IDLE + stg_cur=0) for affected models
-    if state.state == "IDLE" and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
+    # Firmware bug: some models (A1, P1P, P1S) report stg_cur=0 when not printing.
+    # stg_cur=0 maps to "Printing" in STAGE_NAMES, which incorrectly overrides the
+    # real state (IDLE, FINISH, FAILED, etc.). Only trust stg_cur when the printer
+    # is actually in an active print state (RUNNING or PAUSE).
+    if state.state not in ("RUNNING", "PAUSE") and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
         return None
 
     # If we have a valid calibration stage, use it
@@ -673,7 +726,13 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
 
     # Parse virtual tray (external spool) — now a list
     if "vt_tray" in raw_data:
-        for vt_data in raw_data["vt_tray"]:
+        vt_tray_raw = raw_data["vt_tray"]
+        # Defensive: MQTT sends vt_tray as a dict; normalize to list
+        if isinstance(vt_tray_raw, dict):
+            vt_tray_raw = [vt_tray_raw]
+        elif not isinstance(vt_tray_raw, list):
+            vt_tray_raw = []
+        for vt_data in vt_tray_raw:
             vt_tag_uid = vt_data.get("tag_uid")
             if vt_tag_uid in ("", "0000000000000000"):
                 vt_tag_uid = None
@@ -744,6 +803,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
         "wired_network": state.wired_network,
+        "door_open": state.door_open,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state, model),
@@ -758,6 +818,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "chamber_light": state.chamber_light,
         # Active extruder for dual-nozzle printers (0=right, 1=left)
         "active_extruder": state.active_extruder,
+        # Print speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)
+        "speed_level": state.speed_level,
         # H2C nozzle rack (tool-changer dock positions)
         # Map raw MQTT field names (type/diameter) to schema names (nozzle_type/nozzle_diameter)
         "nozzle_rack": [

+ 274 - 0
backend/app/services/rest_smart_plug.py

@@ -0,0 +1,274 @@
+"""Service for controlling smart plugs via generic REST/HTTP API."""
+
+import ipaddress
+import json
+import logging
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlparse
+
+import httpx
+
+if TYPE_CHECKING:
+    from backend.app.models.smart_plug import SmartPlug
+
+logger = logging.getLogger(__name__)
+
+
+class RESTSmartPlugService:
+    """Service for controlling smart plugs via generic REST/HTTP API.
+
+    Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.).
+    """
+
+    def __init__(self, timeout: float = 10.0):
+        self.timeout = timeout
+
+    @staticmethod
+    def _validate_url(url: str) -> bool:
+        """Block cloud metadata and link-local IPs."""
+        try:
+            parsed = urlparse(url)
+            hostname = parsed.hostname
+            if not hostname:
+                return False
+            addr = ipaddress.ip_address(hostname)
+            return not addr.is_loopback and not addr.is_link_local
+        except ValueError:
+            # Hostname is not an IP (e.g., "openhab.local") — allow it
+            return True
+
+    def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
+        """Parse JSON string to dict of headers."""
+        if not headers_json:
+            return {}
+        try:
+            headers = json.loads(headers_json)
+            if isinstance(headers, dict):
+                return {str(k): str(v) for k, v in headers.items()}
+        except (json.JSONDecodeError, TypeError):
+            logger.warning("Failed to parse REST headers JSON: %s", headers_json)
+        return {}
+
+    @staticmethod
+    def _extract_json_path(data: Any, path: str) -> Any:
+        """Extract value using dot notation (e.g., 'state' or 'data.power.status')."""
+        if not path:
+            return None
+
+        parts = path.split(".")
+        current = data
+
+        for part in parts:
+            if isinstance(current, dict) and part in current:
+                current = current[part]
+            else:
+                return None
+
+        return current
+
+    async def _send_request(
+        self,
+        url: str,
+        method: str = "POST",
+        headers: dict[str, str] | None = None,
+        body: str | None = None,
+    ) -> httpx.Response | None:
+        """Send an HTTP request and return the response."""
+        if not self._validate_url(url):
+            logger.warning("Blocked REST request to invalid URL: %s", url)
+            return None
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                kwargs: dict[str, Any] = {"headers": headers or {}}
+                if body is not None:
+                    # Try to detect if body is JSON
+                    try:
+                        json.loads(body)
+                        kwargs["content"] = body
+                        if "Content-Type" not in (headers or {}):
+                            kwargs["headers"]["Content-Type"] = "application/json"
+                    except (json.JSONDecodeError, TypeError):
+                        kwargs["content"] = body
+
+                response = await client.request(method.upper(), url, **kwargs)
+                response.raise_for_status()
+                return response
+        except httpx.TimeoutException:
+            logger.warning("REST smart plug at %s timed out", url)
+            return None
+        except httpx.HTTPStatusError as e:
+            logger.warning("REST smart plug at %s returned error: %s", url, e)
+            return None
+        except httpx.RequestError as e:
+            logger.warning("Failed to connect to REST smart plug at %s: %s", url, e)
+            return None
+        except Exception as e:
+            logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e)
+            return None
+
+    async def turn_on(self, plug: "SmartPlug") -> bool:
+        """Turn on the plug. Returns True if successful."""
+        if not plug.rest_on_url:
+            logger.warning("No ON URL configured for REST plug '%s'", plug.name)
+            return False
+
+        headers = self._parse_headers(plug.rest_headers)
+        method = plug.rest_method or "POST"
+        response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
+
+        if response is not None:
+            logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url)
+            return True
+
+        logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
+        return False
+
+    async def turn_off(self, plug: "SmartPlug") -> bool:
+        """Turn off the plug. Returns True if successful."""
+        if not plug.rest_off_url:
+            logger.warning("No OFF URL configured for REST plug '%s'", plug.name)
+            return False
+
+        headers = self._parse_headers(plug.rest_headers)
+        method = plug.rest_method or "POST"
+        response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
+
+        if response is not None:
+            logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url)
+            return True
+
+        logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)
+        return False
+
+    async def toggle(self, plug: "SmartPlug") -> bool:
+        """Toggle the plug state by checking status first."""
+        status = await self.get_status(plug)
+        if status["state"] == "ON":
+            return await self.turn_off(plug)
+        else:
+            return await self.turn_on(plug)
+
+    async def get_status(self, plug: "SmartPlug") -> dict:
+        """Get current power state.
+
+        Returns dict with:
+            - state: "ON" or "OFF" or None if unreachable
+            - reachable: bool
+            - device_name: None (REST plugs don't report device names)
+        """
+        if not plug.rest_status_url:
+            return {"state": None, "reachable": True, "device_name": None}
+
+        headers = self._parse_headers(plug.rest_headers)
+        response = await self._send_request(plug.rest_status_url, "GET", headers)
+
+        if response is None:
+            return {"state": None, "reachable": False, "device_name": None}
+
+        # Try to extract state from response
+        state = None
+        try:
+            data = response.json()
+            if plug.rest_status_path:
+                raw_value = self._extract_json_path(data, plug.rest_status_path)
+                if raw_value is not None:
+                    on_value = (plug.rest_status_on_value or "ON").upper()
+                    state = "ON" if str(raw_value).upper() == on_value else "OFF"
+            else:
+                # No path configured — try common patterns
+                raw_value = str(data).upper() if not isinstance(data, dict) else None
+                if raw_value in ("ON", "TRUE", "1"):
+                    state = "ON"
+                elif raw_value in ("OFF", "FALSE", "0"):
+                    state = "OFF"
+        except Exception:
+            # Response is not JSON — try raw text
+            text = response.text.strip().upper()
+            on_value = (plug.rest_status_on_value or "ON").upper()
+            state = "ON" if text == on_value else "OFF"
+
+        return {"state": state, "reachable": True, "device_name": None}
+
+    async def get_energy(self, plug: "SmartPlug") -> dict | None:
+        """Get energy monitoring data.
+
+        Each value (power, energy) can come from its own URL or fall back to the shared status URL.
+        Multipliers are applied to convert units (e.g., Wh → kWh with multiplier 0.001).
+
+        Returns dict with energy data or None if not available.
+        """
+        if not plug.rest_power_path and not plug.rest_energy_path:
+            return None
+
+        headers = self._parse_headers(plug.rest_headers)
+        energy: dict[str, float | None] = {}
+
+        power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
+        energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
+
+        # Fetch data — deduplicate when both resolve to the same URL
+        fetched: dict[str, Any] = {}
+
+        for url in {power_url, energy_url} - {None}:
+            fetched[url] = await self._fetch_json(url, headers)
+
+        # Extract power value
+        if plug.rest_power_path and power_url and fetched.get(power_url) is not None:
+            raw = self._extract_json_path(fetched[power_url], plug.rest_power_path)
+            if raw is not None:
+                try:
+                    energy["power"] = float(raw) * (plug.rest_power_multiplier or 1.0)
+                except (ValueError, TypeError):
+                    pass
+
+        # Extract energy value
+        if plug.rest_energy_path and energy_url and fetched.get(energy_url) is not None:
+            raw = self._extract_json_path(fetched[energy_url], plug.rest_energy_path)
+            if raw is not None:
+                try:
+                    energy["today"] = float(raw) * (plug.rest_energy_multiplier or 1.0)
+                except (ValueError, TypeError):
+                    pass
+
+        return energy if energy else None
+
+    async def _fetch_json(self, url: str, headers: dict[str, str]) -> Any:
+        """Fetch a URL and parse JSON response. Returns parsed data or None."""
+        response = await self._send_request(url, "GET", headers)
+        if response is None:
+            return None
+        try:
+            return response.json()
+        except Exception:
+            return None
+
+    async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
+        """Test connection to a REST endpoint.
+
+        Returns dict with:
+            - success: bool
+            - error: error message if failed
+        """
+        if not self._validate_url(url):
+            return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
+
+        parsed_headers = self._parse_headers(headers)
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.request(method.upper(), url, headers=parsed_headers)
+                response.raise_for_status()
+                return {"success": True, "error": None}
+        except httpx.TimeoutException:
+            return {"success": False, "error": "Connection timed out"}
+        except httpx.HTTPStatusError as e:
+            return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
+        except httpx.RequestError as e:
+            return {"success": False, "error": f"Connection failed: {e}"}
+        except Exception as e:
+            return {"success": False, "error": str(e)}
+
+
+# Singleton instance
+rest_smart_plug_service = RESTSmartPlugService()

+ 162 - 69
backend/app/services/smart_plug_manager.py

@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.services.homeassistant import homeassistant_service
 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.tasmota import tasmota_service
 
 if TYPE_CHECKING:
@@ -25,6 +26,7 @@ class SmartPlugManager:
         self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task
         self._loop: asyncio.AbstractEventLoop | None = None
         self._scheduler_task: asyncio.Task | None = None
+        self._snapshot_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
     async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
@@ -36,6 +38,8 @@ class SmartPlugManager:
             # Configure HA service with current settings
             await self._configure_ha_service(db)
             return homeassistant_service
+        if plug.plug_type == "rest":
+            return rest_smart_plug_service
         return tasmota_service
 
     async def _configure_ha_service(self, db: AsyncSession | None = None):
@@ -66,6 +70,9 @@ class SmartPlugManager:
         if self._scheduler_task is None:
             self._scheduler_task = asyncio.create_task(self._schedule_loop())
             logger.info("Smart plug scheduler started")
+        if self._snapshot_task is None:
+            self._snapshot_task = asyncio.create_task(self._snapshot_loop())
+            logger.info("Smart plug energy snapshot loop started")
 
     def stop_scheduler(self):
         """Stop the background scheduler."""
@@ -73,6 +80,10 @@ class SmartPlugManager:
             self._scheduler_task.cancel()
             self._scheduler_task = None
             logger.info("Smart plug scheduler stopped")
+        if self._snapshot_task:
+            self._snapshot_task.cancel()
+            self._snapshot_task = None
+            logger.info("Smart plug energy snapshot loop stopped")
 
     async def _schedule_loop(self):
         """Background loop that checks scheduled on/off times every minute."""
@@ -85,6 +96,71 @@ class SmartPlugManager:
             # Wait until the next minute
             await asyncio.sleep(60)
 
+    async def _snapshot_loop(self):
+        """Background loop that captures each plug's lifetime energy counter hourly.
+
+        Powers date-range queries in "total consumption" energy mode (#941). Takes
+        a snapshot shortly after startup so the first bucket isn't empty, then
+        every hour.
+        """
+        # Short warm-up delay so other services finish booting; still gives us
+        # an initial snapshot well before the first hour mark.
+        await asyncio.sleep(30)
+        while True:
+            try:
+                await self._capture_energy_snapshots()
+            except Exception as e:
+                logger.error("Error in energy snapshot capture: %s", e)
+            await asyncio.sleep(3600)  # 1 hour
+
+    async def _capture_energy_snapshots(self):
+        """Capture one energy snapshot row per plug with a usable lifetime counter."""
+        from datetime import timezone
+
+        from backend.app.core.database import async_session
+        from backend.app.models.smart_plug import SmartPlug
+        from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+
+        async with async_session() as db:
+            plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))
+            plugs = list(plugs_result.scalars().all())
+            if not plugs:
+                return
+
+            now = datetime.now(timezone.utc)
+            captured = 0
+            for plug in plugs:
+                # MQTT plugs only publish a "today" counter that resets at midnight —
+                # they can never feed cumulative snapshots, so skip them outright to
+                # avoid a noisy tasmota-service fallback attempt on an IP-less plug.
+                if plug.plug_type == "mqtt":
+                    continue
+                try:
+                    service = await self.get_service_for_plug(plug, db)
+                    energy = await service.get_energy(plug)
+                except Exception as e:
+                    logger.debug("Snapshot: failed to read energy from plug %s: %s", plug.id, e)
+                    continue
+                if not energy:
+                    continue
+                lifetime = energy.get("total")
+                if lifetime is None:
+                    # MQTT / REST plugs that only expose "today" can't be used for
+                    # cumulative snapshots — skip them.
+                    continue
+                db.add(
+                    SmartPlugEnergySnapshot(
+                        plug_id=plug.id,
+                        recorded_at=now,
+                        lifetime_kwh=float(lifetime),
+                    )
+                )
+                captured += 1
+
+            if captured:
+                await db.commit()
+                logger.info("Captured %d energy snapshot(s)", captured)
+
     async def _check_schedules(self):
         """Check all plugs for scheduled on/off times."""
         from backend.app.core.database import async_session
@@ -131,102 +207,91 @@ class SmartPlugManager:
 
             await db.commit()
 
-    async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None":
-        """Get the main (non-script) smart plug linked to a printer.
-
-        When multiple plugs are assigned (e.g., a power plug + secondary HA switch),
-        returns the main power plug for automation control.
-        """
+    async def _get_plugs_for_printer(self, printer_id: int, db: AsyncSession) -> list["SmartPlug"]:
+        """Get all smart plugs linked to a printer for automation control."""
         from backend.app.models.smart_plug import SmartPlug
 
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        plugs = result.scalars().all()
-
-        if not plugs:
-            return None
-
-        # Prefer non-script, non-secondary plugs (main power plug)
-        for plug in plugs:
-            is_script = (
-                plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
-            )
-            if not is_script:
-                return plug
-
-        # All are scripts, return the first one
-        return plugs[0]
+        return list(result.scalars().all())
 
     async def on_print_start(self, printer_id: int, db: AsyncSession):
-        """Called when a print starts - turn on plug if configured."""
-        plug = await self._get_plug_for_printer(printer_id, db)
+        """Called when a print starts - turn on all plugs linked to this printer."""
+        plugs = await self._get_plugs_for_printer(printer_id, db)
 
-        if not plug:
+        if not plugs:
             return
 
-        if not plug.enabled:
-            logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
-            return
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
+                continue
 
-        if not plug.auto_on:
-            logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
-            return
+            if not plug.auto_on:
+                logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
+                continue
 
-        # Cancel any pending off task
-        self._cancel_pending_off(plug.id)
+            # Cancel any pending off task
+            self._cancel_pending_off(plug.id)
 
-        # Turn on the plug
-        logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
-        service = await self.get_service_for_plug(plug, db)
-        success = await service.turn_on(plug)
+            # Turn on the plug
+            logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
+            try:
+                service = await self.get_service_for_plug(plug, db)
+                success = await service.turn_on(plug)
 
-        if success:
-            # Update last state and reset auto_off_executed
-            plug.last_state = "ON"
-            plug.last_checked = datetime.now(timezone.utc)
-            plug.auto_off_executed = False  # Reset flag when turning on
-            await db.commit()
+                if success:
+                    plug.last_state = "ON"
+                    plug.last_checked = datetime.now(timezone.utc)
+                    plug.auto_off_executed = False  # Reset flag when turning on
+            except Exception as e:
+                logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
+
+        await db.commit()
 
     async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
-        """Called when a print completes - schedule turn off if configured.
+        """Called when a print completes - schedule turn off for all plugs linked to this printer.
 
         Only triggers auto-off on successful completion (status='completed').
         Failed prints keep the printer powered on for user investigation.
         """
-        plug = await self._get_plug_for_printer(printer_id, db)
-
-        if not plug:
+        # Only auto-off on successful completion, not on failures
+        if status != "completed":
+            logger.info(
+                "Print on printer %s ended with status '%s', skipping auto-off to allow investigation",
+                printer_id,
+                status,
+            )
             return
 
-        if not plug.enabled:
-            logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
-            return
+        plugs = await self._get_plugs_for_printer(printer_id, db)
 
-        if not plug.auto_off:
-            logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
+        if not plugs:
             return
 
-        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
-        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
-            logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
-            return
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping auto-off", plug.name)
+                continue
+
+            if not plug.auto_off:
+                logger.debug("Smart plug '%s' auto_off is disabled", plug.name)
+                continue
+
+            # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+            if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+                logger.debug("Smart plug '%s' is a HA script entity, skipping auto-off", plug.name)
+                continue
 
-        # Only auto-off on successful completion, not on failures
-        # This allows the user to investigate errors before power-off
-        if status != "completed":
             logger.info(
-                f"Print on printer {printer_id} ended with status '{status}', "
-                f"skipping auto-off for plug '{plug.name}' to allow investigation"
+                "Print completed successfully on printer %s, scheduling turn-off for plug '%s'",
+                printer_id,
+                plug.name,
             )
-            return
-
-        logger.info(
-            "Print completed successfully on printer %s, scheduling turn-off for plug '%s'", printer_id, plug.name
-        )
 
-        if plug.off_delay_mode == "time":
-            self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
-        elif plug.off_delay_mode == "temperature":
-            self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
+            if plug.off_delay_mode == "time":
+                self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
+            elif plug.off_delay_mode == "temperature":
+                self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
 
     def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
         """Schedule turn-off after delay."""
@@ -248,6 +313,10 @@ class SmartPlugManager:
                 plug.password,
                 printer_id,
                 delay_seconds,
+                rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
+                rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
+                rest_method=plug.rest_method if plug.plug_type == "rest" else None,
+                rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
             )
         )
         self._pending_off[plug.id] = task
@@ -262,6 +331,11 @@ class SmartPlugManager:
         password: str | None,
         printer_id: int,
         delay_seconds: int,
+        *,
+        rest_off_url: str | None = None,
+        rest_off_body: str | None = None,
+        rest_method: str | None = None,
+        rest_headers: str | None = None,
     ):
         """Wait and turn off."""
         try:
@@ -276,6 +350,11 @@ class SmartPlugManager:
                     self.username = username
                     self.password = password
                     self.name = f"plug_{plug_id}"
+                    # REST fields
+                    self.rest_off_url = rest_off_url
+                    self.rest_off_body = rest_off_body
+                    self.rest_method = rest_method
+                    self.rest_headers = rest_headers
 
             plug_info = PlugInfo()
             service = await self.get_service_for_plug(plug_info)
@@ -313,6 +392,10 @@ class SmartPlugManager:
                 plug.password,
                 printer_id,
                 temp_threshold,
+                rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
+                rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
+                rest_method=plug.rest_method if plug.plug_type == "rest" else None,
+                rest_headers=plug.rest_headers if plug.plug_type == "rest" else None,
             )
         )
         self._pending_off[plug.id] = task
@@ -327,6 +410,11 @@ class SmartPlugManager:
         password: str | None,
         printer_id: int,
         temp_threshold: int,
+        *,
+        rest_off_url: str | None = None,
+        rest_off_body: str | None = None,
+        rest_method: str | None = None,
+        rest_headers: str | None = None,
     ):
         """Poll temperature until below threshold, then turn off.
 
@@ -370,6 +458,11 @@ class SmartPlugManager:
                                 self.username = username
                                 self.password = password
                                 self.name = f"plug_{plug_id}"
+                                # REST fields
+                                self.rest_off_url = rest_off_url
+                                self.rest_off_body = rest_off_body
+                                self.rest_method = rest_method
+                                self.rest_headers = rest_headers
 
                         plug_info = PlugInfo()
                         service = await self.get_service_for_plug(plug_info)

+ 46 - 15
backend/app/services/spool_tag_matcher.py

@@ -70,24 +70,31 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
         material = tray_sub_brands
 
-    # Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
-    from backend.app.core.bambu_colors import resolve_bambu_color_name
-
+    # Upgrade subtype for gradient/multi-color variants based on tray_id_name color code.
+    # Firmware sends tray_sub_brands="PLA Basic" for gradients and "PLA Silk" for dual/tri-color,
+    # but the M*/T* suffix in tray_id_name distinguishes them:
+    #   A00-M* = PLA Basic Gradient, A05-M* = PLA Silk Dual Color, A05-T* = PLA Silk Tri Color
+    if tray_id_name and "-" in tray_id_name:
+        color_code = tray_id_name.split("-", 1)[1]
+        if color_code and color_code[0] == "M":
+            # M* = gradient for PLA Basic (A00), dual-color for PLA Silk (A05)
+            prefix = tray_id_name.split("-", 1)[0]
+            if prefix == "A05":
+                subtype = "Dual Color"
+            else:
+                subtype = "Gradient"
+        elif color_code and color_code[0] == "T":
+            subtype = "Tri Color"
+
+    # Resolve color name from the color catalog by hex. The catalog is the single
+    # source of truth — tray_id_name codes (e.g. "A17-R1") are NOT globally unique
+    # across material families (A17-R1 is PLA Translucent Cherry Pink; A01-R1 is
+    # PLA Matte Scarlet Red), so a suffix-based fallback would pick the wrong name.
+    # See #857.
     rgba = tray_color if tray_color else None
     color_name = None
 
-    # 1. Try Bambu color code mapping (e.g. "A06-D0" → "Titan Gray")
-    if tray_id_name:
-        color_name = resolve_bambu_color_name(tray_id_name)
-        logger.info("Color resolve: tray_id_name=%r → resolved=%r", tray_id_name, color_name)
-        # If not a known code, use tray_id_name directly (it may be a readable name)
-        if not color_name and "-" not in tray_id_name:
-            color_name = tray_id_name
-    else:
-        logger.info("Color resolve: tray_id_name is empty, rgba=%r", rgba)
-
-    # 2. Try color catalog lookup by hex color
-    if not color_name and rgba and len(rgba) >= 6:
+    if rgba and len(rgba) >= 6:
         hex_prefix = f"#{rgba[:6].upper()}"
         cat_result = await db.execute(
             select(ColorCatalogEntry)
@@ -99,6 +106,17 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         if entry:
             color_name = entry.color_name
 
+    # If tray_id_name is a human-readable name (no "-" code), fall back to it.
+    if not color_name and tray_id_name and "-" not in tray_id_name:
+        color_name = tray_id_name
+
+    logger.info(
+        "Color resolve: tray_id_name=%r rgba=%r → resolved=%r",
+        tray_id_name,
+        rgba,
+        color_name,
+    )
+
     # Look up core weight from spool catalog
     core_weight = 250  # Default for Bambu Lab plastic spools
     cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
@@ -198,6 +216,19 @@ async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spo
     elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
         material = tray_sub_brands
 
+    # Upgrade subtype for gradient/multi-color variants (same logic as create_spool_from_tray)
+    tray_id_name = tray_data.get("tray_id_name", "")
+    if tray_id_name and "-" in tray_id_name:
+        color_code = tray_id_name.split("-", 1)[1]
+        if color_code and color_code[0] == "M":
+            prefix = tray_id_name.split("-", 1)[0]
+            if prefix == "A05":
+                subtype = "Dual Color"
+            else:
+                subtype = "Gradient"
+        elif color_code and color_code[0] == "T":
+            subtype = "Tri Color"
+
     # Build query: active spools with no tag, matching brand + material + color
     query = (
         select(Spool)

+ 103 - 62
backend/app/services/spoolbuddy_ssh.py

@@ -3,14 +3,24 @@
 Instead of the daemon updating itself (fragile: permission issues, self-modifying
 code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
 update remotely: git fetch/checkout, pip install, systemctl restart.
+
+Uses `asyncssh` (pure-Python async SSH client) rather than shelling out to the
+OpenSSH `ssh` binary. The subprocess approach fails in Docker: both `ssh` and
+`ssh-keygen` call `getpwuid(getuid())` during startup and abort with
+"No user exists for uid <N>" when the container runs under a UID that is not
+listed in /etc/passwd (e.g. PUID=1000 on python:3.13-slim, which only has
+entries for root). asyncssh does all of its work in-process.
 """
 
 import asyncio
 import logging
 import os
-import shutil
 from pathlib import Path
 
+import asyncssh
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ed25519
+
 from backend.app.core.config import settings
 
 logger = logging.getLogger(__name__)
@@ -18,6 +28,19 @@ logger = logging.getLogger(__name__)
 SSH_USER = "spoolbuddy"
 DEFAULT_INSTALL_PATH = "/opt/bambuddy"
 
+# Project root — where the `.git` directory lives for native installs and for
+# Docker containers that bind-mount the repo. This is intentionally distinct
+# from `settings.base_dir`, which points at the persistent *data* directory
+# (e.g. `DATA_DIR=/app/data` in Docker) and therefore never contains `.git`.
+# `backend/app/services/spoolbuddy_ssh.py` → parents[3] = project root.
+_APP_DIR = Path(__file__).resolve().parents[3]
+
+# Note for Docker: asyncssh.connect() internally calls getpass.getuser() to
+# resolve the *local* username for ~/.ssh/config host matching. Under an
+# arbitrary PUID with no /etc/passwd entry this would raise OSError. The
+# Dockerfile sets LOGNAME/USER/HOME so getpass.getuser() succeeds via env-var
+# lookup before ever touching the passwd database.
+
 
 def _get_ssh_key_dir() -> Path:
     """Return (and create if needed) the directory for SpoolBuddy SSH keys."""
@@ -28,7 +51,14 @@ def _get_ssh_key_dir() -> Path:
 
 
 async def get_or_create_keypair() -> tuple[Path, Path]:
-    """Return (private_key_path, public_key_path), generating if missing."""
+    """Return (private_key_path, public_key_path), generating if missing.
+
+    Uses the in-process `cryptography` library instead of shelling out to
+    `ssh-keygen`. The subprocess approach fails inside Docker containers when
+    the image runs under an arbitrary UID (e.g. PUID=1001) that is not listed
+    in /etc/passwd — `ssh-keygen` calls `getpwuid()` for the current user's
+    home directory and aborts with "no user exists for uid <N>".
+    """
     key_dir = _get_ssh_key_dir()
     private_key = key_dir / "id_ed25519"
     public_key = key_dir / "id_ed25519.pub"
@@ -37,24 +67,26 @@ async def get_or_create_keypair() -> tuple[Path, Path]:
         return private_key, public_key
 
     logger.info("Generating SSH keypair for SpoolBuddy updates")
-    proc = await asyncio.create_subprocess_exec(
-        "ssh-keygen",
-        "-t",
-        "ed25519",
-        "-f",
-        str(private_key),
-        "-N",
-        "",  # no passphrase
-        "-C",
-        "bambuddy-spoolbuddy",
-        stdout=asyncio.subprocess.PIPE,
-        stderr=asyncio.subprocess.PIPE,
+    priv_obj = ed25519.Ed25519PrivateKey.generate()
+    pub_obj = priv_obj.public_key()
+
+    private_bytes = priv_obj.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.OpenSSH,
+        encryption_algorithm=serialization.NoEncryption(),
     )
-    _, stderr = await proc.communicate()
-    if proc.returncode != 0:
-        raise RuntimeError(f"ssh-keygen failed: {stderr.decode()[:200]}")
+    public_bytes = pub_obj.public_bytes(
+        encoding=serialization.Encoding.OpenSSH,
+        format=serialization.PublicFormat.OpenSSH,
+    )
+    # OpenSSH public format has no comment field by default; append one to match
+    # the previous ssh-keygen output so the authorized_keys line is identifiable.
+    public_line = public_bytes + b" bambuddy-spoolbuddy\n"
 
+    private_key.write_bytes(private_bytes)
     private_key.chmod(0o600)
+    public_key.write_bytes(public_line)
+
     logger.info("SSH keypair generated at %s", key_dir)
     return private_key, public_key
 
@@ -68,26 +100,37 @@ async def get_public_key() -> str:
 def detect_current_branch() -> str:
     """Detect the git branch Bambuddy is running on.
 
-    For native installs, reads from the .git directory.
-    For Docker (no .git), falls back to GIT_BRANCH env var, then "main".
+    Reads `.git/HEAD` directly from the application root (``_APP_DIR``) rather
+    than shelling out to `git`. The application root is deliberately distinct
+    from ``settings.base_dir``: in Docker, ``base_dir`` points at the data
+    volume (``/app/data``) which never contains ``.git``, while the repo is
+    bind-mounted (or COPYd) to ``/app``. This works for native installs,
+    bare Docker containers (no ``.git`` — fall through to the env var), and
+    Docker containers that bind-mount the repo (``.git`` is present, no
+    ``git`` binary required, and no ``getpwuid()`` call that could fail under
+    an arbitrary PUID).
+
+    Fallback order: ``.git/HEAD`` → ``GIT_BRANCH`` env var → ``"main"``.
     """
-    git_dir = settings.base_dir / ".git"
-    if git_dir.exists():
-        git_path = shutil.which("git") or "/usr/bin/git"
-        try:
-            import subprocess
-
-            result = subprocess.run(
-                [git_path, "rev-parse", "--abbrev-ref", "HEAD"],
-                cwd=str(settings.base_dir),
-                capture_output=True,
-                text=True,
-                timeout=5,
-            )
-            if result.returncode == 0 and result.stdout.strip():
-                return result.stdout.strip()
-        except Exception:
-            pass
+    git_path = _APP_DIR / ".git"
+    try:
+        if git_path.exists():
+            # Git worktrees use a file containing `gitdir: <path>` instead of
+            # a directory — follow the pointer.
+            if git_path.is_file():
+                content = git_path.read_text(encoding="utf-8").strip()
+                if content.startswith("gitdir:"):
+                    git_path = (_APP_DIR / content.removeprefix("gitdir:").strip()).resolve()
+
+            head_file = git_path / "HEAD"
+            if head_file.is_file():
+                head = head_file.read_text(encoding="utf-8").strip()
+                # Normal case: `ref: refs/heads/<branch>`.
+                # Detached HEAD stores a raw commit hash — fall through to env var.
+                if head.startswith("ref: refs/heads/"):
+                    return head.removeprefix("ref: refs/heads/").strip()
+    except OSError as exc:
+        logger.debug("Could not read .git/HEAD, falling back: %s", exc)
 
     return os.environ.get("GIT_BRANCH", "main")
 
@@ -100,36 +143,34 @@ async def _run_ssh_command(
 ) -> tuple[int, str, str]:
     """Execute a command on a SpoolBuddy device via SSH.
 
-    Returns (returncode, stdout, stderr).
+    Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
+    for the Docker/PUID rationale.
+
+    Returns (returncode, stdout, stderr). On connection failure the return
+    code is 255 (matching `ssh`'s own convention) and stderr carries the
+    asyncssh error message. On timeout the return code is -1.
     """
-    ssh_path = shutil.which("ssh") or "/usr/bin/ssh"
-    proc = await asyncio.create_subprocess_exec(
-        ssh_path,
-        "-i",
-        str(private_key),
-        "-o",
-        "StrictHostKeyChecking=no",
-        "-o",
-        "UserKnownHostsFile=/dev/null",
-        "-o",
-        "ConnectTimeout=10",
-        "-o",
-        "BatchMode=yes",
-        "-o",
-        "LogLevel=ERROR",
-        f"{SSH_USER}@{ip}",
-        command,
-        stdout=asyncio.subprocess.PIPE,
-        stderr=asyncio.subprocess.PIPE,
-    )
     try:
-        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+        async with asyncio.timeout(timeout):
+            async with asyncssh.connect(
+                host=ip,
+                username=SSH_USER,
+                client_keys=[str(private_key)],
+                known_hosts=None,  # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null
+                config=[],  # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs
+                connect_timeout=10,
+            ) as conn:
+                result = await conn.run(command, check=False)
     except TimeoutError:
-        proc.kill()
-        await proc.communicate()
         return -1, "", "SSH command timed out"
-
-    return proc.returncode, stdout.decode(), stderr.decode()
+    except (asyncssh.Error, OSError) as exc:
+        return 255, "", str(exc)
+
+    stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b"").decode(errors="replace")
+    stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b"").decode(errors="replace")
+    # asyncssh's exit_status is None when the remote closed without setting one
+    returncode = result.exit_status if result.exit_status is not None else 0
+    return returncode, stdout, stderr
 
 
 async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:

+ 321 - 91
backend/app/services/usage_tracker.py

@@ -158,6 +158,8 @@ class PrintSession:
     # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
     # Prevents usage loss when on_ams_change unlinks a spool mid-print
     spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
+    # AMS mapping from print command (captured at start, needed when auto-archive is off)
+    ams_mapping: list[int] | None = None
 
 
 # Module-level storage, keyed by printer_id
@@ -237,11 +239,10 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
 
     ams_raw = state.raw_data.get("ams", [])
     ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
-    if not ams_data:
-        logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
-        return
 
     tray_remain_start: dict[tuple[int, int], int] = {}
+    skipped_invalid: list[str] = []
+
     for ams_unit in ams_data:
         ams_id = int(ams_unit.get("id", 0))
         for tray in ams_unit.get("tray", []):
@@ -249,6 +250,35 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
             remain = tray.get("remain", -1)
             if isinstance(remain, int) and 0 <= remain <= 100:
                 tray_remain_start[(ams_id, tray_id)] = remain
+            else:
+                skipped_invalid.append(f"AMS{ams_id}-T{tray_id}(remain={remain})")
+
+    # Also capture VT (external) tray remain% — these are separate from AMS units
+    vt_tray_raw = state.raw_data.get("vt_tray") or []
+    if isinstance(vt_tray_raw, dict):
+        vt_tray_raw = [vt_tray_raw]
+    for vt in vt_tray_raw:
+        if not isinstance(vt, dict):
+            continue
+        vt_id = int(vt.get("id", 254))
+        # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)
+        vt_tray_id = vt_id - 254
+        remain = vt.get("remain", -1)
+        if isinstance(remain, int) and 0 <= remain <= 100:
+            tray_remain_start[(255, vt_tray_id)] = remain
+        else:
+            skipped_invalid.append(f"VT{vt_id}(remain={remain})")
+
+    if skipped_invalid:
+        logger.info(
+            "[UsageTracker] Skipped trays with invalid remain%% for printer %d: %s",
+            printer_id,
+            ", ".join(skipped_invalid),
+        )
+
+    if not ams_data and not vt_tray_raw:
+        logger.debug("[UsageTracker] No AMS or VT tray data for printer %d, skipping", printer_id)
+        return
 
     print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
 
@@ -305,6 +335,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
         tray_remain_start=tray_remain_start,
         tray_now_at_start=tray_now_at_start,
         spool_assignments=spool_assignments,
+        ams_mapping=data.get("ams_mapping"),
     )
     _active_sessions[printer_id] = session
 
@@ -349,6 +380,11 @@ async def on_print_complete(
     default_cost_str = await get_setting(db, "default_filament_cost")
     default_filament_cost = float(default_cost_str) if default_cost_str else 0.0
 
+    # Fall back to ams_mapping captured at print start (needed when auto-archive is off
+    # and the caller can't retrieve the mapping from _print_ams_mappings without archive_id)
+    if not ams_mapping and session and session.ams_mapping:
+        ams_mapping = session.ams_mapping
+
     logger.info(
         "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
         printer_id,
@@ -369,10 +405,21 @@ async def on_print_complete(
         )
 
     # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
-    if archive_id:
-        print_name = (
-            (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
-        )
+    print_name = (
+        (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
+    )
+
+    # When auto-archive is disabled (archive_id=None), try to find a 3MF by filename
+    # from the library or previous archives so we can still track filament usage.
+    threemf_path = None
+    if not archive_id:
+        from backend.app.core.config import settings as app_settings
+
+        search_filename = data.get("filename") or data.get("subtask_name") or (session.print_name if session else "")
+        if search_filename:
+            threemf_path = await _find_3mf_by_filename(printer_id, search_filename, db, app_settings.base_dir)
+
+    if archive_id or threemf_path:
         threemf_results = await _track_from_3mf(
             printer_id,
             archive_id,
@@ -388,6 +435,7 @@ async def on_print_complete(
             default_filament_cost=default_filament_cost,
             spool_assignments=session.spool_assignments if session else None,
             print_started_at=session.started_at if session else None,
+            threemf_path=threemf_path,
         )
         results.extend(threemf_results)
 
@@ -400,94 +448,123 @@ async def on_print_complete(
                 ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
             )
 
+            # Collect all trays to check: AMS trays + VT (external) trays
+            # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)
+            trays_to_check: list[tuple[int, int, int, str]] = []
+
             for ams_unit in ams_data:
                 ams_id = int(ams_unit.get("id", 0))
                 for tray in ams_unit.get("tray", []):
                     tray_id = int(tray.get("id", 0))
-                    key = (ams_id, tray_id)
+                    remain = tray.get("remain", -1)
+                    trays_to_check.append((ams_id, tray_id, remain, f"AMS{ams_id}-T{tray_id}"))
+
+            # VT (external) trays — same remain% delta logic
+            vt_tray_raw = state.raw_data.get("vt_tray") or []
+            if isinstance(vt_tray_raw, dict):
+                vt_tray_raw = [vt_tray_raw]
+            for vt in vt_tray_raw:
+                if not isinstance(vt, dict):
+                    continue
+                vt_id = int(vt.get("id", 254))
+                vt_tray_id = vt_id - 254  # 254→0, 255→1
+                remain = vt.get("remain", -1)
+                trays_to_check.append((255, vt_tray_id, remain, f"VT{vt_id}"))
 
-                    if key in handled_trays:
-                        continue  # Already tracked via 3MF
+            for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
+                key = (assign_ams_id, assign_tray_id)
 
-                    if key not in session.tray_remain_start:
-                        continue
+                if key in handled_trays:
+                    continue  # Already tracked via 3MF
 
-                    current_remain = tray.get("remain", -1)
-                    if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
-                        continue
+                if key not in session.tray_remain_start:
+                    continue
 
-                    start_remain = session.tray_remain_start[key]
-                    delta_pct = start_remain - current_remain
+                if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
+                    logger.info(
+                        "[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d",
+                        tray_label,
+                        current_remain,
+                        printer_id,
+                    )
+                    continue
 
-                    if delta_pct <= 0:
-                        continue  # No consumption or tray was refilled
+                start_remain = session.tray_remain_start[key]
+                delta_pct = start_remain - current_remain
 
-                    spool_id = await _resolve_spool_id_for_tray(
-                        printer_id=printer_id,
-                        ams_id=ams_id,
-                        tray_id=tray_id,
-                        db=db,
-                        spool_assignments_snapshot=session.spool_assignments,
-                        print_started_at=session.started_at,
-                    )
-                    if spool_id is None:
-                        continue
-
-                    # Load spool
-                    spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
-                    spool = spool_result.scalar_one_or_none()
-                    if not spool:
-                        continue
-
-                    # Compute weight consumed
-                    weight_grams = (delta_pct / 100.0) * spool.label_weight
-
-                    # Update spool
-                    spool.weight_used = (spool.weight_used or 0) + weight_grams
-                    spool.last_used = datetime.now(timezone.utc)
-
-                    # Calculate cost for this usage
-                    cost = None
-                    cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
-                    if cost_per_kg > 0:
-                        cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
-
-                    # Insert usage history record
-                    history = SpoolUsageHistory(
-                        spool_id=spool.id,
-                        printer_id=printer_id,
-                        print_name=session.print_name,
-                        weight_used=round(weight_grams, 1),
-                        percent_used=delta_pct,
-                        status=status,
-                        cost=cost,
-                        archive_id=archive_id,
-                    )
-                    db.add(history)
-
-                    handled_trays.add(key)
-                    results.append(
-                        {
-                            "spool_id": spool.id,
-                            "weight_used": round(weight_grams, 1),
-                            "percent_used": delta_pct,
-                            "ams_id": ams_id,
-                            "tray_id": tray_id,
-                            "material": spool.material,
-                            "cost": cost,
-                        }
-                    )
+                if delta_pct <= 0:
+                    continue  # No consumption or tray was refilled
 
+                spool_id = await _resolve_spool_id_for_tray(
+                    printer_id=printer_id,
+                    ams_id=assign_ams_id,
+                    tray_id=assign_tray_id,
+                    db=db,
+                    spool_assignments_snapshot=session.spool_assignments,
+                    print_started_at=session.started_at,
+                )
+                if spool_id is None:
                     logger.info(
-                        "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
-                        spool.id,
-                        weight_grams,
-                        delta_pct,
+                        "[UsageTracker] %s: no spool assigned, skipping fallback for printer %d",
+                        tray_label,
                         printer_id,
-                        ams_id,
-                        tray_id,
-                        status,
                     )
+                    continue
+
+                # Load spool
+                spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
+                spool = spool_result.scalar_one_or_none()
+                if not spool:
+                    continue
+
+                # Compute weight consumed
+                weight_grams = (delta_pct / 100.0) * spool.label_weight
+
+                # Update spool
+                spool.weight_used = (spool.weight_used or 0) + weight_grams
+                spool.last_used = datetime.now(timezone.utc)
+
+                # Calculate cost for this usage
+                cost = None
+                cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
+                if cost_per_kg > 0:
+                    cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
+
+                # Insert usage history record
+                history = SpoolUsageHistory(
+                    spool_id=spool.id,
+                    printer_id=printer_id,
+                    print_name=session.print_name,
+                    weight_used=round(weight_grams, 1),
+                    percent_used=delta_pct,
+                    status=status,
+                    cost=cost,
+                    archive_id=archive_id,
+                )
+                db.add(history)
+
+                handled_trays.add(key)
+                results.append(
+                    {
+                        "spool_id": spool.id,
+                        "weight_used": round(weight_grams, 1),
+                        "percent_used": delta_pct,
+                        "ams_id": assign_ams_id,
+                        "tray_id": assign_tray_id,
+                        "material": spool.material,
+                        "cost": cost,
+                    }
+                )
+
+                logger.info(
+                    "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d %s (AMS fallback, %s)",
+                    spool.id,
+                    weight_grams,
+                    delta_pct,
+                    printer_id,
+                    tray_label,
+                    status,
+                )
 
     if results:
         await db.commit()
@@ -510,9 +587,144 @@ async def on_print_complete(
     return results
 
 
+async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
+    """Try to find a 3MF file from library or a previous archive when the current archive has none.
+
+    This handles fallback archives (FTP download failed) where the 3MF may already exist
+    locally from a library upload or a previous successful print of the same file.
+    """
+    from pathlib import Path
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.library import LibraryFile
+
+    # Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf")
+    search_name = archive.filename or archive.print_name
+    if not search_name:
+        return None
+    # Normalize: strip path parts, get base name
+    search_name = search_name.split("/")[-1]
+    search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
+    if not search_base:
+        return None
+
+    # 1. Try library files matching the name (match base name at file boundary)
+    try:
+        lib_result = await db.execute(
+            select(LibraryFile)
+            .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
+            .where(LibraryFile.file_path.ilike("%.3mf"))
+            .order_by(LibraryFile.created_at.desc())
+            .limit(3)
+        )
+        for lib_file in lib_result.scalars().all():
+            lib_path = Path(lib_file.file_path)
+            candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
+            if candidate.exists() and candidate.suffix == ".3mf":
+                logger.info("[UsageTracker] 3MF fallback: found library file %s for archive %s", candidate, archive.id)
+                return candidate
+    except Exception as e:
+        logger.debug("[UsageTracker] 3MF fallback: library lookup failed: %s", e)
+
+    # 2. Try previous archives with the same filename that have a valid file_path
+    try:
+        prev_result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.id != archive.id)
+            .where(PrintArchive.printer_id == archive.printer_id)
+            .where(PrintArchive.file_path != "")
+            .where(PrintArchive.file_path.isnot(None))
+            .where(
+                PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
+            )
+            .order_by(PrintArchive.created_at.desc())
+            .limit(3)
+        )
+        for prev_archive in prev_result.scalars().all():
+            candidate = base_dir / prev_archive.file_path
+            if candidate.exists() and candidate.suffix == ".3mf":
+                logger.info(
+                    "[UsageTracker] 3MF fallback: found previous archive %s file for archive %s",
+                    prev_archive.id,
+                    archive.id,
+                )
+                return candidate
+    except Exception as e:
+        logger.debug("[UsageTracker] 3MF fallback: previous archive lookup failed: %s", e)
+
+    return None
+
+
+async def _find_3mf_by_filename(
+    printer_id: int,
+    filename: str,
+    db: AsyncSession,
+    base_dir,
+):
+    """Find a 3MF file by filename from library or previous archives.
+
+    Used when auto-archive is disabled and there's no archive_id, but we still
+    need the 3MF slicer data for filament usage tracking.
+    """
+    from pathlib import Path
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.library import LibraryFile
+
+    search_name = filename.split("/")[-1] if "/" in filename else filename
+    search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
+    if not search_base:
+        return None
+
+    # 1. Try library files matching the name
+    try:
+        lib_result = await db.execute(
+            select(LibraryFile)
+            .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
+            .where(LibraryFile.file_path.ilike("%.3mf"))
+            .order_by(LibraryFile.created_at.desc())
+            .limit(3)
+        )
+        for lib_file in lib_result.scalars().all():
+            lib_path = Path(lib_file.file_path)
+            candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
+            if candidate.exists() and candidate.suffix == ".3mf":
+                logger.info("[UsageTracker] 3MF (no-archive): found library file %s for '%s'", candidate, filename)
+                return candidate
+    except Exception as e:
+        logger.debug("[UsageTracker] 3MF (no-archive): library lookup failed: %s", e)
+
+    # 2. Try previous archives with a valid 3MF file_path
+    try:
+        prev_result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.printer_id == printer_id)
+            .where(PrintArchive.file_path != "")
+            .where(PrintArchive.file_path.isnot(None))
+            .where(
+                PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
+            )
+            .order_by(PrintArchive.created_at.desc())
+            .limit(3)
+        )
+        for prev_archive in prev_result.scalars().all():
+            candidate = base_dir / prev_archive.file_path
+            if candidate.exists() and candidate.suffix == ".3mf":
+                logger.info(
+                    "[UsageTracker] 3MF (no-archive): found previous archive %s file for '%s'",
+                    prev_archive.id,
+                    filename,
+                )
+                return candidate
+    except Exception as e:
+        logger.debug("[UsageTracker] 3MF (no-archive): previous archive lookup failed: %s", e)
+
+    return None
+
+
 async def _track_from_3mf(
     printer_id: int,
-    archive_id: int,
+    archive_id: int | None,
     status: str,
     print_name: str,
     handled_trays: set[tuple[int, int]],
@@ -525,6 +737,7 @@ async def _track_from_3mf(
     default_filament_cost: float = 0.0,
     spool_assignments: dict[tuple[int, int], int] | None = None,
     print_started_at: datetime | None = None,
+    threemf_path=None,
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
 
@@ -532,6 +745,9 @@ async def _track_from_3mf(
     For partial prints (failed/aborted), tries per-layer gcode data first,
     then falls back to linear scaling by progress.
 
+    When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
+    can be provided to still track filament usage from slicer data.
+
     Slot-to-tray mapping priority:
     1. Stored ams_mapping from print command (reprints/direct prints)
     2. MQTT mapping field from printer state (universal, all print sources)
@@ -540,20 +756,34 @@ async def _track_from_3mf(
     5. Position-based default using sorted available tray IDs (handles external spools)
     6. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
+    from pathlib import Path
+
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
     from backend.app.models.print_queue import PrintQueueItem
     from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
-    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive or not archive.file_path:
-        logger.info("[UsageTracker] 3MF: archive %s has no file_path, skipping", archive_id)
-        return []
+    file_path: Path | None = threemf_path
+
+    if file_path is None and archive_id:
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        archive = result.scalar_one_or_none()
+        if not archive:
+            logger.info("[UsageTracker] 3MF: archive %s not found, skipping", archive_id)
+            return []
 
-    file_path = app_settings.base_dir / archive.file_path
-    if not file_path.exists():
-        logger.info("[UsageTracker] 3MF: file not found: %s", file_path)
+        # Try archive's own file_path first
+        if archive.file_path:
+            candidate = app_settings.base_dir / archive.file_path
+            if candidate.exists():
+                file_path = candidate
+
+        # Fallback: find 3MF from library or a previous archive with the same filename
+        if file_path is None:
+            file_path = await _resolve_3mf_fallback(archive, db, app_settings.base_dir)
+
+    if file_path is None:
+        logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
         return []
 
     filament_usage = extract_filament_usage_from_3mf(file_path)
@@ -583,7 +813,7 @@ async def _track_from_3mf(
                 mapping_source = "mqtt"
 
     # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
-    if not slot_to_tray:
+    if not slot_to_tray and archive_id:
         queue_result = await db.execute(
             select(PrintQueueItem)
             .where(PrintQueueItem.archive_id == archive_id)

+ 4 - 0
backend/app/services/virtual_printer/manager.py

@@ -31,6 +31,8 @@ VIRTUAL_PRINTER_MODELS = {
     "BL-P001": "X1C",  # X1 Carbon
     "BL-P002": "X1",  # X1
     "C13": "X1E",  # X1E
+    # X2 Series
+    "N6": "X2D",  # X2D
     # P Series
     "C11": "P1P",  # P1P
     "C12": "P1S",  # P1S
@@ -59,6 +61,8 @@ MODEL_SERIAL_PREFIXES = {
     "BL-P001": "00M00A",  # X1C
     "BL-P002": "00M00A",  # X1
     "C13": "03W00A",  # X1E
+    # X2 Series
+    "N6": "20P90A",  # X2D (first 4 chars "20P9" match real serials)
     # P Series
     "C11": "01S00A",  # P1P
     "C12": "01P00A",  # P1S

+ 149 - 65
backend/app/services/virtual_printer/mqtt_server.py

@@ -21,6 +21,7 @@ MODEL_PRODUCT_NAMES = {
     "BL-P001": "X1 Carbon",
     "BL-P002": "X1",
     "C13": "X1E",
+    "N6": "X2D",
     "C11": "P1P",
     "C12": "P1S",
     "N7": "P2S",
@@ -201,6 +202,14 @@ class SimpleMQTTServer:
         self._running = False
         self._server = None
         self._clients: dict[str, asyncio.StreamWriter] = {}
+        # Per-client "effective serial" — the serial the slicer actually uses in
+        # device/{serial}/report|request topics. Populated from the first
+        # SUBSCRIBE/PUBLISH we see on a connection. This lets the VP respond on
+        # the topic the slicer is listening on even when it disagrees with
+        # self.serial (e.g. a stale Orca config that was bound to an older VP
+        # serial, or a printer entry that was re-pointed at the VP IP without
+        # updating the serial).
+        self._client_serials: dict[str, str] = {}
         self._status_push_task: asyncio.Task | None = None
         self._sequence_id = 0
 
@@ -311,6 +320,7 @@ class SimpleMQTTServer:
             except OSError:
                 pass  # Best-effort client connection cleanup; client may have disconnected
         self._clients.clear()
+        self._client_serials.clear()
 
         if self._server:
             try:
@@ -320,6 +330,22 @@ class SimpleMQTTServer:
                 pass  # Best-effort server shutdown; port may already be released
             self._server = None
 
+    @staticmethod
+    def _extract_serial_from_topic(topic: str) -> str | None:
+        """Pull the serial out of a `device/{serial}/report|request` topic.
+
+        Returns None if the topic doesn't match that shape — callers fall back
+        to self.serial in that case.
+        """
+        if not topic.startswith("device/"):
+            return None
+        rest = topic[len("device/") :]
+        # Expect "{serial}/report" or "{serial}/request" (possibly with suffixes).
+        slash = rest.find("/")
+        if slash <= 0:
+            return None
+        return rest[:slash]
+
     async def _periodic_status_push(self) -> None:
         """Send periodic status updates to all connected clients."""
         logger.info("Starting periodic status push task")
@@ -334,7 +360,8 @@ class SimpleMQTTServer:
                         if writer.is_closing():
                             disconnected.append(client_id)
                             continue
-                        await self._send_status_report(writer)
+                        serial = self._client_serials.get(client_id, self.serial)
+                        await self._send_status_report(writer, serial=serial)
                     except OSError as e:
                         logger.debug("Failed to push status to %s: %s", client_id, e)
                         disconnected.append(client_id)
@@ -342,6 +369,7 @@ class SimpleMQTTServer:
                 # Remove disconnected clients
                 for client_id in disconnected:
                     self._clients.pop(client_id, None)
+                    self._client_serials.pop(client_id, None)
 
             except asyncio.CancelledError:
                 break
@@ -384,14 +412,17 @@ class SimpleMQTTServer:
                     authenticated = await self._handle_connect(payload, writer)
                     if not authenticated:
                         break
-                    # Register client for periodic status pushes
+                    # Register client for periodic status pushes; start with
+                    # self.serial as the fallback until we learn the slicer's
+                    # preferred serial from the first SUBSCRIBE/PUBLISH.
                     self._clients[client_id] = writer
+                    self._client_serials[client_id] = self.serial
                 elif packet_type == 3:  # PUBLISH
                     if authenticated:
-                        await self._handle_publish(header[0], payload, writer)
+                        await self._handle_publish(header[0], payload, writer, client_id)
                 elif packet_type == 8:  # SUBSCRIBE
                     if authenticated:
-                        await self._handle_subscribe(payload, writer)
+                        await self._handle_subscribe(payload, writer, client_id)
                 elif packet_type == 12:  # PINGREQ
                     # Send PINGRESP
                     writer.write(bytes([0xD0, 0x00]))
@@ -405,8 +436,8 @@ class SimpleMQTTServer:
             logger.debug("MQTT client error: %s", e)
         finally:
             logger.debug("MQTT client disconnected: %s", client_id)
-            if client_id in self._clients:
-                del self._clients[client_id]
+            self._clients.pop(client_id, None)
+            self._client_serials.pop(client_id, None)
             try:
                 writer.close()
                 await writer.wait_closed()
@@ -493,7 +524,7 @@ class SimpleMQTTServer:
             await writer.drain()
             return False
 
-    async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter) -> None:
+    async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
         """Handle MQTT SUBSCRIBE packet."""
         try:
             # Parse packet ID
@@ -502,6 +533,7 @@ class SimpleMQTTServer:
             # Parse topic filters (just acknowledge them)
             idx = 2
             granted_qos = []
+            learned_serial: str | None = None
             while idx < len(payload):
                 topic_len = (payload[idx] << 8) | payload[idx + 1]
                 idx += 2
@@ -513,19 +545,36 @@ class SimpleMQTTServer:
                 logger.info("%sMQTT subscribe: %s QoS=%s", self._log_prefix, topic, requested_qos)
                 granted_qos.append(min(requested_qos, 1))  # Grant up to QoS 1
 
+                # Remember the serial the slicer is listening on so status/version
+                # responses go to a topic it actually subscribed to.
+                if learned_serial is None:
+                    extracted = self._extract_serial_from_topic(topic)
+                    if extracted:
+                        learned_serial = extracted
+
+            if learned_serial and learned_serial != self._client_serials.get(client_id):
+                if learned_serial != self.serial:
+                    logger.info(
+                        "%sMQTT client subscribed with serial %s (VP serial is %s) — adapting responses",
+                        self._log_prefix,
+                        learned_serial,
+                        self.serial,
+                    )
+                self._client_serials[client_id] = learned_serial
+
             # Send SUBACK
             suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF])
             suback += bytes(granted_qos)
             writer.write(suback)
             await writer.drain()
 
-            # Send initial status report after subscribe
-            await self._send_status_report(writer)
+            # Send initial status report after subscribe on the client's subscribed topic
+            await self._send_status_report(writer, serial=self._client_serials.get(client_id, self.serial))
 
         except (IndexError, ValueError, OSError) as e:
             logger.debug("MQTT SUBSCRIBE error: %s", e)
 
-    async def _send_status_report(self, writer: asyncio.StreamWriter) -> None:
+    async def _send_status_report(self, writer: asyncio.StreamWriter, serial: str | None = None) -> None:
         """Send a status report to the slicer after connection."""
         try:
             # Build status message matching Bambu printer format
@@ -603,16 +652,21 @@ class SimpleMQTTServer:
                 }
             }
 
-            await self._publish_to_report(writer, status, self.serial)
+            await self._publish_to_report(writer, status, serial or self.serial)
 
         except OSError as e:
             logger.error("Failed to send status report: %s", e)
 
-    async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
+    async def _send_version_response(
+        self, writer: asyncio.StreamWriter, sequence_id: str, serial: str | None = None
+    ) -> None:
         """Send version info response to the slicer."""
         try:
             product_name = MODEL_PRODUCT_NAMES.get(self.model, self.model or "X1 Carbon")
-            serial = self.serial
+            # The serial is embedded inside the module[].sn fields *and* used as
+            # the report topic. Use the client's effective serial so the slicer
+            # sees internal/topic consistency even when it differs from self.serial.
+            serial = serial or self.serial
 
             # Build version response matching OrcaSlicer expectations
             # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
@@ -715,7 +769,9 @@ class SimpleMQTTServer:
         except TimeoutError:
             logger.debug("MQTT drain timeout for %s — client may be busy", topic)
 
-    async def _send_print_response(self, writer: asyncio.StreamWriter, sequence_id: str, filename: str) -> None:
+    async def _send_print_response(
+        self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None
+    ) -> None:
         """Send project_file acknowledgment matching real Bambu printer behavior."""
         # Update state so periodic status pushes reflect preparation
         self._gcode_state = "PREPARE"
@@ -739,12 +795,12 @@ class SimpleMQTTServer:
                     "msg": 0,
                 }
             }
-            await self._publish_to_report(writer, response)
+            await self._publish_to_report(writer, response, serial or self.serial)
             logger.info("Sent project_file acknowledgment for %s", filename)
         except OSError as e:
             logger.error("Failed to send print response: %s", e)
 
-    async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
+    async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:
         """Handle MQTT PUBLISH packet."""
         try:
             # Parse topic
@@ -765,55 +821,83 @@ class SimpleMQTTServer:
 
             logger.info("MQTT publish to %s: %s...", topic, message[:100])
 
-            # Handle commands on device request topic
-            if f"device/{self.serial}/request" in topic:
-                try:
-                    data = json.loads(message)
-
-                    # Handle pushing command (status request)
-                    if "pushing" in data:
-                        pushing_data = data["pushing"]
-                        command = pushing_data.get("command", "")
-                        logger.info("MQTT pushing command: %s", command)
-
-                        if command == "pushall":
-                            # Slicer is requesting full status - send response
-                            logger.info("Sending status report in response to pushall")
-                            await self._send_status_report(writer)
-                        elif command == "start":
-                            # Slicer wants periodic status updates - send one now
-                            logger.info("Starting status push stream")
-                            await self._send_status_report(writer)
-
-                    # Handle info commands (get_version, etc.)
-                    if "info" in data:
-                        info_data = data["info"]
-                        command = info_data.get("command", "")
-                        sequence_id = info_data.get("sequence_id", "0")
-                        logger.info("MQTT info command: %s", command)
-
-                        if command == "get_version":
-                            await self._send_version_response(writer, sequence_id)
-
-                    # Handle print commands
-                    if "print" in data:
-                        print_data = data["print"]
-                        command = print_data.get("command", "")
-                        filename = print_data.get("subtask_name", "")
-                        sequence_id = print_data.get("sequence_id", "0")
-
-                        logger.info("MQTT print command: %s for %s", command, filename)
-
-                        if command == "project_file":
-                            # Respond with PREPARE status so slicer proceeds with FTP upload
-                            file_3mf = print_data.get("file", filename)
-                            await self._send_print_response(writer, sequence_id, file_3mf)
-
-                            if self.on_print_command:
-                                await self._notify_print_command(filename, print_data)
-
-                except json.JSONDecodeError:
-                    pass  # Non-JSON payloads on request topic are safely ignored
+            # Only handle publishes on *some* device/.../request topic. The
+            # serial is taken from the topic rather than compared against
+            # self.serial: the client is already authenticated via the access
+            # code, and Orca/BambuStudio may have a cached serial that differs
+            # from the VP's computed self.serial (#927). Use the topic's serial
+            # for all responses so they land on the topic the slicer subscribed
+            # to.
+            if not topic.startswith("device/") or "/request" not in topic:
+                return
+
+            client_serial = self._extract_serial_from_topic(topic) or self.serial
+            if client_serial and client_serial != self._client_serials.get(client_id):
+                if client_serial != self.serial:
+                    logger.info(
+                        "%sMQTT client publishing with serial %s (VP serial is %s) — adapting responses",
+                        self._log_prefix,
+                        client_serial,
+                        self.serial,
+                    )
+                self._client_serials[client_id] = client_serial
+
+            try:
+                # Some slicer builds (observed with OrcaSlicer on Linux, #927)
+                # include the C-string null terminator in the MQTT payload
+                # length, so the decoded message ends with \x00. Real brokers
+                # pass the bytes through; strict json.loads raises "Extra data"
+                # and every pushall/get_version/project_file silently dropped.
+                data = json.loads(message.rstrip("\x00 \r\n\t"))
+            except json.JSONDecodeError as e:
+                logger.debug(
+                    "MQTT publish JSON decode failed: %s (payload=%r)",
+                    e,
+                    message[:200],
+                )
+                return
+
+            # Handle pushing command (status request)
+            if "pushing" in data:
+                pushing_data = data["pushing"]
+                command = pushing_data.get("command", "")
+                logger.info("MQTT pushing command: %s", command)
+
+                if command == "pushall":
+                    # Slicer is requesting full status - send response
+                    logger.info("Sending status report in response to pushall")
+                    await self._send_status_report(writer, serial=client_serial)
+                elif command == "start":
+                    # Slicer wants periodic status updates - send one now
+                    logger.info("Starting status push stream")
+                    await self._send_status_report(writer, serial=client_serial)
+
+            # Handle info commands (get_version, etc.)
+            if "info" in data:
+                info_data = data["info"]
+                command = info_data.get("command", "")
+                sequence_id = info_data.get("sequence_id", "0")
+                logger.info("MQTT info command: %s", command)
+
+                if command == "get_version":
+                    await self._send_version_response(writer, sequence_id, serial=client_serial)
+
+            # Handle print commands
+            if "print" in data:
+                print_data = data["print"]
+                command = print_data.get("command", "")
+                filename = print_data.get("subtask_name", "")
+                sequence_id = print_data.get("sequence_id", "0")
+
+                logger.info("MQTT print command: %s for %s", command, filename)
+
+                if command == "project_file":
+                    # Respond with PREPARE status so slicer proceeds with FTP upload
+                    file_3mf = print_data.get("file", filename)
+                    await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
+
+                    if self.on_print_command:
+                        await self._notify_print_command(filename, print_data)
 
         except (IndexError, ValueError, OSError) as e:
             logger.debug("MQTT PUBLISH error: %s", e)

+ 9 - 2
backend/app/utils/printer_models.py

@@ -19,6 +19,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2D Pro": "H2D Pro",
     "Bambu Lab H2C": "H2C",
     "Bambu Lab H2S": "H2S",
+    "Bambu Lab X2D": "X2D",
 }
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -33,6 +34,8 @@ PRINTER_MODEL_ID_MAP = {
     "P1S": "P1S",
     # P2 series
     "P2S": "P2S",
+    # X2 series
+    "N6": "X2D",
     # A1 series
     "A11": "A1",
     "A12": "A1 Mini",
@@ -51,7 +54,7 @@ PRINTER_MODEL_ID_MAP = {
 
 # Rod/rail type classification for maintenance tasks.
 # Carbon rods: X1, P1 series (CoreXY with carbon fiber rods)
-# Steel rods: P2S series (hardened steel linear shafts)
+# Steel rods: P2S, X2D series (hardened steel linear shafts)
 # Linear rails: A1, H2 series (linear rail motion system)
 # Values must be uppercase with spaces stripped for normalized comparison.
 CARBON_ROD_MODELS = frozenset(
@@ -73,8 +76,10 @@ STEEL_ROD_MODELS = frozenset(
     [
         # Display names (uppercase, no spaces)
         "P2S",
+        "X2D",
         # Internal codes
         "N7",  # P2S
+        "N6",  # X2D
     ]
 )
 
@@ -110,6 +115,7 @@ ETHERNET_MODELS = frozenset(
         # Display names (uppercase, no spaces)
         "X1C",
         "X1E",
+        "X2D",
         "P1S",
         "P2S",
         "H2D",
@@ -119,6 +125,7 @@ ETHERNET_MODELS = frozenset(
         # Internal codes
         "C11",  # X1C
         "C13",  # X1E
+        "N6",  # X2D
         "P1S",  # P1S
         "O1D",  # H2D
         "O1E",  # H2D Pro
@@ -143,7 +150,7 @@ def get_rod_type(model: str | None) -> str | None:
 
     Returns:
         "carbon" for X1/P1 series (carbon fiber rods),
-        "steel_rod" for P2S series (hardened steel rods),
+        "steel_rod" for P2S/X2D series (hardened steel rods),
         "linear_rail" for A1/H2 series (linear rails),
         None for unknown models.
     """

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

@@ -296,6 +296,32 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         if not physical_extruder_map or len(physical_extruder_map) <= 1:
             return None  # Single-nozzle printer
 
+        # Check if only one extruder is active.
+        # If so, we can skip the mapping and just assign all slots to that extruder.
+        # extruder_nozzle_stats format: ["Standard#0|High Flow#0", "Standard#1"]
+        # Each entry = one extruder. Format: <NozzleVolumeType>#<count>[|...]
+        # #N is the count of physical nozzles of that type (0 = none installed).
+        # Types: Standard, High Flow, Hybrid, TPU High Flow
+
+        active_extruders = []
+        for stats_str in data.get("extruder_nozzle_stats") or []:
+            nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
+            active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
+
+        if sum(active_extruders) == 1:
+            nozzle_mapping: dict[int, int] = {}
+            active_idx = active_extruders.index(1)
+            target_extruder = int(physical_extruder_map[active_idx])
+            if "Metadata/slice_info.config" in zf.namelist():
+                si_content = zf.read("Metadata/slice_info.config").decode()
+                si_root = ET.fromstring(si_content)
+                for filament_elem in si_root.findall(".//filament"):
+                    try:
+                        nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
+                    except (ValueError, TypeError):
+                        pass
+            return nozzle_mapping or None
+
         # Priority 1: Use group_id from slice_info filament elements.
         # This reflects the actual slicer assignment (respects "Auto For Flush").
         nozzle_mapping: dict[int, int] = {}
@@ -414,3 +440,74 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
         pass  # Return whatever usage data was collected before the error
 
     return filament_usage
+
+
+def inject_gcode_into_3mf(
+    source_path: Path,
+    plate_id: int,
+    start_gcode: str | None,
+    end_gcode: str | None,
+):
+    """Create a temp copy of a 3MF with G-code injected at start/end.
+
+    Args:
+        source_path: Path to the original 3MF file.
+        plate_id: Plate number (1-indexed) to inject into.
+        start_gcode: G-code to prepend, or None.
+        end_gcode: G-code to append, or None.
+
+    Returns:
+        Path to temp file with injected G-code, or None if injection failed.
+        Caller is responsible for cleaning up the temp file.
+    """
+    import tempfile
+
+    if not start_gcode and not end_gcode:
+        return None
+
+    try:
+        # Find the target gcode file inside the 3MF
+        with zipfile.ZipFile(source_path, "r") as zf:
+            all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
+            if not all_gcode:
+                return None
+
+            # Try plate-specific gcode file first
+            target_gcode = None
+            plate_pattern = f"plate_{plate_id}.gcode"
+            for f in all_gcode:
+                if f.endswith(plate_pattern):
+                    target_gcode = f
+                    break
+
+            # Fall back to first gcode file
+            if target_gcode is None:
+                target_gcode = all_gcode[0]
+
+            # Read and modify gcode content
+            gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
+
+            if start_gcode:
+                gcode_content = start_gcode + "\n" + gcode_content
+            if end_gcode:
+                gcode_content = gcode_content.rstrip("\n") + "\n" + end_gcode + "\n"
+
+            # Write modified 3MF to temp file
+            with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
+                tmp_path = Path(tmp.name)
+
+            with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
+                for item in zf.namelist():
+                    info = zf.getinfo(item)
+                    if item == target_gcode:
+                        zf_write.writestr(info, gcode_content.encode("utf-8"))
+                    else:
+                        zf_write.writestr(info, zf.read(item))
+
+        return tmp_path
+
+    except Exception:
+        # Clean up temp file on error
+        if "tmp_path" in locals() and tmp_path.exists():
+            tmp_path.unlink(missing_ok=True)
+        return None

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff