Browse Source

Merge pull request #536 from maziggy/0.2.1

# Bambuddy v0.2.1

## New Features

- **Multiple Virtual Printers** — Run multiple virtual printers per installation, each with a dedicated bind IP and fully independent FTP, MQTT, SSDP, and Bind servers. Full CRUD API and React UI for creating, editing, and deleting instances. Supports all four modes, any of the 11 printer models, per-instance TLS certificates, and individual network interface override.
- **Background Print Dispatch** ([#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background. FTP uploads are decoupled from API request latency, so the UI responds immediately. Real-time progress via WebSocket with per-job upload bars, status badges, and a cancel button.
- **Filament Cost Tracking** ([#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Archive cards display total cost, the print modal shows a real-time cost preview, and the inventory table includes a sortable cost/kg column. A global default filament cost setting provides a fallback. Contributed by @Keybored02.
- **Filament Override for Model-Based Queue** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling to "any printer", you can now override the 3MF's original filament choices. The scheduler matches against overridden type and color, preferring printers with exact color matches.
- **Bulk Spool Addition & Stock Spools** ([#480](https://github.com/maziggy/bambuddy/issues/480)) — Quick Add mode for inventorying filament without a slicer profile. Quantity field creates up to 100 identical spools in one transaction. Stock spools shown with an amber badge. New grouping toggle collapses identical unused spools into expandable rows.
- **Developer LAN Mode Detection** — Automatically detects printers without Developer LAN Mode enabled and shows a persistent warning banner with affected printer names and a link to Bambu Lab's documentation.
- **Clear Plate Permission** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — New `printers:clear_plate` permission so users can confirm plate clears without needing full `printers:control`.
- **Full-Page Group Permission Editor** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — Replaced the cramped permission modal with a dedicated full-page editor featuring a 2-column grid, search/filtering, and bulk actions.
- **External Materials & Brands in Spool Form** ([#455](https://github.com/maziggy/bambuddy/issues/455)) — The spool form now allows using custom materials and brands not in the Bambu filament catalog when adding or editing spools.
- **Energy Cost 3-Decimal Precision** ([#416](https://github.com/maziggy/bambuddy/issues/416)) — Energy cost values now support 3 decimal places (e.g., €0.123/kWh) instead of 2, for more accurate cost tracking in regions with fractional energy pricing.
- **Include Beta Updates Setting** — New toggle to opt in to beta/prerelease update notifications. Default: off (stable only).
- **Virtual Printer Dual Bind/Detect Ports** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — The slicer bind/detect handshake now listens on both ports 3000 and 3002 for compatibility across BambuStudio/OrcaSlicer versions.

## Fixed

### Print Queue & Scheduling
- **Queue Empty After Container Restart** ([#523](https://github.com/maziggy/bambuddy/issues/523)) — SQLite WAL not checkpointed on shutdown. Now runs `PRAGMA wal_checkpoint(TRUNCATE)` before exit.
- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — Queue API now uses OR logic to also return unassigned items whose `target_model` matches the printer's model.
- **Queue "Any Model" Jobs Stuck in "Waiting" After Plate Clear** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — MQTT raw AMS data returned IDs as strings; fixed type casting.
- **"Queue to Any Printer" Ignores Filament Color Override** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — Now requires at least 1 color match when filament overrides specify colors.
- **"Clear Plate & Start Next" Ignores Filament Override Color** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — Frontend now checks override colors, mirroring the backend's color matching logic.
- **"Clear Plate & Start Next" Shown on Printers Without Correct Filament** ([#527](https://github.com/maziggy/bambuddy/issues/527)) — Widget now filters queue items by filament compatibility.
- **Virtual Printer Queue Sends Wrong Plate ID** ([#529](https://github.com/maziggy/bambuddy/issues/529)) — `plate_id` was always `1`; now extracts plate index from 3MF's `slice_info.config`.
- **Virtual Printer Queue Ignores AMS Mapping** ([#529](https://github.com/maziggy/bambuddy/issues/529)) — Scheduler now computes AMS mapping for all queue items that lack one.
- **Virtual Printer Queue Mode Doesn't Assign Printer** ([#518](https://github.com/maziggy/bambuddy/issues/518)) — Now assigns the VP's target printer or falls back to model-based scheduling.
- **Unnecessary Target Model Selector on "Any" Tab** ([#528](https://github.com/maziggy/bambuddy/issues/528)) — Dropdown hidden when sliced model is known.
- **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — Library files now display the original human-readable name.
- **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Used wrong printer object type; fixed to use DB query.
- **Phantom Prints From Lingering SD Card Files** ([#477](https://github.com/maziggy/bambuddy/issues/477)) — Fixed retry loop, added cleanup on `start_print()` failure.

### AMS & Filament
- **AMS History Chart Shows Wrong Time Range** ([#535](https://github.com/maziggy/bambuddy/issues/535)) — X axis now pinned to the full requested time range.
- **Manual Spool Weight Overwritten by AMS Auto-Sync** ([#525](https://github.com/maziggy/bambuddy/issues/525)) — Added `weight_locked` flag for manual weight entries.
- **AMS Slot Auto-Config Falls Back to Generic Instead of Spool's Preset** — Fixed PFUS*/P* custom preset handling and "sticky" generic ID reuse.
- **PCTG Filament Misidentified as PC** ([#478](https://github.com/maziggy/bambuddy/issues/478)) — Reordered material parser; added PCTG to all material type arrays.
- **External Spool Mapping Inverted on H2C** ([#492](https://github.com/maziggy/bambuddy/issues/492)) — Now uses `active_extruder` on dual-nozzle printers.
- **External Spool Assignments Lost on Restart** ([#493](https://github.com/maziggy/bambuddy/issues/493)) — Now looks up external assignments in `vt_tray` data.
- **Filament Fill Level Wrong / Missing for External Spools** ([#496](https://github.com/maziggy/bambuddy/issues/496)) — Fixed priority chain, removed misleading gray fallback bar, added AMS remain fallback for external spools.
- **Wrong AMS Unit Displayed With Dual AMS on P2S** ([#420](https://github.com/maziggy/bambuddy/issues/420)) — Now cross-references `tray_now` with MQTT mapping field for multi-AMS printers.
- **H2D Tray Disambiguation Produces Bogus IDs for AMS-HT** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Fixed AMS-HT global tray ID computation across multiple code paths.
- **Usage Tracking Wrong Spool on Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Now decodes MQTT mapping field from snow encoding for universal spool resolution.
- **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — Snapshots assignments at print start; splits weight across trays using per-layer gcode data.
- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — Brand and Subtype now mandatory.

### Printing & Archives
- **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Removed redundant cost paths; now computes from current session only.
- **Finish Photo Not Captured When Archive Has No Source 3MF** ([#484](https://github.com/maziggy/bambuddy/issues/484)) — Falls back to `archive/{id}/` directory.
- **Finish Photo Not Shown for BambuStudio Prints** ([#474](https://github.com/maziggy/bambuddy/issues/474)) — Fixed path resolution for empty `file_path`.
- **Archive Endpoints Crash With "Is a directory"** ([#475](https://github.com/maziggy/bambuddy/issues/475)) — Replaced `.exists()` with `.is_file()` across 15 locations.
- **File Manager Rename Doesn't Update Displayed Name** ([#460](https://github.com/maziggy/bambuddy/issues/460)) — Rename now also updates `print_name` in file metadata.
- **"Open in Slicer" Fails for Special Characters** — Sanitizes `/`, `\`, `?`, `#` to `_` in slicer download URLs.
- **"Open in Slicer" Fails When Auth Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — Added short-lived, single-use download tokens with platform-specific URL formats.
- **Non-Actionable HMS Errors Triggering Notifications** ([#470](https://github.com/maziggy/bambuddy/issues/470)) — Suppresses MQTT auth failures, cloud connection failures, and user cancellations.
- **ntfy Notifications Fail With "Illegal header value"** ([#466](https://github.com/maziggy/bambuddy/issues/466)) — Escapes newlines in HTTP headers; retries without image when attachments disabled.
- **Print Bed Cooled Notification Never Triggers** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — Moved bed cooldown monitor before archive lookup early-return.
- **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — Fixed TOCTOU race between MQTT callback and asyncio thread.

### Permissions & UI
- **"Power Off Printer" Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — Checkbox now disabled for users without `printers:control`.
- **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — Now uses `hasPermission('settings:read')` instead of hardcoded role check.
- **Settings Text Fields Reset While Typing** — Removed stale state overwrite from auto-save handler.
- **Spool Form Profile Dropdown Truncates Long Names** ([#534](https://github.com/maziggy/bambuddy/issues/534)) — Removed inline filament codes; widened modal.

### Timestamps & Dates
- **Timestamps Off by Timezone Offset in Non-UTC Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — Replaced `datetime.now()` / `datetime.utcnow()` with `datetime.now(timezone.utc)` across 16 files (~80 call sites). Fixed 13 frontend `new Date()` calls.
- **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — Now respects date format setting.
- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — Now uses shared `formatSlotLabel()` utility.

### Docker & Infrastructure
- **FTP Proxy Cannot Bind to Port 990 in Docker** — `cap_add: NET_BIND_SERVICE` didn't reliably propagate with the `user:` directive. Now sets file capabilities via `setcap` in the Dockerfile.
- **Support Bundle Leaking Personal Data** ([#473](https://github.com/maziggy/bambuddy/issues/473)) — Now queries DB for sensitive values; uses httpx `auth` parameter for smart plug credentials; redacts serial numbers fully.
- **IP Addresses Not Redacted From Support Bundle** — Added IPv4 regex that preserves firmware version strings.

### AMS Mapping & Dual-Nozzle (H2D/H2C)
- **7 AMS Mapping Fixes** — Hard nozzle filter, external spool extruder, `ams_extruder_map` race condition, `group_id` priority, dual-nozzle detection, AMS-HT `globalTrayId`, dropdown nozzle filter.
- **Nozzle Mapping Uses Wrong Source in 3MF** — Now uses `group_id` as primary source instead of `filament_nozzle_map`.
- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — Now uses persistent `_is_dual_nozzle` flag.
- **Cancelled Print Usage Tracking Uses Stale Progress** — Now captures last valid progress/layer during printing.
- **Developer Mode Detection Always Reports Null** — Fixed `int(value, 16)` TypeError on integer MQTT field.

### Other
- **Wrong Documentation Link for "Lubricate Carbon Rods" on P2S** ([#490](https://github.com/maziggy/bambuddy/issues/490))
- **"Unknown stage (74)" on H2D** — Mapped stage 74 ("Preparing") and 77 ("Preparing AMS").
- **Color Tooltip Clipped Behind Adjacent Swatches** — Added z-index.
- **PAUSED State Never Matched** ([#447](https://github.com/maziggy/bambuddy/issues/447)) — Removed dead `PAUSED` checks; printer only sends `PAUSE`.

## Improved

- **Queue API Returns More Print Metadata** ([#524](https://github.com/maziggy/bambuddy/issues/524)) — Endpoints now include `filament_type`, `filament_color`, `layer_height`, `nozzle_diameter`, and `sliced_for_model`.
- **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — `/api/v1/filaments/` → `/api/v1/filament-catalog/` for clarity.
- **Clear Plate Dot Indicator on Sidebar** — Yellow dot on Printers icon when plate clear is needed.
- **Inventory Sidebar Always Visible** — Shows Spoolman iframe or internal inventory based on mode.
- **Queue Page Mobile Responsiveness** — Improved layout for mobile devices.
- **Storage Size Formatting & Overdue Queue Items** — UI polish and i18n fixes.
- **Brazilian Portuguese Translation** — Full translation contributed by @wreuel.
- **H2D Pro Printer Image** — Added H2D Pro image and model mapping. Contributed by @cimdDev.
- **Native Updater Script** — Systemd-based updater with backup and safety checks. Contributed by @uefigs139.
- **Docker Install iptables Option** — Option to add iptables port redirect during Docker install. Contributed by @wreuel.
- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — TypeScript type checking and ESLint on staged frontend files.
- **Show Remaining Spool Weight** — Displays remaining weight instead of used weight. Contributed by @Keybored02.

## Community Contributors

Thank you to everyone who contributed to this release:

- **@Keybored02** — Filament cost tracking, remaining weight display, spool form fixes
- **@wreuel** — Brazilian Portuguese translation, Docker iptables install option
- **@cimdDev** — H2D Pro printer image
- **@uefigs139** — Native updater script
- **@aneopsy** — Queue page mobile responsiveness, page refactoring, UI improvements
MartinNYHC 2 months ago
parent
commit
eee901fc20
100 changed files with 11215 additions and 2219 deletions
  1. 1 0
      .github/FUNDING.yml
  2. 2 0
      .gitignore
  3. 7 0
      .pre-commit-config.yaml
  4. 123 1
      CHANGELOG.md
  5. 1 0
      CONTRIBUTING.md
  6. 7 0
      Dockerfile
  7. 13 0
      README.md
  8. 1 0
      SECURITY.md
  9. 3 3
      backend/app/api/routes/ams_history.py
  10. 226 162
      backend/app/api/routes/archives.py
  11. 32 0
      backend/app/api/routes/background_dispatch.py
  12. 1 1
      backend/app/api/routes/filaments.py
  13. 80 2
      backend/app/api/routes/inventory.py
  14. 85 130
      backend/app/api/routes/library.py
  15. 3 3
      backend/app/api/routes/maintenance.py
  16. 8 8
      backend/app/api/routes/notifications.py
  17. 72 6
      backend/app/api/routes/print_queue.py
  18. 220 47
      backend/app/api/routes/printers.py
  19. 1 1
      backend/app/api/routes/projects.py
  20. 4 3
      backend/app/api/routes/settings.py
  21. 6 6
      backend/app/api/routes/smart_plugs.py
  22. 388 0
      backend/app/api/routes/spoolbuddy.py
  23. 59 15
      backend/app/api/routes/support.py
  24. 36 2
      backend/app/api/routes/updates.py
  25. 380 0
      backend/app/api/routes/virtual_printers.py
  26. 10 0
      backend/app/api/routes/websocket.py
  27. 44 7
      backend/app/core/auth.py
  28. 1 1
      backend/app/core/config.py
  29. 115 0
      backend/app/core/database.py
  30. 3 0
      backend/app/core/permissions.py
  31. 327 263
      backend/app/main.py
  32. 2 0
      backend/app/models/__init__.py
  33. 5 0
      backend/app/models/print_queue.py
  34. 9 1
      backend/app/models/spool.py
  35. 2 0
      backend/app/models/spool_usage_history.py
  36. 29 0
      backend/app/models/spoolbuddy_device.py
  37. 28 0
      backend/app/models/virtual_printer.py
  38. 11 5
      backend/app/schemas/archive.py
  39. 1 0
      backend/app/schemas/library.py
  40. 8 0
      backend/app/schemas/print_queue.py
  41. 2 0
      backend/app/schemas/printer.py
  42. 2 0
      backend/app/schemas/settings.py
  43. 11 0
      backend/app/schemas/spool.py
  44. 1 0
      backend/app/schemas/spool_usage.py
  45. 102 0
      backend/app/schemas/spoolbuddy.py
  46. 10 43
      backend/app/services/archive.py
  47. 881 0
      backend/app/services/background_dispatch.py
  48. 6 6
      backend/app/services/bambu_cloud.py
  49. 56 9
      backend/app/services/bambu_ftp.py
  50. 170 20
      backend/app/services/bambu_mqtt.py
  51. 6 6
      backend/app/services/discovery.py
  52. 3 3
      backend/app/services/failure_analysis.py
  53. 20 20
      backend/app/services/mqtt_relay.py
  54. 3 3
      backend/app/services/mqtt_smart_plug.py
  55. 97 2
      backend/app/services/network_utils.py
  56. 13 5
      backend/app/services/notification_service.py
  57. 141 25
      backend/app/services/print_scheduler.py
  58. 2 2
      backend/app/services/printer_manager.py
  59. 7 7
      backend/app/services/smart_plug_manager.py
  60. 4 12
      backend/app/services/tasmota.py
  61. 438 39
      backend/app/services/usage_tracker.py
  62. 48 31
      backend/app/services/virtual_printer/bind_server.py
  63. 7 4
      backend/app/services/virtual_printer/certificate.py
  64. 8 2
      backend/app/services/virtual_printer/ftp_server.py
  65. 532 523
      backend/app/services/virtual_printer/manager.py
  66. 38 16
      backend/app/services/virtual_printer/mqtt_server.py
  67. 22 24
      backend/app/services/virtual_printer/ssdp_server.py
  68. 43 26
      backend/app/services/virtual_printer/tcp_proxy.py
  69. 40 22
      backend/app/utils/threemf_tools.py
  70. 16 0
      backend/tests/conftest.py
  71. 243 0
      backend/tests/integration/test_background_dispatch_api.py
  72. 412 0
      backend/tests/integration/test_cost_statistics.py
  73. 3 3
      backend/tests/integration/test_endpoint_auth.py
  74. 9 9
      backend/tests/integration/test_filaments_api.py
  75. 326 0
      backend/tests/integration/test_inventory_assign.py
  76. 224 0
      backend/tests/integration/test_printers_api.py
  77. 322 0
      backend/tests/unit/services/test_background_dispatch.py
  78. 44 0
      backend/tests/unit/services/test_bambu_ftp.py
  79. 1098 0
      backend/tests/unit/services/test_bambu_mqtt.py
  80. 5 5
      backend/tests/unit/services/test_smart_plug_manager.py
  81. 5 4
      backend/tests/unit/services/test_tasmota.py
  82. 294 0
      backend/tests/unit/services/test_usage_tracker.py
  83. 414 427
      backend/tests/unit/services/test_virtual_printer.py
  84. 92 0
      backend/tests/unit/test_archive_file_path_guard.py
  85. 2 0
      backend/tests/unit/test_archive_filtering.py
  86. 184 0
      backend/tests/unit/test_bulk_spool_create.py
  87. 776 0
      backend/tests/unit/test_cost_tracking.py
  88. 76 0
      backend/tests/unit/test_permissions.py
  89. 363 11
      backend/tests/unit/test_scheduler_ams_mapping.py
  90. 215 0
      backend/tests/unit/test_scheduler_filament_override.py
  91. 101 0
      backend/tests/unit/test_support_helpers.py
  92. 733 0
      backend/tests/unit/test_usage_tracker.py
  93. 1 0
      docker-compose.yml
  94. 20 7
      docker-publish.sh
  95. 25 0
      docs/ams_slot_printer_matrix.txt
  96. BIN
      docs/screenshots/settings-virtual-printer.png
  97. 126 236
      frontend/package-lock.json
  98. 3 0
      frontend/package.json
  99. BIN
      frontend/public/img/printers/h2dpro.png
  100. 16 0
      frontend/src/App.tsx

+ 1 - 0
.github/FUNDING.yml

@@ -1 +1,2 @@
 github: maziggy
+ko_fi: maziggy

+ 2 - 0
.gitignore

@@ -66,3 +66,5 @@ data/
 
 # Security scan output
 *.sarif
+
+debug_logs/

+ 7 - 0
.pre-commit-config.yaml

@@ -42,3 +42,10 @@ repos:
         pass_filenames: false
         types: [python]
         files: ^backend/app/
+      - id: frontend-typecheck
+        name: TypeScript type check
+        entry: bash -c 'cd frontend && npx tsc --noEmit'
+        language: system
+        pass_filenames: false
+        files: ^frontend/src/
+        types_or: [ts, tsx]

+ 123 - 1
CHANGELOG.md

@@ -2,6 +2,128 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.2.1] - 2026-02-27
+
+### Fixed
+- **FTP Proxy Cannot Bind to Port 990 in Docker** — The `cap_add: NET_BIND_SERVICE` in docker-compose.yml didn't reliably propagate to the Python process when running as a non-root user (`user:` directive), depending on the container runtime's ambient capability support. Now sets the file capability directly on the Python binary in the Dockerfile via `setcap`, which the kernel honors regardless of runtime configuration.
+- **AMS History Chart Shows Wrong Time Range** ([#535](https://github.com/maziggy/bambuddy/issues/535)) — The AMS temperature/humidity chart X axis was fitted to only the data points present (`dataMin`/`dataMax`), not the selected time window. When the printer was offline for part of the period, shorter views (e.g., 6h) appeared compressed to only the portion with data (e.g., 1.5h). Now pins the X axis domain to the full requested time range (e.g., now−6h to now), pads the data edges so the line extends across the full window, and connects through null values so the chart always shows a continuous line.
+- **"Clear Plate & Start Next" Ignores Filament Override Color** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When a print was queued to "any printer" with a filament color override (e.g., white PETG), the "Clear Plate & Start Next" button appeared on all printers of the matching model that had the correct filament *type*, regardless of *color*. A printer with blue PETG would show the button for a white PETG job. The backend scheduler already correctly rejected color mismatches, but the frontend `PrinterQueueWidget` only checked `required_filament_types` (type only) and ignored `filament_overrides` (type + color). Now passes loaded filament type+color pairs from AMS/vt_tray status to the widget and filters queue items against override colors, mirroring the backend's `_count_override_color_matches()` logic.
+- **Queue Empty After Container Restart Due to Uncheckpointed WAL** ([#523](https://github.com/maziggy/bambuddy/issues/523)) — The print queue appeared empty after a Docker container restart until a filter was applied. SQLite WAL mode keeps uncommitted data in a separate `-wal` file, but the shutdown handler never checkpointed the WAL back into the main database or disposed of engine connections. If the container was stopped or crashed, the WAL could contain partial schema migrations or uncommitted data, causing inconsistent query results on restart. Deleting the `-wal` and `-shm` files was the only workaround. Now runs `PRAGMA wal_checkpoint(TRUNCATE)` and disposes the engine on shutdown, ensuring all data is flushed to the main database file before exit.
+- **Virtual Printer Queue Sends Wrong Plate ID and Ignores AMS Mapping** ([#529](https://github.com/maziggy/bambuddy/issues/529)) — Files sent to a virtual printer in queue mode had two issues. First, `plate_id` was always `1`, generating the wrong MQTT gcode path for multi-plate 3MF files (HMS error 0500_4003). Now extracts the plate index from the 3MF's `slice_info.config`. Second, `ams_mapping` was never computed for printer-specific queue items (VP assigned to a particular printer), so the printer always used the first AMS slot regardless of which filament the 3MF required. The scheduler now computes AMS mapping for all queue items that lack one, not just model-based assignments.
+- **Unnecessary Target Model Selector on "Any" Tab** ([#528](https://github.com/maziggy/bambuddy/issues/528)) — When scheduling a print to "Any {model}", a redundant "Target Model" dropdown appeared even though the G-code is already sliced for a specific printer model. Changing the target model would lead to print failures. The dropdown is now hidden when the sliced model is known (the tab label already shows "Any {model}"). It still appears as a fallback for legacy files without model metadata.
+- **"Clear Plate & Start Next" Button Shown on Printers Without Correct Filament** ([#527](https://github.com/maziggy/bambuddy/issues/527)) — When a print job was queued for "any printer" of a model (e.g., "any H2S"), the "Clear Plate & Start Next" button appeared on ALL printers of that model, including those without the required filament loaded. Clicking it on a printer without the right filament would start a print that fails. The `PrinterQueueWidget` now filters queue items by filament compatibility — it checks the printer's loaded filament types (from AMS and external spools) against the queue item's `required_filament_types` and only shows items the printer can actually print. If no compatible items exist, the widget is hidden.
+- **Manual Spool Weight Overwritten by AMS Auto-Sync** ([#525](https://github.com/maziggy/bambuddy/issues/525)) — When a user manually entered a spool weight (via UI or API), the value was overwritten by the automatic AMS remain% sync that runs on every MQTT update. The AMS remain% is integer-only (~10g resolution for 1kg spool) and can't match precise manual entries. Added a `weight_locked` flag that is automatically set when `weight_used` is explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by setting `weight_locked: false`.
+- **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to `archive.cost` with conflicting strategies: the usage tracker summed ALL historical `SpoolUsageHistory` rows for the archive (including rows from previous reprints), and a separate `add_reprint_cost` method added yet another full print's cost on top. Removed the redundant `add_reprint_cost` path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. `archive.cost` now always reflects the cost of a single print.
+- **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.
+- **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.
+- **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions.
+- **"Open in Slicer" Fails for Filenames Containing Special Characters** — Filenames with `/`, `\`, `?`, or `#` (e.g., `Abzweigdose/Verteilerdose 70mm`) caused the slicer protocol handler to fail. The filename is placed in the download URL path and `encodeURIComponent`-encoded, but BambuStudio and OrcaSlicer call `url_decode()` on the entire protocol handler URL before downloading. This decoded `%2F` back to `/`, creating extra path segments that resulted in a 404. The URL filename is purely cosmetic (the backend resolves files by archive ID, not filename), so now sanitizes `/`, `\`, `?`, and `#` to `_` in slicer download URLs.
+- **"Queue to Any Printer" Ignores Filament Color Override** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to "any printer" with a filament color override, the scheduler picked a printer with the correct filament type but wrong color. `_find_idle_printer_for_model()` validated only filament type (via `_get_missing_filament_types()`), while color matching (`_count_override_color_matches()`) was used only for ranking candidates, not filtering them. A printer with 0 color matches was still selected if it had the right types. Now requires at least 1 color match when filament overrides specify colors — printers with 0 matches are skipped and added to the "waiting for filament" reason instead of being treated as valid candidates.
+- **Virtual Printer Queue Mode Doesn't Assign Printer** ([#518](https://github.com/maziggy/bambuddy/issues/518)) — Files sent to a virtual printer in "print queue" mode were added to the queue with no printer assigned, requiring manual assignment. The `_add_to_print_queue()` method always created queue items with `printer_id=None` and no `target_model`. Now assigns the virtual printer's `target_printer_id` if configured, or falls back to the VP's model (e.g., P1S, X1C) as `target_model` for "Any Printer" scheduling.
+- **Settings Text Fields Reset While Typing** — Text input fields on the Settings page (MQTT broker hostname, HA URL, tokens, etc.) reset mid-typing because the auto-save `onSuccess` handler overwrote `localSettings` with the server response, discarding characters typed during the save request. Removed the stale state overwrite so in-progress user input is preserved.
+
+### Improved
+- **Queue API Returns More Print Metadata** ([#524](https://github.com/maziggy/bambuddy/issues/524)) — The `GET /api/v1/queue` and `GET /api/v1/queue/{id}` endpoints now include `filament_type`, `filament_color`, `layer_height`, `nozzle_diameter`, and `sliced_for_model` from the archive or library file. Previously these fields were only available via the archive endpoints, requiring an extra API call.
+- **Spool Form Profile Dropdown Truncates Long Names** ([#534](https://github.com/maziggy/bambuddy/issues/534)) — Long filament profile names (e.g., "Polymaker Panchroma Matte PLA 0.4 nozzle P1S") were truncated in the spool creation form's preset dropdown because filament ID codes displayed alongside each name consumed horizontal space. Removed the inline filament codes from dropdown items (the selected code is still shown below the input after selection) and widened the modal from `max-w-lg` to `max-w-xl` to give profile names more room.
+
+## [0.2.1b3] - 2026-02-23
+
+### Fixed
+- **Print Bed Cooled Notification Never Triggers** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor (which polls bed temperature after a print and sends a notification when it drops below the configured threshold) was defined at the end of the `on_print_complete` callback, after an early `return` that exits when no archive is found for the print. Prints started from BambuStudio or the printer's touchscreen typically have no archive in Bambuddy, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown monitor to before the archive lookup early-return so it fires for all completed prints regardless of archive state. Also hardened the temperature dict check from truthiness (`if status.temperatures:`) to type check (`isinstance(status.temperatures, dict)`) to avoid false negatives on empty dicts.
+- **IP Addresses Not Redacted From Support Bundle Logs** — The `_sanitize_log_content()` function redacted emails, serials, and credentials but left raw IPv4 addresses in log output. Now adds known printer IPs to the sensitive string list for exact matching, and applies an IPv4 regex that replaces addresses with `[IP]` while preserving firmware version strings (which use leading-zero octets like `01.09.01.00`). Updated the system info page privacy disclaimer to list IP addresses as redacted.
+- **"Unknown stage (74)" on H2D During Print Preparation** — The H2D firmware reports `stg_cur=74` during print preparation, but this stage was not in the stage name lookup table (which went up to 66, sourced from BambuStudio). Now maps stage 74 to "Preparing". Also added stage 77 ("Preparing AMS") which was present in BambuStudio but missing from the lookup.
+- **Wrong Documentation Link for "Lubricate Carbon Rods" on P2S** ([#490](https://github.com/maziggy/bambuddy/issues/490)) — The "Lubricate Carbon Rods" maintenance task linked to the belt tension wiki page instead of the XYZ axis lubrication page for P2S printers.
+- **External Spool Mapping Inverted on H2C** ([#492](https://github.com/maziggy/bambuddy/issues/492)) — On H2C dual-nozzle printers, printing from the right nozzle's external spool (Ext-R) incorrectly highlighted the left external spool (Ext-L) as active. The H2C firmware reports `tray_now=254` generically for both external spools, so the frontend's direct ID comparison (`effectiveTrayNow === extTrayId`) always matched Ext-L (id=254). Now uses `active_extruder` on dual-nozzle printers to determine which external spool is active: extruder 1 (left) → Ext-L, extruder 0 (right) → Ext-R.
+- **External Spool Assignments Lost on Restart** ([#493](https://github.com/maziggy/bambuddy/issues/493)) — Filament spool assignments on external spool holders (Ext-L / Ext-R) were silently deleted every time AMS data changed, including on container restart. The `on_ams_change` stale-assignment cleanup searched only AMS unit data for matching trays, but external spools live in `vt_tray` (a separate MQTT field). Since `_find_tray_in_ams_data` never found them, external assignments were always marked as stale and removed. Now looks up external spool assignments (`ams_id=255`) in the printer's `vt_tray` data instead, and keeps the assignment if `vt_tray` data hasn't arrived yet.
+- **Developer Mode Detection Always Reports Null** — The MQTT `fun` field is an integer in the JSON payload, but the parser used `int(value, 16)` which requires a string argument. This raised `TypeError` on every message, silently caught by the exception handler, so `developer_mode` was never set. Now handles both integer and hex string formats.
+- **Filament Fill Level Wrong in Hover Card / Missing for External Spools** ([#496](https://github.com/maziggy/bambuddy/issues/496)) — Three related fill level display bugs on the printer card. First, external spool slots (vt_tray) were missing the AMS `remain` fallback entirely — `extEffectiveFill` only checked Spoolman and inventory, falling through to `null` even when the printer reported a valid fill percentage. Now includes the same AMS remain fallback as regular and AMS-HT slots. Second, when fill level was unknown (`null`), the AMS slot visual showed a full-width gray bar (appearing "full") while the hover card showed "—" (appearing "empty") — confusing users into thinking the printer card and hover card disagreed. Removed the misleading gray fallback bar from all three slot types; the empty fill bar track now consistently indicates "unknown" in both views. Third, the fill level priority chain always preferred AMS `remain` over Spoolman and inventory data, even when those sources were more accurate (e.g., spools migrated from Spoolman to internal inventory, or spools with accurate usage tracking). Reversed the priority to Spoolman → Inventory → AMS remain, and fixed `fillSource` to correctly reflect the actual data source used (was always reporting `'ams'` even when Spoolman or inventory provided the value via the fallback chain when `remain` was -1).
+- **File Manager Rename Doesn't Update Displayed Name** ([#460](https://github.com/maziggy/bambuddy/issues/460)) — Renaming a file in the File Manager updated the `filename` field but not `file_metadata.print_name`, which the UI uses as the primary display name. Since `print_name` is extracted from inside the 3MF at upload time, it always took precedence over the renamed `filename`. The rename endpoint now also updates `print_name` in the file metadata when present.
+- **Finish Photo Not Captured When Archive Has No Source 3MF** ([#484](https://github.com/maziggy/bambuddy/issues/484)) — When a print completed but the 3MF source file wasn't downloaded from the printer (e.g. FTP download failure), the archive's `file_path` was null. The finish photo capture silently skipped because it derived the save directory from `file_path`. Now falls back to `archive/{id}/` so the photo is captured regardless.
+
+### New Features
+- **Filament Override for Model-Based Queue** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to "any printer" (model-based assignment), you can now override the 3MF's original filament choices. A new section in the print modal shows the filaments required by the sliced file and lets you swap each slot to any compatible filament loaded across printers of the selected model. The scheduler matches against the overridden type and color instead of the original 3MF values, preferring printers with exact color matches. On dual-nozzle printers (H2D), the override dropdown only shows filaments on the correct extruder for each slot. New `GET /printers/available-filaments` endpoint aggregates loaded filaments across all active printers of a given model. Backend stores overrides as a JSON column on the queue item and applies them at scheduling time by merging into filament requirements before AMS mapping. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
+
+## [0.2.1b2] - 2026-02-21
+
+### Fixed
+- **Wrong AMS Unit Displayed With Dual AMS on P2S** ([#420](https://github.com/maziggy/bambuddy/issues/420)) — On P2S printers with two AMS units, the UI highlighted the wrong AMS when printing from the second unit (e.g., printing from AMS-B slot 2 but AMS-A slot 2 was shown as active). The P2S firmware sends local slot IDs (0-3) in `tray_now`, not global tray IDs — contrary to the previous assumption that all single-nozzle printers report global IDs. Filament usage tracking was unaffected because it uses the MQTT `mapping` field (snow-encoded with correct AMS hardware IDs). The display now cross-references `tray_now` with the MQTT mapping field to resolve the correct AMS unit when multiple AMS units are detected via `ams_exist_bits`. Falls back to the raw value when no mapping is available (e.g., manual filament load outside of a print) or when the mapping is ambiguous.
+- **PCTG Filament Misidentified as PC** ([#478](https://github.com/maziggy/bambuddy/issues/478)) — Selecting "Generic PCTG" as a filament profile defaulted to PC material. The spool form's material parser listed PC before PCTG and used substring matching (`indexOf`), so "PCTG" matched "PC" first. The AMS slot configuration and local profiles views were also missing PCTG from their known material types. Additionally, the temperature range logic used `includes('PC')` which matched PCTG and assigned PC temperatures (260-300°C) instead of PETG-range temperatures (220-260°C). Fixed by reordering PCTG before PC in the spool form parser, adding PCTG to all material type arrays, and adding an exact-match temperature case for PCTG.
+- **Phantom Prints From Lingering SD Card Files** ([#477](https://github.com/maziggy/bambuddy/issues/477)) — Prints could restart without user input hours after completing, because uploaded gcode files survived on the printer's SD card and were auto-started on firmware restart. Three bugs allowed files to linger. First, the post-print SD card cleanup retry loop always broke after the first attempt regardless of success, because `delete_file_async` catches errors internally and returns `False` instead of raising — the `except` retry branch never executed. Fixed by only breaking on successful delete and retrying with a 2-second delay on failure. Second, when `start_print()` failed after uploading a file (in both the background dispatcher and print scheduler), the uploaded file was never cleaned up since `on_print_complete` never fires for a print that never started. Now deletes the uploaded file on a best-effort basis when `start_print()` returns `False`. Third, cleanup failure logging was at `DEBUG` level, making failures invisible in normal operation — escalated to `WARNING`.
+- **Non-Actionable HMS Errors Triggering Notifications** ([#470](https://github.com/maziggy/bambuddy/issues/470)) — Infrastructure and auth-related HMS error codes (like `0500_0007` "MQTT command verification failed") were triggering printer error notifications even though they don't indicate actual print problems. For example, a device with incorrect bind settings sending unauthorized MQTT commands caused repeated false-alarm nozzle/extruder error notifications with camera snapshots of perfectly fine prints. Now suppresses notifications for known non-actionable error codes: `0500_0007` (MQTT auth failure), `0500_4001` (Bambu Cloud connection failure), and `0500_400E` (print cancelled by user).
+- **Support Bundle Leaking Personal Data** ([#473](https://github.com/maziggy/bambuddy/issues/473)) — The support bundle's log sanitizer only used regex patterns, which can't detect arbitrary user-chosen strings like printer names and usernames. Now queries the database for known sensitive values (printer names, serial numbers, auth usernames, Bambu Cloud email) and does exact-string replacement before the regex pass. Serial number regex no longer leaks the first 3 characters (was using a capture group for partial redaction). Tasmota smart plug credentials embedded in URLs (`http://user:pass@host`) were logged verbatim by httpx; now uses httpx's `auth` parameter for HTTP Basic auth so credentials never appear in the URL. Added `username` and `path` to the settings key filter to redact `smtp_username` and `slicer_binary_path` from the support info JSON. A URL credentials regex provides defense-in-depth for any remaining `user:pass@` patterns in logs. IP addresses are no longer redacted from the bundle as they are needed for connectivity debugging. Updated the frontend privacy disclaimer and wiki documentation to reflect the new behavior.
+- **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, two problems caused incorrect tracking. First, the `on_ams_change` handler eagerly deleted the empty spool's `SpoolAssignment` record (fingerprint mismatch), so `on_print_complete` found nothing and silently dropped usage — fixed by snapshotting all spool assignments at print start into the `PrintSession`. Second, even with the snapshot fix, the entire print's filament weight was attributed to the original spool (100%/0% split) because `_track_from_3mf()` only knew about the tray loaded at print start. Now tracks tray changes during the print via `tray_change_log` on `PrinterState`, recording each tray switch with its layer number. At print completion, the usage tracker splits the 3MF weight across trays using per-layer gcode data for precise segment boundaries, with a linear layer-ratio fallback when gcode data isn't available. The last segment always receives the remainder to prevent rounding drift.
+- **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with `AttributeError: 'NoneType' object has no attribute 'set'`. The MQTT callback thread checked `self._pending_kprofile_response` (not None) at line 2698, but between that check and the `.set()` call, the asyncio thread's `finally` block in `get_kprofiles()` could clear the attribute to `None` after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.
+- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.
+- **Queue "Any Model" Jobs Stuck in "Waiting" After Plate Clear** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — After the queue visibility fix above, "Any Model" jobs were correctly assigned to an idle printer but immediately crashed with `'>=' not supported between instances of 'str' and 'int'` when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but `_build_loaded_filaments()` compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Cast `ams_id` and `tray_id` to `int()` to match the pattern already used for external spool IDs.
+- **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.
+- **Finish Photo Not Shown on Archives for BambuStudio Prints** ([#474](https://github.com/maziggy/bambuddy/issues/474)) — When a print was started from BambuStudio (not Bambuddy), the auto-archive had an empty `file_path`. The finish photo was saved correctly to `data/photos/`, but the photo serving endpoint resolved the path as `(base_dir / "").parent / "photos/"` which evaluates to `base_dir.parent/photos/` — one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution in `get_photo`, `upload_photo`, and `delete_photo` to use `base_dir / Path(file_path).parent` (same pattern as the save code), which correctly resolves to `base_dir/photos/` when `file_path` is empty.
+- **Archive Endpoints Crash With "Is a directory" for BambuStudio Prints** ([#475](https://github.com/maziggy/bambuddy/issues/475)) — When a print was started from BambuStudio (not Bambuddy), the 3MF file is transient on the printer and FTP download fails, creating a fallback archive with `file_path=""`. The archive endpoints used `Path.exists()` to check if the 3MF file was available, but `settings.base_dir / ""` resolves to the base directory itself — which `exists()` reports as True. Subsequent `ZipFile()` calls then failed with `[Errno 21] Is a directory`. Replaced all `.exists()` checks on archive file paths with `.is_file()` across 15 locations in the archive routes and 1 in the main module. Also added a `file_path` truthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path.
+- **AMS Slot Auto-Configuration Falls Back to Generic Instead of Spool's Slicer Preset** ([#479](https://github.com/maziggy/bambuddy/issues/479)) — When assigning a spool with a custom slicer preset (e.g., PFUS* cloud-synced profiles from BambuStudio) to an AMS slot, the slot was always configured with a generic Bambu filament ID (e.g., "Generic ABS" / GFB99) instead of the spool's actual preset. Two bugs caused this. First, all PFUS* IDs were blanket-rejected as "user-local IDs unknown to other slicers" and replaced with generic IDs — but PFUS presets are cloud-synced custom profiles that the printer understands. Second, the slot-reuse logic preserved generic fallback IDs (GFB99, GFL99, etc.) as if they were specific presets: once a slot was set to generic, every subsequent same-material assignment reused it, making generic IDs "sticky". Fixed priority order: (1) spool's own `slicer_filament` if set (including PFUS*/P* custom presets), (2) reuse slot's existing preset only if it's a specific non-generic ID for the same material, (3) generic Bambu filament ID as last resort. Both `assign_spool` and `configure_ams_slot` code paths are fixed.
+- **ntfy Notifications Fail With "Illegal header value"** ([#466](https://github.com/maziggy/bambuddy/issues/466)) — When sending ntfy notifications with image attachments (progress, error events), the message body was placed in an HTTP `Message` header. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal `\n` in the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with "attachments not allowed" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments.
+- **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.
+- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
+
+### New Features
+- **Bulk Spool Addition & Stock Spools** ([#480](https://github.com/maziggy/bambuddy/issues/480)) — Inventory enhancements for managing large filament collections. **Quick Add mode**: a toggle on the spool form that shows only material (required), brand, subtype (both optional), color, label weight, and quantity — ideal for inventorying filament without a specific slicer profile ("stock" spools). The quantity field (1–100) only appears in Quick Add mode and creates multiple identical spools in one transaction via `POST /inventory/spools/bulk`. Stock spools are computed (no database migration) — any spool without a `slicer_filament` is displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. **Group similar spools**: a "Group" toggle in the inventory toolbar visually collapses identical unused/unassigned spools into a single expandable row or card with a count badge (e.g., "5 identical spools"). Grouping key uses material, subtype, brand, color, and label weight. Used or AMS-assigned spools always appear individually. Group state persists to localStorage. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
+- **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
+- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
+- **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
+- **Developer LAN Mode Detection & Warning Banner** — Automatically detects whether connected printers have Developer LAN Mode enabled by parsing the MQTT `fun` field (bit `0x20000000`). When any connected printer lacks developer mode, a persistent orange warning banner appears at the top of the UI with the affected printer name(s) and a link to Bambu Lab's documentation on how to enable it. Without developer mode, MQTT write operations (start/stop/pause prints, AMS control, light/speed/gcode commands) are silently rejected by newer firmware. The `developer_mode` state is included in the support bundle for diagnostics. New `/printers/developer-mode-warnings` endpoint provides a lightweight polling summary. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
+
+### Improved
+- **Clear Plate Dot Indicator on Sidebar** — When the print queue is active and a printer finishes or fails with a pending next job, a small yellow dot now appears on the Printers sidebar icon to signal that user action (clearing the build plate) is needed. The indicator reuses the existing WebSocket-driven printer status cache, so no additional API polling is required. The dot disappears once the plate is cleared or the queue empties.
+- **Inventory Sidebar Always Visible** — The Inventory sidebar item is no longer hidden when Spoolman is enabled. Instead, clicking it embeds the Spoolman web UI in the main content area via iframe (same approach as external links). When Spoolman is disabled, the internal inventory page is shown as before. Both modes use the same `/inventory` route and sidebar position.
+- **Filament Override Test Coverage** — Added 11 backend unit tests: 6 for `_count_override_color_matches` (no status, exact match, no match, partial match, color normalization, external spool) and 5 for override application in filament matching (color override, tray_info_idx clearing, type change, partial override, nozzle filtering with override). Added 12 frontend tests for the `FilamentOverride` component: 5 rendering tests (null guards, slot display, dropdown count), 2 type filtering tests (same-type only, all colors), 3 nozzle filtering tests (extruder_id matching, single-nozzle passthrough, null extruder_id inclusion), and 2 interaction tests (select override, reset to original).
+- **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.
+- **Bulk Spool, Stock & Grouping Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 for `SpoolFormModal` covering `validateForm` with `quickAdd` flag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic covering `spoolGroupKey` identity/differentiation (7 tests) and `computeDisplayItems` grouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios).
+- **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
+- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
+- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
+- **Tray Change Split Test Coverage** — Added 8 MQTT unit tests for `tray_change_log` lifecycle (default empty, seed on print start, clear on new print, record during RUNNING/PAUSE, ignore during IDLE, deduplicate, multi-change history). Added 6 usage tracker unit tests for weight splitting (per-layer gcode split, linear fallback, no-change normal path, empty log recovery, missing spool skip, triple segment split).
+- **Developer Mode Detection Test Coverage** — Added 7 backend unit tests for MQTT `fun` field parsing (bit clear/set detection, exact bit check, invalid hex handling, state persistence across messages). Added 4 frontend tests for the warning banner (single/multiple printer names, hidden when empty, "How to enable" link).
+- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.
+
+## [0.2.1b] - 2026-02-19
+
+### Fixed
+- **PAUSED State Never Matched** ([#447](https://github.com/maziggy/bambuddy/issues/447)) — Removed dead `PAUSED` checks across frontend and backend. The printer only sends `PAUSE` via MQTT `gcode_state`, so `PAUSED` comparisons were unreachable code.
+- **Nozzle Mapping Uses Wrong Source in 3MF Files** — The `extract_nozzle_mapping_from_3mf()` function used `filament_nozzle_map` (user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in the `group_id` attribute on `<filament>` elements in `slice_info.config`. Now uses `group_id` as the primary source and falls back to `filament_nozzle_map` only when `group_id` is not present.
+- **Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle** — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.
+- **Print Scheduler External Spool Ignores Nozzle Assignment** — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.
+- **ams_extruder_map Race Condition on Printer Status API** — The `/printers/{id}/status` endpoint read `ams_extruder_map` from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.
+- **Filament Mapping Frontend Ignores Nozzle for External Spools** — The `useFilamentMapping` hook always set `extruder_id: 0` for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.
+- **AMS-HT Global Tray ID Computed Wrong on Printer Card** — The PrintersPage computed AMS-HT tray IDs using `ams_id * 4 + slot` (giving 512+), but AMS-HT units use their raw `ams_id` (128-135) as the global tray ID. Now uses `ams_id` directly for AMS-HT units.
+- **Filament Mapping Dropdown Shows Wrong Nozzle Trays** — The FilamentMapping dropdown filtered by `extruder_id` using strict equality, but `extruder_id` could be `undefined` for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when `extruder_id` is undefined.
+- **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.
+- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
+- **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
+- **H2D Tray Disambiguation Produces Bogus tray_now for AMS-HT** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When the snow field hadn't arrived yet on H2D dual-nozzle printers, the `ams_extruder_map` fallback computed `ams_id * 4 + slot` for all AMS types — including AMS-HT units (IDs 128-135) which have a single slot and use their unit ID as the global tray ID. This produced bogus values like 512+ that briefly appeared in the UI and could pollute `last_loaded_tray`. Now correctly returns the AMS-HT unit ID for single-slot units, handles AMS-HT in multi-AMS matching, filters AMS-HT candidates when slot > 0, and tightens `last_loaded_tray` to only accept physically valid tray IDs (0-15, 128-135, 254).
+- **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
+- **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.
+
+- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
+- **AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots** — When assigning a spool with a user-local `PFUS*` preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replaces `PFUS*` IDs with generic Bambu filament IDs (e.g., `GFL99` for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g., `P4d64437`), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow.
+- **Fill Level Bar Missing for Brand New Spools** — Spools with `weight_used = 0` (brand new, never printed) showed no fill level bar on the printer card. The condition checked `weight_used > 0` instead of `weight_used != null`, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (`null`).
+- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
+- **npm audit: fix minimatch ReDoS finding** — Added an npm override for `minimatch@^10.2.1` in `package.json` to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.
+- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
+- **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
+
+### New Features
+- **Multiple Virtual Printers** — Run multiple virtual printers per Bambuddy installation. Each virtual printer gets a dedicated bind IP address with completely independent FTP, MQTT, SSDP, and Bind servers — no shared services or SNI routing. Full CRUD API (`/api/virtual-printers`) and React UI for creating, editing, and deleting virtual printers. Each instance supports all four modes (Immediate, Review, Print Queue, Proxy), any of the 11 supported printer models, per-instance TLS certificates (shared CA), and individual network interface override. Database-backed with auto-incremented serial suffixes.
+- **Virtual Printer: Dual Bind/Detect Ports** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — The slicer bind/detect handshake now listens on both ports 3000 and 3002. Different BambuStudio/OrcaSlicer versions use different ports for this handshake, so Bambuddy accepts connections on either. Applies to both server mode (BindServer) and proxy mode (SlicerProxyManager).
+- **Clear Plate Permission** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — New `printers:clear_plate` permission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting full `printers:control` (which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups with `printers:control` automatically receive the new permission on startup. The Operators default group includes it by default.
+- **Full-Page Group Permission Editor** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — Replaced the cramped permission modal with a dedicated full-page editor at `/groups/:id/edit`. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The old `GroupsPage.tsx` dead code has been removed.
+
+### Changed
+- **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.
+
+### Improved
+- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
+- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
+- **Tray Info Idx Resolution Test Coverage** — Added 12 backend integration tests for PFUS→generic tray_info_idx resolution across both the slot configure and inventory assignment endpoints, plus 10 frontend unit tests for the fill level calculation logic.
+
+
 ## [0.2.0] - 2026-02-17
 
 ### New Features
@@ -55,7 +177,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.
 
 ### Improved
-- **Virtual Printer: Port 3000 Bind/Detect Server** — Recent BambuStudio/OrcaSlicer updates require a bind/detect handshake on port 3000 before connecting via MQTT/FTP. Added a BindServer that responds to the slicer's detect protocol in all server modes (immediate, review, print_queue). Without this, slicers cannot discover or connect to the virtual printer. Docker users in bridge mode need to expose port 3000 (`-p 3000:3000`). Proxy mode already forwards port 3000 via TCPProxy. Wiki documentation updated with revised port tables, Docker examples, and platform setup instructions.
+- **Virtual Printer: Dual Bind/Detect Ports 3000 + 3002** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — BambuStudio/OrcaSlicer require a bind/detect handshake before connecting via MQTT/FTP. Different slicer versions use port 3000 or 3002, so the BindServer and proxy now listen on both ports for full compatibility. Docker users in bridge mode need to expose both (`-p 3000:3000 -p 3002:3002`).
 - **Usage Tracking Diagnostic Logging** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Added INFO-level logging at print start and completion that dumps the printer's MQTT `mapping` field, `tray_now`, `last_loaded_tray`, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode.
 - **Skip Objects: Click-to-Enlarge Lightbox** ([#396](https://github.com/maziggy/bambuddy/issues/396)) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.
 - **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency.

+ 1 - 0
CONTRIBUTING.md

@@ -168,6 +168,7 @@ Translations live in `frontend/src/i18n/locales/`:
 | `de.ts` | German |
 | `fr.ts` | French |
 | `ja.ts` | Japanese |
+| `pt-BR.ts` | Brazilian Portuguese |
 
 ### Adding New Strings
 

+ 7 - 0
Dockerfile

@@ -23,8 +23,14 @@ ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     ffmpeg \
+    libcap2-bin \
     && rm -rf /var/lib/apt/lists/*
 
+# Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.
+# File capabilities are more reliable than Docker cap_add with user: directive,
+# which depends on ambient capability support in the container runtime.
+RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
+
 # Install Python dependencies with cache mount
 COPY requirements.txt ./
 RUN --mount=type=cache,target=/root/.cache/pip \
@@ -47,6 +53,7 @@ ENV LOG_DIR=/app/logs
 ENV PORT=8000
 
 EXPOSE 3000
+EXPOSE 3002
 EXPOSE 8000
 EXPOSE 8883
 EXPOSE 9990

+ 13 - 0
README.md

@@ -98,9 +98,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - 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
 - Multi-printer selection (send to multiple printers at once)
 - 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)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
@@ -154,6 +156,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
 - Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
 - Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
+- **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
+- **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 
 ### 🔧 Integrations
@@ -354,6 +358,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 ### Requirements
 - Python 3.10+ (3.11/3.12 recommended)
 - Bambu Lab printer with **Developer Mode** enabled (see below)
+- **"Store sent files on external storage"** enabled in Bambu Studio/OrcaSlicer
 - Same local network as printer
 
 ### Installation
@@ -521,6 +526,14 @@ Developer Mode allows third-party software like Bambuddy to control your printer
 
 > **Note:** Developer Mode disables cloud features but provides full local control. Standard LAN Mode (without Developer Mode) only allows read-only monitoring.
 
+### Slicer Settings
+
+In Bambu Studio or OrcaSlicer, enable **"Store sent files on external storage"** so that print files (3MF) are saved to the printer's SD card. Bambuddy needs these files to extract thumbnails and 3D model previews.
+
+1. Open **Bambu Studio** or **OrcaSlicer**
+2. Go to the **Device** tab for your printer
+3. Enable **"Store sent files on external storage"**
+
 ---
 
 ## 📚 Documentation

+ 1 - 0
SECURITY.md

@@ -40,6 +40,7 @@ Please include the following information in your report:
 | Version | Supported          |
 | ------- | ------------------ |
 | 0.1.x   | :white_check_mark: |
+| 0.2.x   | :white_check_mark: |
 
 ## Security Considerations
 

+ 3 - 3
backend/app/api/routes/ams_history.py

@@ -1,6 +1,6 @@
 """API routes for AMS sensor history."""
 
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 from fastapi import APIRouter, Depends, Query
 from pydantic import BaseModel
@@ -44,7 +44,7 @@ async def get_ams_history(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
-    since = datetime.now() - timedelta(hours=hours)
+    since = datetime.now(timezone.utc) - timedelta(hours=hours)
 
     # Get data points
     result = await db.execute(
@@ -108,7 +108,7 @@ async def delete_old_history(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Delete old AMS history data for a printer."""
-    cutoff = datetime.now() - timedelta(days=days)
+    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
 
     result = await db.execute(
         select(func.count(AMSSensorHistory.id)).where(

+ 226 - 162
backend/app/api/routes/archives.py

@@ -2,6 +2,7 @@ import io
 import json
 import logging
 import zipfile
+from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
@@ -18,6 +19,7 @@ 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.filament import Filament
+from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
@@ -570,7 +572,7 @@ async def get_archive_stats(
                     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, 2)
+        total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
     else:
         # Print mode: sum up per-print energy from archives
         energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
@@ -591,7 +593,7 @@ async def get_archive_stats(
         average_time_accuracy=average_accuracy,
         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, 2),
+        total_energy_cost=round(total_energy_cost, 3),
     )
 
 
@@ -827,7 +829,7 @@ async def rescan_archive(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     # Parse the 3MF file
@@ -856,18 +858,34 @@ async def rescan_archive(
     if metadata.get("designer"):
         archive.designer = metadata["designer"]
 
-    # Calculate cost based on filament usage and type
+    # Calculate cost: prefer spool-based cost if available, else catalog-based
+
     if archive.filament_used_grams and archive.filament_type:
-        primary_type = archive.filament_type.split(",")[0].strip()
-        filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
-        filament = filament_result.scalar_one_or_none()
-        if filament:
-            archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2)
+        usage_result = await db.execute(
+            select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.archive_id == archive.id)
+        )
+        usage_cost = usage_result.scalar()
+        if usage_cost is not None and usage_cost > 0:
+            archive.cost = float(Decimal(str(usage_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
         else:
-            # Use default filament cost from settings
-            default_cost_setting = await get_setting(db, "default_filament_cost")
-            default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
-            archive.cost = round((archive.filament_used_grams / 1000) * default_cost_per_kg, 2)
+            primary_type = archive.filament_type.split(",")[0].strip()
+            filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
+            filament = filament_result.scalar_one_or_none()
+            if filament:
+                archive.cost = float(
+                    Decimal(str((archive.filament_used_grams / 1000) * filament.cost_per_kg)).quantize(
+                        Decimal("0.01"), rounding=ROUND_HALF_UP
+                    )
+                )
+            else:
+                # Use default filament cost from settings
+                default_cost_setting = await get_setting(db, "default_filament_cost")
+                default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
+                archive.cost = float(
+                    Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
+                        Decimal("0.01"), rounding=ROUND_HALF_UP
+                    )
+                )
 
     await db.commit()
     await db.refresh(archive)
@@ -880,6 +898,7 @@ async def recalculate_all_costs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Recalculate costs for all archives based on filament usage and prices."""
+
     from backend.app.api.routes.settings import get_setting
 
     result = await db.execute(select(PrintArchive))
@@ -893,15 +912,38 @@ async def recalculate_all_costs(
     default_cost_setting = await get_setting(db, "default_filament_cost")
     default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
 
+    # Pre-fetch all usage costs by archive_id
+    usage_costs_result = await db.execute(
+        select(SpoolUsageHistory.archive_id, func.sum(SpoolUsageHistory.cost)).group_by(SpoolUsageHistory.archive_id)
+    )
+    usage_costs = usage_costs_result.fetchall()
+    cost_map = {row[0]: row[1] for row in usage_costs if row[0] is not None and row[1] is not None and row[1] > 0}
+
     updated = 0
     for archive in archives:
-        if archive.filament_used_grams and archive.filament_type:
-            primary_type = archive.filament_type.split(",")[0].strip()
-            cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
-            new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
-            if archive.cost != new_cost:
-                archive.cost = new_cost
-                updated += 1
+        usage_cost = cost_map.get(archive.id)
+        if usage_cost is not None:
+            new_cost = round(usage_cost, 2)
+        else:
+            # Fallback: sum costs for old records by print_name
+            usage_result = await db.execute(
+                select(func.sum(SpoolUsageHistory.cost)).where(
+                    SpoolUsageHistory.print_name == archive.print_name,
+                    SpoolUsageHistory.archive_id.is_(None),
+                )
+            )
+            fallback_cost = usage_result.scalar()
+            if fallback_cost is not None and fallback_cost > 0:
+                new_cost = round(fallback_cost, 2)
+            elif archive.filament_used_grams and archive.filament_type:
+                primary_type = archive.filament_type.split(",")[0].strip()
+                cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
+                new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
+            else:
+                new_cost = None
+        if new_cost is not None and archive.cost != new_cost:
+            archive.cost = new_cost
+            updated += 1
 
     await db.commit()
     return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
@@ -924,7 +966,7 @@ async def rescan_all_archives(
     for archive in archives:
         try:
             file_path = settings.base_dir / archive.file_path
-            if not file_path.exists():
+            if not file_path.is_file():
                 errors.append({"id": archive.id, "error": "File not found"})
                 continue
 
@@ -994,7 +1036,7 @@ async def backfill_content_hashes(
     for archive in archives:
         try:
             file_path = settings.base_dir / archive.file_path
-            if not file_path.exists():
+            if not file_path.is_file():
                 errors.append({"id": archive.id, "error": "File not found"})
                 continue
 
@@ -1053,7 +1095,7 @@ async def download_archive(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "File not found")
 
     # Use inline disposition to let browser/OS handle file association
@@ -1074,14 +1116,70 @@ async def download_archive_with_filename(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
+    """Download the 3MF file with filename in URL."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive:
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
+        raise HTTPException(404, "File not found")
+
+    return FileResponse(
+        path=file_path,
+        filename=archive.filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/slicer-token")
+async def create_archive_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    token = create_slicer_download_token("archive", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/dl/{token}/{filename}")
+async def download_archive_for_slicer(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download 3MF file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "archive", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.is_file():
         raise HTTPException(404, "File not found")
 
     return FileResponse(
@@ -1340,11 +1438,11 @@ async def scan_timelapse(
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
     if not matching_file and len(video_files) == 1:
-        from datetime import datetime, timedelta
+        from datetime import datetime, timedelta, timezone
 
         archive_completed = archive.completed_at or archive.created_at
         if archive_completed:
-            time_since_completion = datetime.now() - archive_completed
+            time_since_completion = datetime.now(timezone.utc) - archive_completed
             # If archive was completed within the last hour, assume the single timelapse is for it
             if time_since_completion < timedelta(hours=1):
                 matching_file = video_files[0]
@@ -1724,8 +1822,7 @@ async def upload_photo(
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
 
     # Get archive directory
-    file_path = settings.base_dir / archive.file_path
-    archive_dir = file_path.parent
+    archive_dir = settings.base_dir / Path(archive.file_path).parent
     photos_dir = archive_dir / "photos"
     photos_dir.mkdir(exist_ok=True)
 
@@ -1766,8 +1863,8 @@ async def get_photo(
     if not archive:
         raise HTTPException(404, "Archive not found")
 
-    file_path = settings.base_dir / archive.file_path
-    photo_path = file_path.parent / "photos" / filename
+    archive_dir = settings.base_dir / Path(archive.file_path).parent
+    photo_path = archive_dir / "photos" / filename
 
     if not photo_path.exists():
         raise HTTPException(404, "Photo not found")
@@ -1802,8 +1899,8 @@ async def delete_photo(
         raise HTTPException(404, "Photo not found")
 
     # Delete file
-    file_path = settings.base_dir / archive.file_path
-    photo_path = file_path.parent / "photos" / filename
+    archive_dir = settings.base_dir / Path(archive.file_path).parent
+    photo_path = archive_dir / "photos" / filename
     if photo_path.exists():
         photo_path.unlink()
 
@@ -1893,7 +1990,7 @@ async def get_archive_capabilities(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "File not found")
 
     has_model = False
@@ -2111,7 +2208,7 @@ async def get_gcode(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "File not found")
 
     try:
@@ -2153,7 +2250,7 @@ async def get_plate_preview(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "File not found")
 
     try:
@@ -2314,7 +2411,7 @@ async def get_archive_plates(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     plates = []
@@ -2333,7 +2430,8 @@ async def get_archive_plates(
                 for gf in gcode_files:
                     # "Metadata/plate_5.gcode" -> 5
                     try:
-                        plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                        # Remove "Metadata/plate_" and ".gcode"
+                        plate_str = gf[15:-6]
                         plate_indices.append(int(plate_str))
                     except ValueError:
                         pass  # Skip gcode file with non-numeric plate index
@@ -2581,7 +2679,7 @@ async def get_plate_thumbnail(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     try:
@@ -2620,7 +2718,7 @@ async def get_filament_requirements(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     filaments = []
@@ -2736,14 +2834,9 @@ async def reprint_archive(
         )
     ),
 ):
-    """Send an archived 3MF file to a printer and start printing."""
-    from backend.app.main import register_expected_print
+    """Dispatch an archived 3MF file for send/start on a printer."""
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import (
-        get_ftp_retry_settings,
-        upload_file_async,
-        with_ftp_retry,
-    )
+    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
     from backend.app.services.printer_manager import printer_manager
 
     user, can_modify_all = auth_result
@@ -2773,139 +2866,54 @@ async def reprint_archive(
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(400, "Printer is not connected")
 
-    # Get the sliced 3MF file path
     if not archive.file_path:
         raise HTTPException(
             404,
             "No 3MF file available for this archive. "
             "The file could not be downloaded from the printer when the print was recorded.",
         )
+
+    # Validate archive file exists
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
-    # Upload file to printer via FTP
-    from backend.app.services.bambu_ftp import delete_file_async
+    plate_name = body.plate_name
+    if not plate_name and body.plate_id is not None:
+        plate_name = f"Plate {body.plate_id}"
 
-    # Use a clean filename to avoid issues with double extensions like .gcode.3mf
-    # The printer might reject filenames with unusual extensions
-    base_name = archive.filename
-    if base_name.endswith(".gcode.3mf"):
-        base_name = base_name[:-10]  # Remove .gcode.3mf
-    elif base_name.endswith(".3mf"):
-        base_name = base_name[:-4]  # Remove .3mf
-    remote_filename = f"{base_name}.3mf"
-    remote_path = f"/{remote_filename}"
+    dispatch_source_name = archive.filename
+    if plate_name:
+        dispatch_source_name = f"{archive.filename} • {plate_name}"
 
-    # Get FTP retry settings
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-
-    logger.info(
-        f"Reprint FTP upload starting: printer={printer.name} ({printer.model}), "
-        f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
-        f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
-    )
-
-    # Delete existing file if present (avoids 553 error)
-    logger.debug("Deleting existing file %s if present...", remote_path)
-    delete_result = await delete_file_async(
-        printer.ip_address,
-        printer.access_code,
-        remote_path,
-        socket_timeout=ftp_timeout,
-        printer_model=printer.model,
-    )
-    logger.debug("Delete result: %s", delete_result)
-
-    if ftp_retry_enabled:
-        uploaded = await with_ftp_retry(
-            upload_file_async,
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            max_retries=ftp_retry_count,
-            retry_delay=ftp_retry_delay,
-            operation_name=f"Upload for reprint to {printer.name}",
-        )
-    else:
-        uploaded = await upload_file_async(
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-        )
-
-    if not uploaded:
-        logger.error(
-            f"FTP upload failed for reprint: printer={printer.name}, model={printer.model}, "
-            f"ip={printer.ip_address}, file={remote_filename}. "
-            "Check logs above for storage diagnostics and specific error codes."
-        )
-        raise HTTPException(
-            500,
-            "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
-            "See server logs for detailed diagnostics.",
+    try:
+        dispatch_result = await background_dispatch.dispatch_reprint_archive(
+            archive_id=archive_id,
+            archive_name=dispatch_source_name,
+            printer_id=printer_id,
+            printer_name=printer.name,
+            options=body.model_dump(exclude_none=True),
+            requested_by_user_id=user.id if user else None,
+            requested_by_username=user.username if user else None,
         )
-
-    # Register this as an expected print so we don't create a duplicate archive
-    register_expected_print(printer_id, remote_filename, archive_id, ams_mapping=body.ams_mapping)
-
-    # Use plate_id from request if provided, otherwise auto-detect from 3MF file
-    if body.plate_id is not None:
-        plate_id = body.plate_id
-    else:
-        # Auto-detect plate ID from 3MF file (legacy behavior for single-plate files)
-        plate_id = 1
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                for name in zf.namelist():
-                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
-                        # Extract plate number from "Metadata/plate_X.gcode"
-                        plate_str = name[15:-6]  # Remove "Metadata/plate_" and ".gcode"
-                        plate_id = int(plate_str)
-                        break
-        except (ValueError, zipfile.BadZipFile, OSError):
-            pass  # Default to plate 1 if detection fails
+    except DispatchEnqueueRejected as e:
+        raise HTTPException(status_code=409, detail=str(e)) from e
 
     logger.info(
-        f"Reprint archive {archive_id}: plate_id={plate_id}, "
-        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
-        f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
-        f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
-    )
-
-    # Start the print with options
-    started = printer_manager.start_print(
+        "Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
+        archive_id,
         printer_id,
-        remote_filename,
-        plate_id,
-        ams_mapping=body.ams_mapping,
-        timelapse=body.timelapse,
-        bed_levelling=body.bed_levelling,
-        flow_cali=body.flow_cali,
-        vibration_cali=body.vibration_cali,
-        layer_inspect=body.layer_inspect,
-        use_ams=body.use_ams,
+        dispatch_result["dispatch_job_id"],
+        dispatch_result["dispatch_position"],
     )
 
-    if not started:
-        raise HTTPException(500, "Failed to start print")
-
-    # Track who started this print (Issue #206)
-    if user:
-        printer_manager.set_current_print_user(printer_id, user.id, user.username)
-        logger.info("Reprint started by user: %s", user.username)
-
     return {
-        "status": "printing",
+        "status": "dispatched",
         "printer_id": printer_id,
         "archive_id": archive_id,
         "filename": archive.filename,
+        "dispatch_job_id": dispatch_result["dispatch_job_id"],
+        "dispatch_position": dispatch_result["dispatch_position"],
     }
 
 
@@ -2930,7 +2938,7 @@ async def get_project_page(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     parser = ProjectPageParser(file_path)
@@ -2955,7 +2963,7 @@ async def update_project_page(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     parser = ProjectPageParser(file_path)
@@ -2987,7 +2995,7 @@ async def get_project_image(
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
-    if not file_path.exists():
+    if not file_path.is_file():
         raise HTTPException(404, "Archive file not found")
 
     parser = ProjectPageParser(file_path)
@@ -3093,7 +3101,63 @@ async def download_source_3mf_for_slicer(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
+    """Download source 3MF with filename in URL."""
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    return FileResponse(
+        path=source_path,
+        filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/source-slicer-token")
+async def create_source_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening source 3MF in slicer."""
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    token = create_slicer_download_token("source", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/source-dl/{token}/{filename}")
+async def download_source_3mf_for_slicer_with_token(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download source 3MF using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/source-slicer-token.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not 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))
     archive = result.scalar_one_or_none()
     if not archive:

+ 32 - 0
backend/app/api/routes/background_dispatch.py

@@ -0,0 +1,32 @@
+from fastapi import APIRouter, HTTPException
+
+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.background_dispatch import background_dispatch
+
+router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
+
+
+@router.delete("/{job_id}")
+async def cancel_dispatch_job(
+    job_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+):
+    """Cancel a background-dispatch job.
+
+    Queued jobs are cancelled immediately. Active jobs are marked for
+    cooperative cancellation and will stop at the next cancellation checkpoint.
+    """
+    result = await background_dispatch.cancel_job(job_id)
+
+    if not result["cancelled"]:
+        raise HTTPException(status_code=404, detail="Dispatch job not found")
+
+    return {
+        "status": "cancelling" if result.get("pending") else "cancelled",
+        "job_id": result["job_id"],
+        "source_name": result["source_name"],
+        "printer_id": result["printer_id"],
+        "printer_name": result["printer_name"],
+    }

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

@@ -14,7 +14,7 @@ from backend.app.schemas.filament import (
     FilamentUpdate,
 )
 
-router = APIRouter(prefix="/filaments", tags=["filaments"])
+router = APIRouter(prefix="/filament-catalog", tags=["filament-catalog"])
 
 
 @router.get("/", response_model=list[FilamentResponse])

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

@@ -22,6 +22,7 @@ from backend.app.models.user import User
 from backend.app.schemas.spool import (
     SpoolAssignmentCreate,
     SpoolAssignmentResponse,
+    SpoolBulkCreate,
     SpoolCreate,
     SpoolKProfileBase,
     SpoolKProfileResponse,
@@ -482,6 +483,24 @@ async def create_spool(
     return result.scalar_one()
 
 
+@router.post("/spools/bulk", response_model=list[SpoolResponse])
+async def bulk_create_spools(
+    data: SpoolBulkCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Create multiple identical spools."""
+    spools = []
+    for _ in range(data.quantity):
+        spool = Spool(**data.spool.model_dump())
+        db.add(spool)
+        spools.append(spool)
+    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)))
+    return list(result.scalars().all())
+
+
 @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
 async def update_spool(
     spool_id: int,
@@ -495,7 +514,12 @@ async def update_spool(
     if not spool:
         raise HTTPException(404, "Spool not found")
 
-    for field, value in spool_data.model_dump(exclude_unset=True).items():
+    update_data = spool_data.model_dump(exclude_unset=True)
+    # Auto-lock weight when user explicitly sets weight_used
+    if "weight_used" in update_data and "weight_locked" not in update_data:
+        update_data["weight_locked"] = True
+
+    for field, value in update_data.items():
         setattr(spool, field, value)
 
     await db.commit()
@@ -640,9 +664,10 @@ async def assign_spool(
     if spool.archived_at:
         raise HTTPException(400, "Cannot assign an archived spool")
 
-    # 2. Get current AMS tray state for fingerprint
+    # 2. Get current AMS tray state for fingerprint + existing filament ID
     fingerprint_color = None
     fingerprint_type = None
+    current_tray_info_idx = ""
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
         if data.ams_id == 255:
@@ -653,6 +678,7 @@ async def assign_spool(
                 if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                     fingerprint_color = vt.get("tray_color", "")
                     fingerprint_type = vt.get("tray_type", "")
+                    current_tray_info_idx = vt.get("tray_info_idx", "")
                     break
         else:
             ams_data = state.raw_data.get("ams", {})
@@ -671,6 +697,7 @@ async def assign_spool(
             if tray:
                 fingerprint_color = tray.get("tray_color", "")
                 fingerprint_type = tray.get("tray_type", "")
+                current_tray_info_idx = tray.get("tray_info_idx", "")
 
     # 3. Upsert assignment (replace if same printer+ams+tray)
     existing = await db.execute(
@@ -709,6 +736,52 @@ async def assign_spool(
             tray_info_idx = spool.slicer_filament or ""
             setting_id = ""
 
+            # Resolve tray_info_idx for the MQTT command.
+            # Priority:
+            #   1. Use the spool's own slicer_filament if set (including
+            #      cloud-synced custom presets like PFUS* / P*).
+            #   2. Reuse the slot's existing tray_info_idx if it's a specific
+            #      (non-generic) preset for the same material.
+            #   3. Fall back to a generic Bambu filament ID.
+            _GENERIC_IDS = {
+                "PLA": "GFL99",
+                "PETG": "GFG99",
+                "ABS": "GFB99",
+                "ASA": "GFB98",
+                "PC": "GFC99",
+                "PA": "GFN99",
+                "NYLON": "GFN99",
+                "TPU": "GFU99",
+                "PVA": "GFS99",
+                "HIPS": "GFS98",
+                "PLA-CF": "GFL98",
+                "PETG-CF": "GFG98",
+                "PA-CF": "GFN98",
+                "PETG HF": "GFG96",
+            }
+            _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
+
+            if tray_info_idx:
+                logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
+            elif (
+                current_tray_info_idx
+                and current_tray_info_idx not in _GENERIC_ID_VALUES
+                and fingerprint_type
+                and fingerprint_type.upper() == tray_type.upper()
+            ):
+                logger.info(
+                    "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
+                    current_tray_info_idx,
+                    tray_type,
+                )
+                tray_info_idx = current_tray_info_idx
+            elif tray_type:
+                material = tray_type.upper().strip()
+                generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
+                if generic:
+                    logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
+                    tray_info_idx = generic
+
             # Temperature: use spool overrides if set, else material defaults
             temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
             if spool.nozzle_temp_min is not None:
@@ -984,6 +1057,11 @@ async def sync_weights_from_ams(
             skipped += 1
             continue
 
+        if spool.weight_locked:
+            logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
+            skipped += 1
+            continue
+
         state = printer_manager.get_status(assignment.printer_id)
         if not state or not state.raw_data:
             logger.info(

+ 85 - 130
backend/app/api/routes/library.py

@@ -54,7 +54,7 @@ from backend.app.schemas.library import (
     ZipExtractResponse,
     ZipExtractResult,
 )
-from backend.app.services.archive import ArchiveService, ThreeMFParser
+from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
@@ -1737,23 +1737,15 @@ async def print_library_file(
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
 ):
-    """Print a library file directly.
+    """Dispatch a library file for send/start on a printer.
 
-    This endpoint:
-    1. Creates an archive from the library file
-    2. Uploads the file to the printer
-    3. Starts the print
+    The actual send/start work is handled asynchronously by background
+    dispatch so the UI can continue immediately.
 
     Only sliced files (.gcode or .gcode.3mf) can be printed.
     """
-    from backend.app.main import register_expected_print
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import (
-        delete_file_async,
-        get_ftp_retry_settings,
-        upload_file_async,
-        with_ftp_retry,
-    )
+    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
     from backend.app.services.printer_manager import printer_manager
 
     # Use defaults if no body provided
@@ -1790,130 +1782,34 @@ async def print_library_file(
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(status_code=400, detail="Printer is not connected")
 
-    # Create archive from the library file
-    archive_service = ArchiveService(db)
-    archive = await archive_service.archive_print(
-        printer_id=printer_id,
-        source_file=file_path,
-    )
-
-    if not archive:
-        raise HTTPException(status_code=500, detail="Failed to create archive")
-
-    await db.flush()
+    plate_name = body.plate_name
+    if not plate_name and body.plate_id is not None:
+        plate_name = f"Plate {body.plate_id}"
 
-    # Prepare remote filename
-    base_name = lib_file.filename
-    if base_name.endswith(".gcode.3mf"):
-        base_name = base_name[:-10]
-    elif base_name.endswith(".3mf"):
-        base_name = base_name[:-4]
-    remote_filename = f"{base_name}.3mf"
-    remote_path = f"/{remote_filename}"
-
-    # Get FTP retry settings
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-
-    logger.info(
-        f"Library print FTP upload starting: printer={printer.name} ({printer.model}), "
-        f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
-        f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
-    )
+    dispatch_source_name = lib_file.filename
+    if plate_name:
+        dispatch_source_name = f"{lib_file.filename} • {plate_name}"
 
-    # Delete existing file if present (avoids 553 error)
-    logger.debug("Deleting existing file %s if present...", remote_path)
-    delete_result = await delete_file_async(
-        printer.ip_address,
-        printer.access_code,
-        remote_path,
-        socket_timeout=ftp_timeout,
-        printer_model=printer.model,
-    )
-    logger.debug("Delete result: %s", delete_result)
-
-    # Upload file to printer
-    if ftp_retry_enabled:
-        uploaded = await with_ftp_retry(
-            upload_file_async,
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-            max_retries=ftp_retry_count,
-            retry_delay=ftp_retry_delay,
-            operation_name=f"Upload for print to {printer.name}",
-        )
-    else:
-        uploaded = await upload_file_async(
-            printer.ip_address,
-            printer.access_code,
-            file_path,
-            remote_path,
-            socket_timeout=ftp_timeout,
-            printer_model=printer.model,
-        )
-
-    if not uploaded:
-        logger.error(
-            f"FTP upload failed for library print: printer={printer.name}, model={printer.model}, "
-            f"ip={printer.ip_address}, file={remote_filename}. "
-            "Check logs above for storage diagnostics and specific error codes."
-        )
-        raise HTTPException(
-            status_code=500,
-            detail="Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
-            "See server logs for detailed diagnostics.",
+    try:
+        dispatch_result = await background_dispatch.dispatch_print_library_file(
+            file_id=file_id,
+            filename=dispatch_source_name,
+            printer_id=printer_id,
+            printer_name=printer.name,
+            options=body.model_dump(exclude_none=True),
+            requested_by_user_id=None,
+            requested_by_username=None,
         )
-
-    # Register this as an expected print so we don't create a duplicate archive
-    register_expected_print(printer_id, remote_filename, archive.id, ams_mapping=body.ams_mapping)
-
-    # Determine plate ID
-    if body.plate_id is not None:
-        plate_id = body.plate_id
-    else:
-        plate_id = 1
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                for name in zf.namelist():
-                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
-                        plate_str = name[15:-6]
-                        plate_id = int(plate_str)
-                        break
-        except (ValueError, zipfile.BadZipFile, OSError):
-            pass  # Default plate_id=1 if archive is unreadable or has no gcode
-
-    logger.info(
-        f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
-        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
-    )
-
-    # Start the print
-    started = printer_manager.start_print(
-        printer_id,
-        remote_filename,
-        plate_id,
-        ams_mapping=body.ams_mapping,
-        timelapse=body.timelapse,
-        bed_levelling=body.bed_levelling,
-        flow_cali=body.flow_cali,
-        vibration_cali=body.vibration_cali,
-        layer_inspect=body.layer_inspect,
-        use_ams=body.use_ams,
-    )
-
-    if not started:
-        raise HTTPException(status_code=500, detail="Failed to start print")
-
-    await db.commit()
+    except DispatchEnqueueRejected as e:
+        raise HTTPException(status_code=409, detail=str(e)) from e
 
     return {
-        "status": "printing",
+        "status": "dispatched",
         "printer_id": printer_id,
-        "archive_id": archive.id,
+        "archive_id": None,
         "filename": lib_file.filename,
+        "dispatch_job_id": dispatch_result["dispatch_job_id"],
+        "dispatch_position": dispatch_result["dispatch_position"],
     }
 
 
@@ -2039,6 +1935,9 @@ async def update_file(
         if "/" in data.filename or "\\" in data.filename:
             raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
         file.filename = data.filename
+        # Also update print_name in file_metadata so the display name matches
+        if file.file_metadata and "print_name" in file.file_metadata:
+            file.file_metadata = {**file.file_metadata, "print_name": data.filename}
 
     if data.folder_id is not None:
         if data.folder_id == 0:
@@ -2138,6 +2037,62 @@ async def download_file(
     )
 
 
+@router.post("/files/{file_id}/slicer-token")
+async def create_library_slicer_token(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    token = create_slicer_download_token("library", file_id)
+    return {"token": token}
+
+
+@router.get("/files/{file_id}/dl/{token}/{filename}")
+async def download_library_file_for_slicer(
+    file_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download a library file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /files/{file_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not 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))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    abs_path = to_absolute_path(file.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    return FastAPIFileResponse(
+        str(abs_path),
+        filename=file.filename,
+        media_type="application/octet-stream",
+    )
+
+
 @router.get("/files/{file_id}/thumbnail")
 async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     """Get a file's thumbnail."""

+ 3 - 3
backend/app/api/routes/maintenance.py

@@ -1,7 +1,7 @@
 """Maintenance tracking API routes."""
 
 import logging
-from datetime import datetime
+from datetime import datetime, timezone
 
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
@@ -303,7 +303,7 @@ async def _get_printer_maintenance_internal(
     due_count = 0
     warning_count = 0
 
-    now = datetime.utcnow()
+    now = datetime.now(timezone.utc)
 
     for maint_type in all_types:
         # Skip system types that don't apply to this printer model
@@ -591,7 +591,7 @@ async def perform_maintenance(
     db.add(history)
 
     # Update item
-    item.last_performed_at = datetime.utcnow()
+    item.last_performed_at = datetime.now(timezone.utc)
     item.last_performed_hours = current_hours
 
     await db.commit()

+ 8 - 8
backend/app/api/routes/notifications.py

@@ -2,7 +2,7 @@
 
 import json
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, func, select
@@ -194,11 +194,11 @@ async def test_all_notification_providers(
 
         # Update provider status
         if success:
-            provider.last_success = datetime.utcnow()
+            provider.last_success = datetime.now(timezone.utc)
             success_count += 1
         else:
             provider.last_error = message
-            provider.last_error_at = datetime.utcnow()
+            provider.last_error_at = datetime.now(timezone.utc)
             failed_count += 1
 
         results.append(
@@ -248,7 +248,7 @@ async def get_notification_logs(
     if success is not None:
         query = query.where(NotificationLog.success == success)
     if days is not None:
-        cutoff = datetime.utcnow() - timedelta(days=days)
+        cutoff = datetime.now(timezone.utc) - timedelta(days=days)
         query = query.where(NotificationLog.created_at >= cutoff)
 
     query = query.offset(offset).limit(limit)
@@ -295,7 +295,7 @@ async def get_notification_log_stats(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get notification log statistics."""
-    cutoff = datetime.utcnow() - timedelta(days=days)
+    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
 
     # Total counts
     total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
@@ -341,7 +341,7 @@ async def clear_notification_logs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
     """Clear old notification logs."""
-    cutoff = datetime.utcnow() - timedelta(days=older_than_days)
+    cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
 
     result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
     await db.commit()
@@ -446,10 +446,10 @@ async def test_notification_provider(
 
     # Update provider status
     if success:
-        provider.last_success = datetime.utcnow()
+        provider.last_success = datetime.now(timezone.utc)
     else:
         provider.last_error = message
-        provider.last_error_at = datetime.utcnow()
+        provider.last_error_at = datetime.now(timezone.utc)
 
     await db.commit()
 

+ 72 - 6
backend/app/api/routes/print_queue.py

@@ -3,12 +3,12 @@
 import json
 import logging
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import func, select
+from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -169,6 +169,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             required_filament_types_parsed = None
 
+    # Parse filament_overrides from JSON string
+    filament_overrides_parsed = None
+    if item.filament_overrides:
+        try:
+            filament_overrides_parsed = json.loads(item.filament_overrides)
+        except json.JSONDecodeError:
+            filament_overrides_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
@@ -176,6 +184,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "target_model": item.target_model,
         "target_location": item.target_location,
         "required_filament_types": required_filament_types_parsed,
+        "filament_overrides": filament_overrides_parsed,
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
@@ -207,6 +216,11 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         response.filament_used_grams = item.archive.filament_used_grams
+        response.filament_type = item.archive.filament_type
+        response.filament_color = item.archive.filament_color
+        response.layer_height = item.archive.layer_height
+        response.nozzle_diameter = item.archive.nozzle_diameter
+        response.sliced_for_model = item.archive.sliced_for_model
         if item.plate_id:
             archive_path = settings.base_dir / item.archive.file_path
             if archive_path.exists():
@@ -223,10 +237,15 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         if not response.library_file_name:
             response.library_file_name = item.library_file.filename
         response.library_file_thumbnail = item.library_file.thumbnail_path
-        # Get print time from library file metadata if no archive
+        # Get metadata from library file if no archive
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
             response.filament_used_grams = item.library_file.file_metadata.get("filament_used_grams")
+            response.filament_type = item.library_file.file_metadata.get("filament_type")
+            response.filament_color = item.library_file.file_metadata.get("filament_color")
+            response.layer_height = item.library_file.file_metadata.get("layer_height")
+            response.nozzle_diameter = item.library_file.file_metadata.get("nozzle_diameter")
+            response.sliced_for_model = item.library_file.file_metadata.get("sliced_for_model")
         if item.plate_id:
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
@@ -248,6 +267,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
 async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
     status: str | None = Query(None, description="Filter by status"),
+    target_model: str | None = Query(
+        None, description="Filter by target model (also includes model-based items when combined with printer_id)"
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
@@ -268,7 +290,31 @@ async def list_queue(
             # Special value: filter for unassigned items
             query = query.where(PrintQueueItem.printer_id.is_(None))
         else:
-            query = query.where(PrintQueueItem.printer_id == printer_id)
+            # Resolve effective model: prefer explicit param, fall back to printer's DB model.
+            # This ensures model-based "Any X" items are returned even when the frontend
+            # doesn't send target_model (e.g. printer.model is NULL on the client side).
+            effective_model = target_model
+            if not effective_model:
+                printer_row = (
+                    await db.execute(select(Printer.model).where(Printer.id == printer_id))
+                ).scalar_one_or_none()
+                effective_model = printer_row
+
+            if effective_model:
+                # Include both printer-specific items AND model-based (unassigned) items
+                query = query.where(
+                    or_(
+                        PrintQueueItem.printer_id == printer_id,
+                        and_(
+                            PrintQueueItem.printer_id.is_(None),
+                            func.lower(PrintQueueItem.target_model) == effective_model.lower(),
+                        ),
+                    )
+                )
+            else:
+                query = query.where(PrintQueueItem.printer_id == printer_id)
+    elif target_model:
+        query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())
     if status:
         query = query.where(PrintQueueItem.status == status)
 
@@ -348,6 +394,19 @@ async def add_to_queue(
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
 
+    # If filament overrides are provided, update required_filament_types to match override types
+    filament_overrides_json = None
+    if data.filament_overrides and target_model_norm:
+        filament_overrides_json = json.dumps(data.filament_overrides)
+        # Update required_filament_types from overrides so scheduler validates against overridden types
+        override_types = sorted({o["type"] for o in data.filament_overrides if "type" in o})
+        if override_types:
+            # Merge with existing types (overrides may only cover some slots)
+            existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
+            # Replace types for overridden slots, keep others
+            all_types = existing_types | set(override_types)
+            required_filament_types = json.dumps(sorted(all_types))
+
     # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
         result = await db.execute(
@@ -369,6 +428,7 @@ async def add_to_queue(
         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,
@@ -586,6 +646,12 @@ async def update_queue_item(
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
 
+    # Serialize filament_overrides to JSON for TEXT column storage
+    if "filament_overrides" in update_data:
+        update_data["filament_overrides"] = (
+            json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
+        )
+
     for field, value in update_data.items():
         setattr(item, field, value)
 
@@ -676,7 +742,7 @@ async def cancel_queue_item(
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
 
     item.status = "cancelled"
-    item.completed_at = datetime.now()
+    item.completed_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info("Cancelled queue item %s", item_id)
@@ -719,7 +785,7 @@ async def stop_queue_item(
 
     # Update queue item status regardless - if printer is off, print is already stopped
     item.status = "cancelled"
-    item.completed_at = datetime.now()
+    item.completed_at = datetime.now(timezone.utc)
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
     await db.commit()
 

+ 220 - 47
backend/app/api/routes/printers.py

@@ -5,7 +5,7 @@ import zipfile
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -91,6 +91,123 @@ async def list_usb_cameras(
     return {"cameras": cameras}
 
 
+@router.get("/available-filaments")
+async def get_available_filaments(
+    model: str = Query(..., description="Target printer model"),
+    location: str | None = Query(None, description="Optional location filter"),
+    _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get deduplicated list of filaments loaded across all active printers of a given model.
+
+    Used by the frontend to offer filament override options for model-based queue assignment.
+    """
+    from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+
+    # Normalize model name
+    normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model
+
+    query = (
+        select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True)  # noqa: E712
+    )
+    if location:
+        query = query.where(Printer.location == location)
+
+    result = await db.execute(query)
+    printers_list = list(result.scalars().all())
+
+    if not printers_list:
+        return []
+
+    # Collect filaments from all matching printers
+    # Dedup key includes extruder_id so same color on different nozzles appears separately
+    seen: set[tuple[str, str, int | None]] = set()  # (type_upper, color_normalized, extruder_id)
+    filaments = []
+
+    for printer in printers_list:
+        status = printer_manager.get_status(printer.id)
+        if not status:
+            continue
+
+        # Get ams_extruder_map for dual-nozzle printers
+        ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+
+        # AMS trays
+        for ams_unit in status.raw_data.get("ams", []):
+            ams_id = str(ams_unit.get("id", 0))
+            extruder_id = ams_extruder_map.get(ams_id)
+            for tray in ams_unit.get("tray", []):
+                tray_type = tray.get("tray_type")
+                if not tray_type:
+                    continue
+                tray_color = tray.get("tray_color", "")
+                # Normalize color: remove alpha, add hash
+                hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
+                color = f"#{hex_color}"
+                tray_info_idx = tray.get("tray_info_idx", "")
+
+                key = (tray_type.upper(), hex_color.lower(), extruder_id)
+                if key not in seen:
+                    seen.add(key)
+                    filaments.append(
+                        {
+                            "type": tray_type,
+                            "color": color,
+                            "tray_info_idx": tray_info_idx,
+                            "extruder_id": extruder_id,
+                        }
+                    )
+
+        # External spools (vt_tray)
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
+            if not vt_type:
+                continue
+            vt_color = vt.get("tray_color", "")
+            hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
+            color = f"#{hex_color}"
+            tray_info_idx = vt.get("tray_info_idx", "")
+            vt_id = int(vt.get("id", 254))
+            extruder_id = (255 - vt_id) if ams_extruder_map else None
+
+            key = (vt_type.upper(), hex_color.lower(), extruder_id)
+            if key not in seen:
+                seen.add(key)
+                filaments.append(
+                    {
+                        "type": vt_type,
+                        "color": color,
+                        "tray_info_idx": tray_info_idx,
+                        "extruder_id": extruder_id,
+                    }
+                )
+
+    return filaments
+
+
+@router.get("/developer-mode-warnings")
+async def get_developer_mode_warnings(
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Check if any connected printer lacks developer LAN mode."""
+    result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
+    printers = result.scalars().all()
+    statuses = printer_manager.get_all_statuses()
+
+    warnings = []
+    for printer in printers:
+        state = statuses.get(printer.id)
+        if state and state.connected and state.developer_mode is False:
+            warnings.append(
+                {
+                    "printer_id": printer.id,
+                    "name": printer.name,
+                }
+            )
+    return warnings
+
+
 @router.get("/{printer_id}", response_model=PrinterResponse)
 async def get_printer(
     printer_id: int,
@@ -226,7 +343,7 @@ async def get_printer_status(
 
     # Determine cover URL if there's an active print (including paused)
     cover_url = None
-    if state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file:
+    if state.state in ("RUNNING", "PAUSE") and state.gcode_file:
         cover_url = f"/api/v1/printers/{printer_id}/cover"
 
     # Convert HMS errors to response format
@@ -402,8 +519,9 @@ async def get_printer_status(
 
     # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
     ams_mapping = raw_data.get("ams_mapping", [])
-    # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
-    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+    # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
+    # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
+    ams_extruder_map = state.ams_extruder_map or {}
     logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
 
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
@@ -466,6 +584,7 @@ async def get_printer_status(
         big_fan2_speed=state.big_fan2_speed,
         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),
     )
 
@@ -1641,53 +1760,107 @@ async def configure_ams_slot(
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
 
-    # Detect RFID spool before sending commands
-    is_rfid_spool = False
-    state = printer_manager.get_status(printer_id)
-    if state and state.raw_data:
-        from backend.app.api.routes.inventory import _find_tray_in_ams_data
-        from backend.app.services.spool_tag_matcher import is_valid_tag
+    # Resolve tray_info_idx for the MQTT command.
+    # Priority:
+    #   1. Use the provided tray_info_idx if set (including cloud-synced
+    #      custom presets like PFUS* / P*).
+    #   2. Reuse the slot's existing tray_info_idx if it's a specific
+    #      (non-generic) preset for the same material.
+    #   3. Fall back to a generic Bambu filament ID.
+    _GENERIC_FILAMENT_IDS = {
+        "PLA": "GFL99",
+        "PETG": "GFG99",
+        "ABS": "GFB99",
+        "ASA": "GFB98",
+        "PC": "GFC99",
+        "PA": "GFN99",
+        "NYLON": "GFN99",
+        "TPU": "GFU99",
+        "PVA": "GFS99",
+        "HIPS": "GFS98",
+        "PLA-CF": "GFL98",
+        "PETG-CF": "GFG98",
+        "PA-CF": "GFN98",
+        "PETG HF": "GFG96",
+    }
+    _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values())
+    effective_tray_info_idx = tray_info_idx
 
-        ams_data = state.raw_data.get("ams", {})
-        ams_list = (
-            ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
-        )
-        current_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
-        if current_tray:
-            is_rfid_spool = is_valid_tag(
-                current_tray.get("tag_uid", ""),
-                current_tray.get("tray_uuid", ""),
+    if not tray_info_idx:
+        # No preset provided — try slot reuse or generic fallback
+        current_tray_info_idx = ""
+        current_tray_type = ""
+        state = printer_manager.get_status(printer_id)
+        if state and state.raw_data:
+            from backend.app.api.routes.inventory import _find_tray_in_ams_data
+
+            if ams_id == 255:
+                vt_tray = state.raw_data.get("vt_tray") or []
+                ext_id = tray_id + 254
+                for vt in vt_tray:
+                    if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
+                        current_tray_info_idx = vt.get("tray_info_idx", "")
+                        current_tray_type = vt.get("tray_type", "")
+                        break
+            else:
+                ams_data = state.raw_data.get("ams", {})
+                ams_list = (
+                    ams_data.get("ams", [])
+                    if isinstance(ams_data, dict)
+                    else ams_data
+                    if isinstance(ams_data, list)
+                    else []
+                )
+                cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
+                if cur_tray:
+                    current_tray_info_idx = cur_tray.get("tray_info_idx", "")
+                    current_tray_type = cur_tray.get("tray_type", "")
+
+        if (
+            current_tray_info_idx
+            and current_tray_info_idx not in _GENERIC_ID_VALUES
+            and current_tray_type
+            and current_tray_type.upper() == tray_type.upper()
+        ):
+            logger.info(
+                "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
+                current_tray_info_idx,
+                tray_type,
             )
+            effective_tray_info_idx = current_tray_info_idx
+        elif tray_type:
+            material = tray_type.upper().strip()
+            generic = (
+                _GENERIC_FILAMENT_IDS.get(material)
+                or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or ""
+            )
+            if generic:
+                logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type)
+                effective_tray_info_idx = generic
 
     # Send filament setting + K-profile commands
-    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
-
-    if is_rfid_spool:
-        # RFID spool: skip ams_set_filament_setting to preserve RFID state (eye icon).
-        # The firmware already has filament config from the RFID tag.
-        logger.info("[configure_ams_slot] RFID spool detected — skipping ams_set_filament_setting")
-    else:
-        # Non-RFID spool: send filament setting (type, color, temp)
-        # When a K-profile is selected, use the K-profile's filament_id as
-        # tray_info_idx so BambuStudio queries the right PA history table.
-        # But always use the PRESET's setting_id (not the K-profile's) —
-        # BambuStudio uses setting_id to identify the filament preset and
-        # overriding it with the K-profile's setting_id confuses the slicer.
-        effective_tray_info_idx = filament_id_for_kprofile if cali_idx >= 0 else tray_info_idx
-        success = client.ams_set_filament_setting(
-            ams_id=ams_id,
-            tray_id=tray_id,
-            tray_info_idx=effective_tray_info_idx,
-            tray_type=tray_type,
-            tray_sub_brands=tray_sub_brands,
-            tray_color=tray_color,
-            nozzle_temp_min=nozzle_temp_min,
-            nozzle_temp_max=nozzle_temp_max,
-            setting_id=setting_id,
-        )
+    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
+
+    # Always send ams_set_filament_setting — the user explicitly clicked
+    # "Configure Slot", so honor that.  Previous versions skipped this for
+    # RFID-tagged slots to preserve the slicer eye icon, but printers cache
+    # stale tag_uid/tray_uuid after a BL spool is removed, causing the check
+    # to false-positive on non-RFID slots and silently drop the command.
+    success = client.ams_set_filament_setting(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_type=tray_type,
+        tray_sub_brands=tray_sub_brands,
+        tray_color=tray_color,
+        nozzle_temp_min=nozzle_temp_min,
+        nozzle_temp_max=nozzle_temp_max,
+        setting_id=setting_id,
+    )
 
-        if not success:
-            raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
 
     # Method 1: Select existing calibration profile by cali_idx
     # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
@@ -1861,7 +2034,7 @@ async def stop_print(
 @router.post("/{printer_id}/clear-plate")
 async def clear_plate(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Acknowledge that the build plate has been cleared after a finished/failed print.

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

@@ -144,7 +144,7 @@ async def compute_project_stats(
         parts_progress_percent=parts_progress_percent,
         estimated_cost=round((sums.total_filament_cost or 0), 2),
         total_energy_kwh=round((sums.total_energy or 0), 3),
-        total_energy_cost=round((sums.total_energy_cost or 0), 2),
+        total_energy_cost=round((sums.total_energy_cost or 0), 3),
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,

+ 4 - 3
backend/app/api/routes/settings.py

@@ -89,6 +89,7 @@ async def get_settings(
                 "spoolman_report_partial_usage",
                 "check_updates",
                 "check_printer_firmware",
+                "include_beta_updates",
                 "virtual_printer_enabled",
                 "ftp_retry_enabled",
                 "mqtt_enabled",
@@ -520,10 +521,10 @@ async def restore_backup(
 async def get_network_interfaces(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
-    """Get available network interfaces for SSDP proxy configuration."""
-    from backend.app.services.network_utils import get_network_interfaces
+    """Get available network interfaces with all IPs (primary + aliases)."""
+    from backend.app.services.network_utils import get_all_interface_ips
 
-    interfaces = get_network_interfaces()
+    interfaces = get_all_interface_ips()
     return {"interfaces": interfaces}
 
 

+ 6 - 6
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,7 @@
 """API routes for smart plug management."""
 
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from pydantic import BaseModel
@@ -605,7 +605,7 @@ async def control_smart_plug(
         elif expected_state == "OFF" and plug.printer_id:
             # Mark printer offline immediately for faster UI update
             printer_manager.mark_printer_offline(plug.printer_id)
-    plug.last_checked = datetime.utcnow()
+    plug.last_checked = datetime.now(timezone.utc)
     await db.commit()
 
     # Trigger associated scripts if this is a main (non-script) plug
@@ -692,7 +692,7 @@ async def get_plug_status(
             # Update last state in database
             if is_reachable and data.state:
                 plug.last_state = data.state
-                plug.last_checked = datetime.utcnow()
+                plug.last_checked = datetime.now(timezone.utc)
                 await db.commit()
 
             energy_data = None
@@ -727,7 +727,7 @@ async def get_plug_status(
     # Update last state in database
     if status["reachable"]:
         plug.last_state = status["state"]
-        plug.last_checked = datetime.utcnow()
+        plug.last_checked = datetime.now(timezone.utc)
         await db.commit()
 
     # Fetch energy data if device is reachable
@@ -756,7 +756,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
     # Cooldown: don't alert more than once per 5 minutes
     cooldown_minutes = 5
     if plug.power_alert_last_triggered:
-        time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
+        time_since_last = datetime.now(timezone.utc) - plug.power_alert_last_triggered
         if time_since_last < timedelta(minutes=cooldown_minutes):
             return
 
@@ -777,7 +777,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
         threshold = plug.power_alert_low
 
     if alert_triggered:
-        plug.power_alert_last_triggered = datetime.utcnow()
+        plug.power_alert_last_triggered = datetime.now(timezone.utc)
         await db.commit()
 
         # Send notification

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

@@ -0,0 +1,388 @@
+"""SpoolBuddy device management API routes."""
+
+import logging
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+from backend.app.models.user import User
+from backend.app.schemas.spoolbuddy import (
+    CalibrationResponse,
+    DeviceRegisterRequest,
+    DeviceResponse,
+    HeartbeatRequest,
+    HeartbeatResponse,
+    ScaleReadingRequest,
+    SetCalibrationFactorRequest,
+    TagRemovedRequest,
+    TagScannedRequest,
+    UpdateSpoolWeightRequest,
+)
+from backend.app.services.spool_tag_matcher import get_spool_by_tag
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
+
+OFFLINE_THRESHOLD_SECONDS = 30
+
+
+def _is_online(device: SpoolBuddyDevice) -> bool:
+    if not device.last_seen:
+        return False
+    return (
+        datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
+    ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
+
+
+def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
+    return DeviceResponse(
+        id=device.id,
+        device_id=device.device_id,
+        hostname=device.hostname,
+        ip_address=device.ip_address,
+        firmware_version=device.firmware_version,
+        has_nfc=device.has_nfc,
+        has_scale=device.has_scale,
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+        last_seen=device.last_seen,
+        pending_command=device.pending_command,
+        nfc_ok=device.nfc_ok,
+        scale_ok=device.scale_ok,
+        uptime_s=device.uptime_s,
+        online=_is_online(device),
+        created_at=device.created_at,
+        updated_at=device.updated_at,
+    )
+
+
+# --- Device endpoints ---
+
+
+@router.post("/devices/register", response_model=DeviceResponse)
+async def register_device(
+    req: DeviceRegisterRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Register or re-register a SpoolBuddy device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+
+    now = datetime.now(timezone.utc)
+    if device:
+        device.hostname = req.hostname
+        device.ip_address = req.ip_address
+        device.firmware_version = req.firmware_version
+        device.has_nfc = req.has_nfc
+        device.has_scale = req.has_scale
+        device.last_seen = now
+        logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
+    else:
+        device = SpoolBuddyDevice(
+            device_id=req.device_id,
+            hostname=req.hostname,
+            ip_address=req.ip_address,
+            firmware_version=req.firmware_version,
+            has_nfc=req.has_nfc,
+            has_scale=req.has_scale,
+            tare_offset=req.tare_offset,
+            calibration_factor=req.calibration_factor,
+            last_seen=now,
+        )
+        db.add(device)
+        logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
+
+    await db.commit()
+    await db.refresh(device)
+
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_online",
+            "device_id": device.device_id,
+            "hostname": device.hostname,
+        }
+    )
+
+    return _device_to_response(device)
+
+
+@router.get("/devices", response_model=list[DeviceResponse])
+async def list_devices(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List all registered SpoolBuddy devices."""
+    result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
+    devices = list(result.scalars().all())
+    return [_device_to_response(d) for d in devices]
+
+
+@router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
+async def device_heartbeat(
+    device_id: str,
+    req: HeartbeatRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Daemon heartbeat — updates status and returns pending 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")
+
+    was_offline = not _is_online(device)
+    now = datetime.now(timezone.utc)
+
+    device.last_seen = now
+    device.nfc_ok = req.nfc_ok
+    device.scale_ok = req.scale_ok
+    device.uptime_s = req.uptime_s
+    if req.firmware_version:
+        device.firmware_version = req.firmware_version
+    if req.ip_address:
+        device.ip_address = req.ip_address
+
+    # Return and clear pending command
+    pending = device.pending_command
+    device.pending_command = None
+
+    await db.commit()
+
+    if was_offline:
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_online",
+                "device_id": device.device_id,
+                "hostname": device.hostname,
+            }
+        )
+
+    return HeartbeatResponse(
+        pending_command=pending,
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
+# --- NFC endpoints ---
+
+
+@router.post("/nfc/tag-scanned")
+async def nfc_tag_scanned(
+    req: TagScannedRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """RPi reports NFC tag detected — lookup spool and broadcast."""
+    spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
+
+    if spool:
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_matched",
+                "device_id": req.device_id,
+                "tag_uid": req.tag_uid,
+                "spool": {
+                    "id": spool.id,
+                    "material": spool.material,
+                    "subtype": spool.subtype,
+                    "color_name": spool.color_name,
+                    "rgba": spool.rgba,
+                    "brand": spool.brand,
+                    "label_weight": spool.label_weight,
+                    "core_weight": spool.core_weight,
+                    "weight_used": spool.weight_used,
+                },
+            }
+        )
+        logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
+    else:
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_unknown_tag",
+                "device_id": req.device_id,
+                "tag_uid": req.tag_uid,
+                "sak": req.sak,
+                "tag_type": req.tag_type,
+            }
+        )
+        logger.info("SpoolBuddy unknown tag: %s", req.tag_uid)
+
+    return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
+
+
+@router.post("/nfc/tag-removed")
+async def nfc_tag_removed(
+    req: TagRemovedRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """RPi reports NFC tag removed — broadcast event."""
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_tag_removed",
+            "device_id": req.device_id,
+            "tag_uid": req.tag_uid,
+        }
+    )
+    return {"status": "ok"}
+
+
+# --- Scale endpoints ---
+
+
+@router.post("/scale/reading")
+async def scale_reading(
+    req: ScaleReadingRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """RPi reports scale weight — broadcast to all clients."""
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_weight",
+            "device_id": req.device_id,
+            "weight_grams": req.weight_grams,
+            "stable": req.stable,
+            "raw_adc": req.raw_adc,
+        }
+    )
+    return {"status": "ok"}
+
+
+@router.post("/scale/update-spool-weight")
+async def update_spool_weight(
+    req: UpdateSpoolWeightRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update spool's used weight from scale reading."""
+    from backend.app.models.spool import Spool
+
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(status_code=404, detail="Spool not found")
+
+    # net weight = total on scale minus empty spool core
+    net_filament = max(0, req.weight_grams - spool.core_weight)
+    spool.weight_used = max(0, spool.label_weight - net_filament)
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
+        spool.id,
+        req.weight_grams,
+        spool.weight_used,
+    )
+    return {"status": "ok", "weight_used": spool.weight_used}
+
+
+# --- Calibration endpoints ---
+
+
+@router.post("/devices/{device_id}/calibration/tare")
+async def tare_scale(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Set pending tare command for the device to pick up."""
+    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")
+
+    device.pending_command = "tare"
+    await db.commit()
+    return {"status": "ok", "message": "Tare command queued"}
+
+
+@router.post("/devices/{device_id}/calibration/set-factor")
+async def set_calibration_factor(
+    device_id: str,
+    req: SetCalibrationFactorRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Calculate and store calibration factor from a known weight."""
+    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")
+
+    raw_delta = req.raw_adc - device.tare_offset
+    if raw_delta == 0:
+        raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
+
+    device.calibration_factor = req.known_weight_grams / raw_delta
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
+        device_id,
+        device.calibration_factor,
+        req.known_weight_grams,
+        req.raw_adc,
+        device.tare_offset,
+    )
+    return CalibrationResponse(
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
+@router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
+async def get_calibration(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get current calibration values for a device."""
+    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 CalibrationResponse(
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
+# --- Background watchdog ---
+
+
+async def spoolbuddy_watchdog():
+    """Check for devices that have gone offline (no heartbeat for 30s).
+
+    Called periodically from the main app's background task loop.
+    """
+    from backend.app.core.database import async_session
+
+    async with async_session() as db:
+        result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
+        devices = list(result.scalars().all())
+
+        threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
+        for device in devices:
+            last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
+            if last_seen and last_seen < threshold:
+                # Only broadcast once — clear last_seen after marking offline
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_offline",
+                        "device_id": device.device_id,
+                    }
+                )
+                device.last_seen = None
+                logger.info("SpoolBuddy device offline: %s", device.device_id)
+
+        await db.commit()

+ 59 - 15
backend/app/api/routes/support.py

@@ -495,6 +495,7 @@ async def _collect_support_info() -> dict:
                     "external_camera_configured": bool(printer.external_camera_url),
                     "plate_detection_enabled": printer.plate_detection_enabled,
                     "hms_error_count": len(state.hms_errors) if state else 0,
+                    "developer_mode": state.developer_mode if state else None,
                     "nozzle_rack_count": len(state.nozzle_rack) if state else 0,
                 }
             )
@@ -512,11 +513,13 @@ async def _collect_support_info() -> dict:
             "cloud_token",
             "mqtt_password",
             "email",
+            "username",
             "vapid",
             "private_key",
             "public_key",
             "webhook",
             "url",
+            "path",  # Filesystem paths may contain usernames
             "config",  # URLs may contain IPs, configs may have embedded secrets
         }
         for s in all_settings:
@@ -660,17 +663,33 @@ async def _collect_support_info() -> dict:
     return info
 
 
-def _sanitize_log_content(content: str) -> str:
+def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
     """Remove sensitive data from log content."""
-    # Replace IP addresses with [IP]
-    content = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", content)
+    # First, replace known sensitive values (database-aware exact matching)
+    # This catches printer names, usernames, and other arbitrary user-chosen strings
+    # that regex patterns cannot detect
+    if sensitive_strings:
+        # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
+        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
+            if len(value) < 3:
+                continue  # Skip very short strings to prevent over-redaction
+            content = re.sub(re.escape(value), label, content)
+
+    # Replace credentials in URLs (e.g. http://user:pass@host)
+    content = re.sub(r"(https?://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
 
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
-    # These appear in logs as [SERIAL] or in messages
-    content = re.sub(r"\b(0[0-3][A-Z0-9])[A-Z0-9]{9,13}\b", r"\1[SERIAL]", content)
+    content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
+
+    # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
+    content = re.sub(
+        r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
+        "[IP]",
+        content,
+    )
 
     # Replace paths with usernames
     content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
@@ -680,7 +699,7 @@ def _sanitize_log_content(content: str) -> str:
     return content
 
 
-def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
+def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
     """Get log file content, limited to max_bytes from the end."""
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
@@ -698,7 +717,7 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
             content = f.read().decode("utf-8", errors="replace")
 
     # Sanitize sensitive data
-    content = _sanitize_log_content(content)
+    content = _sanitize_log_content(content, sensitive_strings)
     return content.encode("utf-8")
 
 
@@ -707,16 +726,41 @@ async def generate_support_bundle(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Generate a support bundle ZIP file for issue reporting."""
-    # Check if debug logging is enabled
+    # Check if debug logging is enabled and collect sensitive values for redaction
     async with async_session() as db:
         enabled, _enabled_at = await _get_debug_setting(db)
 
-    if not enabled:
-        raise HTTPException(
-            status_code=400,
-            detail="Debug logging must be enabled before generating a support bundle. "
-            "Please enable debug logging, reproduce the issue, then generate the bundle.",
-        )
+        if not enabled:
+            raise HTTPException(
+                status_code=400,
+                detail="Debug logging must be enabled before generating a support bundle. "
+                "Please enable debug logging, reproduce the issue, then generate the bundle.",
+            )
+
+        # Collect known sensitive values for log redaction
+        sensitive_strings: dict[str, str] = {}
+
+        # Printer names, serial numbers, and IP addresses
+        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address))
+        for name, serial, ip_address in result.all():
+            if name:
+                sensitive_strings[name] = "[PRINTER]"
+            if serial:
+                sensitive_strings[serial] = "[SERIAL]"
+            if ip_address:
+                sensitive_strings[ip_address] = "[IP]"
+
+        # Auth usernames
+        result = await db.execute(select(User.username))
+        for (username,) in result.all():
+            if username:
+                sensitive_strings[username] = "[USER]"
+
+        # Bambu Cloud email
+        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
+        cloud_email = result.scalar_one_or_none()
+        if cloud_email:
+            sensitive_strings[cloud_email] = "[EMAIL]"
 
     # Collect support info
     support_info = await _collect_support_info()
@@ -730,7 +774,7 @@ async def generate_support_bundle(
         zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
 
         # Add log file
-        log_content = _get_log_content()
+        log_content = _get_log_content(sensitive_strings=sensitive_strings)
         zf.writestr("bambuddy.log", log_content)
 
     zip_buffer.seek(0)

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

@@ -189,6 +189,11 @@ async def check_for_updates(
             "message": "Update checks are disabled",
         }
 
+    # Check if beta updates should be included
+    result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
+    beta_setting = result.scalar_one_or_none()
+    include_beta = beta_setting and beta_setting.value.lower() == "true"
+
     _update_status = {
         "status": "checking",
         "progress": 0,
@@ -199,7 +204,7 @@ async def check_for_updates(
     try:
         async with httpx.AsyncClient() as client:
             response = await client.get(
-                f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
+                f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
                 headers={"Accept": "application/vnd.github.v3+json"},
                 timeout=10.0,
             )
@@ -220,7 +225,36 @@ async def check_for_updates(
                 }
 
             response.raise_for_status()
-            release_data = response.json()
+            releases = response.json()
+
+            # Find the appropriate release based on beta setting
+            release_data = None
+            for release in releases:
+                tag = release.get("tag_name", "")
+                if include_beta:
+                    # Accept any release (first = newest)
+                    release_data = release
+                    break
+                else:
+                    # Skip prereleases (based on version parsing, not GitHub flag)
+                    parsed = parse_version(tag)
+                    if parsed[4] == 0:  # is_prerelease == 0
+                        release_data = release
+                        break
+
+            if not release_data:
+                _update_status = {
+                    "status": "idle",
+                    "progress": 100,
+                    "message": "No releases found",
+                    "error": None,
+                }
+                return {
+                    "update_available": False,
+                    "current_version": APP_VERSION,
+                    "latest_version": None,
+                    "message": "No releases found",
+                }
 
             latest_version = release_data.get("tag_name", "").lstrip("v")
             release_name = release_data.get("name", latest_version)

+ 380 - 0
backend/app/api/routes/virtual_printers.py

@@ -0,0 +1,380 @@
+import logging
+
+from fastapi import APIRouter, Depends
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
+
+
+class VirtualPrinterCreate(BaseModel):
+    name: str = "Bambuddy"
+    enabled: bool = False
+    mode: str = "immediate"
+    model: str | None = None
+    access_code: str | None = None
+    target_printer_id: int | None = None
+    bind_ip: str | None = None
+    remote_interface_ip: str | None = None
+
+
+class VirtualPrinterUpdate(BaseModel):
+    name: str | None = None
+    enabled: bool | None = None
+    mode: str | None = None
+    model: str | None = None
+    access_code: str | None = None
+    target_printer_id: int | None = None
+    bind_ip: str | None = None
+    remote_interface_ip: str | None = None
+
+
+def _vp_to_dict(vp, status: dict | None = None) -> dict:
+    """Convert VirtualPrinter model to response dict."""
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
+    from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
+
+    model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
+    serial = _get_serial_for_model(model_code, vp.serial_suffix)
+
+    return {
+        "id": vp.id,
+        "name": vp.name,
+        "enabled": vp.enabled,
+        "mode": vp.mode,
+        "model": model_code,
+        "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
+        "access_code_set": bool(vp.access_code),
+        "serial": serial,
+        "target_printer_id": vp.target_printer_id,
+        "bind_ip": vp.bind_ip,
+        "remote_interface_ip": vp.remote_interface_ip,
+        "position": vp.position,
+        "status": status or {"running": False, "pending_files": 0},
+    }
+
+
+@router.get("")
+async def list_virtual_printers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """List all virtual printers with status."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
+    vps = result.scalars().all()
+
+    printers = []
+    for vp in vps:
+        instance = virtual_printer_manager.get_instance(vp.id)
+        status = instance.get_status() if instance else {"running": False, "pending_files": 0}
+        printers.append(_vp_to_dict(vp, status))
+
+    return {
+        "printers": printers,
+        "models": VIRTUAL_PRINTER_MODELS,
+    }
+
+
+@router.post("")
+async def create_virtual_printer(
+    body: VirtualPrinterCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Create a new virtual printer."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+    from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
+
+    # Validate mode
+    if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+        return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
+
+    # Validate model
+    if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
+        return JSONResponse(
+            status_code=400,
+            content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
+        )
+
+    # Validate access code length
+    if body.access_code and len(body.access_code) != 8:
+        return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
+
+    # Validation when enabling
+    if body.enabled:
+        if not body.bind_ip:
+            return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
+        if body.mode == "proxy":
+            if not body.target_printer_id:
+                return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
+        else:
+            if not body.access_code:
+                return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
+
+    # Validate proxy target printer exists
+    if body.target_printer_id:
+        from backend.app.models.printer import Printer
+
+        result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
+        if not result.scalar_one_or_none():
+            return JSONResponse(
+                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
+            )
+
+    # Validate bind_ip uniqueness (against all enabled VPs)
+    if body.bind_ip:
+        result = await db.execute(
+            select(VirtualPrinter).where(
+                VirtualPrinter.bind_ip == body.bind_ip,
+                VirtualPrinter.enabled == True,  # noqa: E712
+            )
+        )
+        if result.scalar_one_or_none():
+            return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
+
+    # Generate next serial suffix
+    result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
+    last_suffix = result.scalar()
+    if last_suffix:
+        try:
+            next_num = int(last_suffix) + 1
+            new_suffix = str(next_num).zfill(9)
+        except ValueError:
+            new_suffix = "391800002"
+    else:
+        new_suffix = "391800001"
+
+    # Get next position
+    result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
+    last_pos = result.scalar()
+    next_pos = (last_pos or 0) + 1
+
+    vp = VirtualPrinter(
+        name=body.name,
+        enabled=body.enabled,
+        mode=body.mode,
+        model=body.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+        access_code=body.access_code,
+        target_printer_id=body.target_printer_id,
+        bind_ip=body.bind_ip,
+        remote_interface_ip=body.remote_interface_ip,
+        serial_suffix=new_suffix,
+        position=next_pos,
+    )
+    db.add(vp)
+    await db.commit()
+    await db.refresh(vp)
+
+    logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
+
+    # Sync services if enabled
+    if body.enabled:
+        try:
+            await virtual_printer_manager.sync_from_db()
+        except Exception as e:
+            logger.error("Failed to start virtual printer after create: %s", e)
+
+    return _vp_to_dict(vp)
+
+
+@router.get("/{vp_id}")
+async def get_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Get a single virtual printer with status."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    instance = virtual_printer_manager.get_instance(vp.id)
+    status = instance.get_status() if instance else {"running": False, "pending_files": 0}
+
+    return _vp_to_dict(vp, status)
+
+
+@router.put("/{vp_id}")
+async def update_virtual_printer(
+    vp_id: int,
+    body: VirtualPrinterUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Update a virtual printer."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    logger.debug(
+        "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
+        vp_id,
+        body.model_dump(exclude_unset=True),
+        vp.mode,
+        vp.enabled,
+        bool(vp.access_code),
+        vp.bind_ip,
+        vp.target_printer_id,
+    )
+
+    # Apply updates
+    if body.name is not None:
+        vp.name = body.name
+    if body.mode is not None:
+        if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+            return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
+        vp.mode = body.mode
+    if body.model is not None:
+        if body.model not in VIRTUAL_PRINTER_MODELS:
+            return JSONResponse(
+                status_code=400,
+                content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
+            )
+        vp.model = body.model
+    if body.access_code is not None:
+        if body.access_code and len(body.access_code) != 8:
+            return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
+        vp.access_code = body.access_code
+    if body.target_printer_id is not None:
+        from backend.app.models.printer import Printer
+
+        result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
+        if not result.scalar_one_or_none():
+            return JSONResponse(
+                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
+            )
+        vp.target_printer_id = body.target_printer_id
+    if body.bind_ip is not None:
+        vp.bind_ip = body.bind_ip
+    if body.remote_interface_ip is not None:
+        vp.remote_interface_ip = body.remote_interface_ip
+
+    # Determine final enabled state
+    explicitly_enabling = body.enabled is True
+    new_enabled = body.enabled if body.enabled is not None else vp.enabled
+    effective_mode = vp.mode
+
+    if explicitly_enabling:
+        # User is explicitly toggling on — enforce all requirements
+        if not vp.bind_ip:
+            logger.warning("Update VP %d rejected: no bind_ip", vp_id)
+            return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
+        # Validate bind_ip uniqueness (against all enabled VPs)
+        existing = await db.execute(
+            select(VirtualPrinter).where(
+                VirtualPrinter.bind_ip == vp.bind_ip,
+                VirtualPrinter.id != vp_id,
+                VirtualPrinter.enabled == True,  # noqa: E712
+            )
+        )
+        conflict = existing.scalar_one_or_none()
+        if conflict:
+            logger.warning(
+                "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
+                vp_id,
+                vp.bind_ip,
+                conflict.id,
+                conflict.enabled,
+                conflict.mode,
+            )
+            return JSONResponse(
+                status_code=400,
+                content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
+            )
+        if effective_mode == "proxy":
+            if not vp.target_printer_id:
+                logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
+                return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
+        else:
+            if not vp.access_code:
+                logger.warning(
+                    "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
+                )
+                return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
+    elif new_enabled and body.enabled is None:
+        # VP is already enabled and user is changing other fields —
+        # auto-disable if new state doesn't meet requirements
+        if not vp.bind_ip:
+            new_enabled = False
+        elif effective_mode == "proxy":
+            if not vp.target_printer_id:
+                new_enabled = False
+        else:
+            if not vp.access_code:
+                new_enabled = False
+
+    vp.enabled = new_enabled
+
+    await db.commit()
+    await db.refresh(vp)
+
+    logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
+
+    # Sync services
+    try:
+        await virtual_printer_manager.sync_from_db()
+    except Exception as e:
+        logger.error("Failed to sync virtual printers after update: %s", e)
+
+    instance = virtual_printer_manager.get_instance(vp.id)
+    status = instance.get_status() if instance else {"running": False, "pending_files": 0}
+
+    return _vp_to_dict(vp, status)
+
+
+@router.delete("/{vp_id}")
+async def delete_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Delete a virtual printer."""
+    from sqlalchemy import delete as sql_delete
+
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    vp_name = vp.name
+
+    # Stop instance if running
+    await virtual_printer_manager.remove_instance(vp_id)
+
+    # Delete from DB
+    await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    await db.commit()
+
+    logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
+
+    # Resync remaining services
+    try:
+        await virtual_printer_manager.sync_from_db()
+    except Exception as e:
+        logger.error("Failed to sync virtual printers after delete: %s", e)
+
+    return {"detail": "Deleted", "id": vp_id}

+ 10 - 0
backend/app/api/routes/websocket.py

@@ -3,6 +3,7 @@ import logging
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 
 from backend.app.core.websocket import ws_manager
+from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
 logger = logging.getLogger(__name__)
@@ -27,6 +28,15 @@ async def websocket_endpoint(websocket: WebSocket):
                     "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                 }
             )
+
+        dispatch_state = await background_dispatch.get_state()
+        if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
+            await websocket.send_json(
+                {
+                    "type": "background_dispatch",
+                    "data": dispatch_state,
+                }
+            )
         logger.info("Sent initial status for %s printers", len(statuses))
 
         # Keep connection alive and handle incoming messages

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

@@ -3,7 +3,7 @@ from __future__ import annotations
 import logging
 import os
 import secrets
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from typing import Annotated
 
@@ -98,6 +98,43 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 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]] = {}
+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
+    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]
+
+    token = secrets.token_urlsafe(24)
+    resource_key = f"{resource_type}:{resource_id}"
+    _slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
+    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
+    expected_key = f"{resource_type}:{resource_id}"
+    if resource_key != expected_key:
+        return False
+    # Token is single-use
+    del _slicer_tokens[token]
+    return True
+
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
@@ -119,9 +156,9 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
     """Create a JWT access token."""
     to_encode = data.copy()
     if expires_delta:
-        expire = datetime.utcnow() + expires_delta
+        expire = datetime.now(timezone.utc) + expires_delta
     else:
-        expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     to_encode.update({"exp": expire})
     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
     return encoded_jwt
@@ -198,10 +235,10 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
         for api_key in api_keys:
             if verify_password(api_key_value, api_key.key_hash):
                 # Check expiration
-                if api_key.expires_at and api_key.expires_at < datetime.now():
+                if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
                     return None  # Expired
                 # Update last_used timestamp
-                api_key.last_used = datetime.now()
+                api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
                 return api_key
     except Exception as e:
@@ -414,13 +451,13 @@ async def get_api_key(
         # Check if key matches (verify against hash)
         if verify_password(api_key_value, api_key.key_hash):
             # Check expiration
-            if api_key.expires_at and api_key.expires_at < datetime.now():
+            if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     detail="API key has expired",
                 )
             # Update last_used timestamp
-            api_key.last_used = datetime.now()
+            api_key.last_used = datetime.now(timezone.utc)
             await db.commit()
             return api_key
 

+ 1 - 1
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.0"
+APP_VERSION = "0.2.1"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

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

@@ -102,7 +102,9 @@ async def init_db():
         spool_catalog,
         spool_k_profile,
         spool_usage_history,
+        spoolbuddy_device,
         user,
+        virtual_printer,
     )
 
     async with engine.begin() as conn:
@@ -1185,6 +1187,12 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Create spool_usage_history table for filament consumption tracking
     try:
         await conn.execute(
@@ -1216,6 +1224,100 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add cost tracking fields to spool table
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL"))
+    except OperationalError:
+        pass  # Already applied
+    # Migration: Add cost field to spool_usage_history table
+    try:
+        await conn.execute(text("ALTER TABLE spool_usage_history ADD COLUMN cost REAL"))
+    except OperationalError:
+        pass  # Already applied
+    # Migration: Add archive_id field to spool_usage_history table
+    try:
+        await conn.execute(
+            text("ALTER TABLE spool_usage_history ADD COLUMN archive_id INTEGER REFERENCES print_archives(id)")
+        )
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Migrate single virtual printer key-value settings to virtual_printers table
+    try:
+        # Check if virtual_printers table has any rows
+        result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
+        count = result.scalar() or 0
+
+        if count == 0:
+            # Check if old key-value settings exist
+            result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
+            row = result.fetchone()
+            if row:
+                # Old settings exist — migrate to first virtual printer row
+                old_enabled = row[0] == "true" if row[0] else False
+
+                result = await conn.execute(
+                    text("SELECT value FROM settings WHERE key = 'virtual_printer_access_code'")
+                )
+                row = result.fetchone()
+                old_access_code = row[0] if row else None
+
+                result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
+                row = result.fetchone()
+                old_mode = row[0] if row else "immediate"
+                if old_mode == "queue":
+                    old_mode = "review"
+
+                result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
+                row = result.fetchone()
+                old_model = row[0] if row else "3DPrinter-X1-Carbon"
+
+                result = await conn.execute(
+                    text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
+                )
+                row = result.fetchone()
+                old_target_id = int(row[0]) if row and row[0] else None
+
+                result = await conn.execute(
+                    text("SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'")
+                )
+                row = result.fetchone()
+                old_remote_iface = row[0] if row else None
+
+                await conn.execute(
+                    text("""
+                        INSERT INTO virtual_printers
+                            (name, enabled, mode, model, access_code, target_printer_id,
+                             bind_ip, remote_interface_ip, serial_suffix, position)
+                        VALUES
+                            (:name, :enabled, :mode, :model, :access_code, :target_id,
+                             NULL, :remote_iface, '391800001', 0)
+                    """),
+                    {
+                        "name": "Bambuddy",
+                        "enabled": old_enabled,
+                        "mode": old_mode or "immediate",
+                        "model": old_model,
+                        "access_code": old_access_code,
+                        "target_id": old_target_id,
+                        "remote_iface": old_remote_iface,
+                    },
+                )
+    except OperationalError:
+        pass  # Table may not exist yet on first run
+
+    # Migration: Add filament_overrides column to print_queue for filament override in model-based assignment
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN filament_overrides TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
@@ -1358,6 +1460,19 @@ async def seed_default_groups():
 
         await session.commit()
 
+        # Migrate new permissions: grant printers:clear_plate to all groups with printers:control
+        result = await session.execute(select(Group))
+        all_groups = result.scalars().all()
+        for group in all_groups:
+            if (
+                group.permissions
+                and "printers:control" in group.permissions
+                and "printers:clear_plate" not in group.permissions
+            ):
+                group.permissions = [*group.permissions, "printers:clear_plate"]
+                logger.info("Added printers:clear_plate to group '%s' (has printers:control)", group.name)
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
             # Refresh to get newly created groups

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

@@ -22,6 +22,7 @@ class Permission(StrEnum):
     PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
     PRINTERS_FILES = "printers:files"  # Send files to printer
     PRINTERS_AMS_RFID = "printers:ams_rfid"  # Re-read AMS RFID tags
+    PRINTERS_CLEAR_PLATE = "printers:clear_plate"  # Confirm plate cleared for next print
 
     # Archives
     ARCHIVES_READ = "archives:read"
@@ -167,6 +168,7 @@ PERMISSION_CATEGORIES = {
         Permission.PRINTERS_CONTROL,
         Permission.PRINTERS_FILES,
         Permission.PRINTERS_AMS_RFID,
+        Permission.PRINTERS_CLEAR_PLATE,
     ],
     "Archives": [
         Permission.ARCHIVES_READ,
@@ -320,6 +322,7 @@ DEFAULT_GROUPS = {
             Permission.PRINTERS_CONTROL.value,
             Permission.PRINTERS_FILES.value,
             Permission.PRINTERS_AMS_RFID.value,
+            Permission.PRINTERS_CLEAR_PLATE.value,
             # Archives - own items only
             Permission.ARCHIVES_READ.value,
             Permission.ARCHIVES_CREATE.value,

+ 327 - 263
backend/app/main.py

@@ -1,9 +1,84 @@
 import asyncio
 import logging
+import time
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from sqlalchemy import delete, or_, select, text
+
+from backend.app.api.routes import (
+    ams_history,
+    api_keys,
+    archives,
+    auth,
+    background_dispatch as background_dispatch_routes,
+    camera,
+    cloud,
+    discovery,
+    external_links,
+    filaments,
+    firmware,
+    github_backup,
+    groups,
+    inventory,
+    kprofiles,
+    library,
+    local_presets,
+    maintenance,
+    metrics,
+    notification_templates,
+    notifications,
+    pending_uploads,
+    print_log,
+    print_queue,
+    printers,
+    projects,
+    settings as settings_routes,
+    smart_plugs,
+    spoolbuddy,
+    spoolman,
+    support,
+    system,
+    updates,
+    users,
+    virtual_printers,
+    webhook,
+    websocket,
+)
+from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
+from backend.app.api.routes.support import init_debug_logging
+from backend.app.core.config import APP_VERSION, settings as app_settings
+from backend.app.core.database import async_session, engine, init_db
+from backend.app.core.websocket import ws_manager
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.archive import ArchiveService
+from backend.app.services.background_dispatch import background_dispatch
+from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.github_backup import github_backup_service
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.mqtt_relay import mqtt_relay
+from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+from backend.app.services.notification_service import notification_service
+from backend.app.services.print_scheduler import scheduler as print_scheduler
+from backend.app.services.printer_manager import (
+    init_printer_connections,
+    printer_manager,
+    printer_state_to_dict,
+)
+from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
+from backend.app.services.spoolman_tracking import (
+    cleanup_tracking as _cleanup_spoolman_tracking,
+    report_usage as _report_spoolman_usage,
+    store_print_data as _store_spoolman_print_data,
+)
+from backend.app.services.tasmota import tasmota_service
+
 
 # =============================================================================
 # Dependency Check - runs before other imports to give helpful error messages
@@ -125,10 +200,8 @@ def check_dependencies():
 check_dependencies()
 # =============================================================================
 
-from fastapi import FastAPI
 
 # Import settings first for logging configuration
-from backend.app.core.config import APP_VERSION, settings as app_settings
 
 # Configure logging based on settings
 # DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
@@ -168,73 +241,7 @@ if not app_settings.debug:
     logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
 logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
-from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
-from sqlalchemy import delete, or_, select
 
-from backend.app.api.routes import (
-    ams_history,
-    api_keys,
-    archives,
-    auth,
-    camera,
-    cloud,
-    discovery,
-    external_links,
-    filaments,
-    firmware,
-    github_backup,
-    groups,
-    inventory,
-    kprofiles,
-    library,
-    local_presets,
-    maintenance,
-    metrics,
-    notification_templates,
-    notifications,
-    pending_uploads,
-    print_log,
-    print_queue,
-    printers,
-    projects,
-    settings as settings_routes,
-    smart_plugs,
-    spoolman,
-    support,
-    system,
-    updates,
-    users,
-    webhook,
-    websocket,
-)
-from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
-from backend.app.api.routes.support import init_debug_logging
-from backend.app.core.database import async_session, init_db
-from backend.app.core.websocket import ws_manager
-from backend.app.models.smart_plug import SmartPlug
-from backend.app.services.archive import ArchiveService
-from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
-from backend.app.services.bambu_mqtt import PrinterState
-from backend.app.services.github_backup import github_backup_service
-from backend.app.services.homeassistant import homeassistant_service
-from backend.app.services.mqtt_relay import mqtt_relay
-from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
-from backend.app.services.notification_service import notification_service
-from backend.app.services.print_scheduler import scheduler as print_scheduler
-from backend.app.services.printer_manager import (
-    init_printer_connections,
-    printer_manager,
-    printer_state_to_dict,
-)
-from backend.app.services.smart_plug_manager import smart_plug_manager
-from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
-from backend.app.services.spoolman_tracking import (
-    cleanup_tracking as _cleanup_spoolman_tracking,
-    report_usage as _report_spoolman_usage,
-    store_print_data as _store_spoolman_print_data,
-)
-from backend.app.services.tasmota import tasmota_service
 
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
@@ -246,9 +253,6 @@ _expected_prints: dict[tuple[int, str], int] = {}
 # Track starting energy for prints: {archive_id: starting_kwh}
 _print_energy_start: dict[int, float] = {}
 
-# Track reprints to add costs on completion: {archive_id}
-_reprint_archives: set[int] = set()
-
 # Track AMS mapping for prints: {archive_id: [global_tray_id_per_slot]}
 # Used by usage tracker to map 3MF slots to physical AMS trays
 _print_ams_mappings: dict[int, list[int]] = {}
@@ -260,6 +264,10 @@ _last_progress_milestone: dict[int, int] = {}
 # Track HMS errors that have been notified: {printer_id: set of error codes}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
+# Track when HMS errors were last seen: {printer_id: timestamp}
+# Used to debounce clearing — prevents flapping errors from re-triggering notifications
+_hms_last_seen: dict[int, float] = {}
+_HMS_CLEAR_GRACE_SECONDS = 30.0
 
 # Track timelapse file baselines at print start: {printer_id: set of video filenames}
 # Used for snapshot-diff detection at print completion
@@ -314,7 +322,8 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
 
 
 _last_status_broadcast: dict[int, str] = {}
-_nozzle_count_updated: set[int] = set()  # Track printers where we've updated nozzle_count
+# Track printers where we've updated nozzle_count
+_nozzle_count_updated: set[int] = set()
 
 
 async def on_printer_status_change(printer_id: int, state: PrinterState):
@@ -420,6 +429,14 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         # Reset milestone tracking when print restarts or new print begins
         _last_progress_milestone[printer_id] = 0
 
+    # HMS error codes that should not trigger notifications.
+    # These are infrastructure/auth issues, not actionable print errors.
+    _HMS_NOTIFICATION_SUPPRESS = {
+        "0500_0007",  # MQTT command verification failed (auth/bind issue, not a print error)
+        "0500_4001",  # Failed to connect to Bambu Cloud (network issue)
+        "0500_400E",  # Printing was cancelled (user action, not an error)
+    }
+
     # Check for new HMS errors and send notifications
     current_hms_errors = getattr(state, "hms_errors", []) or []
     if current_hms_errors:
@@ -432,6 +449,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
         # Update tracking immediately to prevent duplicate notifications from concurrent callbacks
         _notified_hms_errors[printer_id] = current_error_codes
+        _hms_last_seen[printer_id] = time.time()
 
         if new_error_codes:
             # Get the actual new errors for the notification
@@ -471,6 +489,9 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                         error_code_masked = error_code_int & 0xFFFF
                         short_code = f"{(error.attr >> 16) & 0xFFFF:04X}_{error_code_masked:04X}"
 
+                        if short_code in _HMS_NOTIFICATION_SUPPRESS:
+                            continue
+
                         error_type = f"{module_name} Error"
                         # Look up human-readable description
                         description = get_error_description(short_code)
@@ -504,9 +525,15 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                 logging.getLogger(__name__).warning(f"HMS error notification failed: {e}")
 
     else:
-        # No HMS errors - clear tracking so future errors get notified
+        # No HMS errors — only clear tracking after a grace period to prevent
+        # flapping errors (brief hms:[] gaps) from re-triggering notifications.
+        # Some HMS codes (e.g. chamber temp regulation during PETG prints) toggle
+        # on/off every few seconds as conditions fluctuate around thresholds.
         if printer_id in _notified_hms_errors:
-            _notified_hms_errors.pop(printer_id, None)
+            last_seen = _hms_last_seen.get(printer_id, 0)
+            if time.time() - last_seen >= _HMS_CLEAR_GRACE_SECONDS:
+                _notified_hms_errors.pop(printer_id, None)
+                _hms_last_seen.pop(printer_id, None)
 
     await ws_manager.send_printer_status(
         printer_id,
@@ -557,7 +584,21 @@ async def on_ams_change(printer_id: int, ams_data: list):
             result = await db.execute(select(SA).where(SA.printer_id == printer_id).options(selectinload(SA.spool)))
             stale = []
             for assignment in result.scalars().all():
-                current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)
+                # External spool assignments (ams_id=255) live in vt_tray, not AMS data
+                if assignment.ams_id == 255:
+                    ps = printer_manager.get_status(printer_id)
+                    vt_tray_raw = ps.raw_data.get("vt_tray", []) if ps else []
+                    ext_id = assignment.tray_id + 254  # 0→254, 1→255
+                    current_tray = None
+                    for vt in vt_tray_raw:
+                        if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
+                            current_tray = vt
+                            break
+                    if not current_tray:
+                        # vt_tray data may not have arrived yet — keep assignment
+                        continue
+                else:
+                    current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)
                 if not current_tray:
                     logger.info(
                         "Auto-unlink: spool %d AMS%d-T%d — tray not found in AMS data (slot empty?)",
@@ -693,7 +734,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             # The AMS remain% is low-resolution (integer %, i.e. 10g steps for 1kg spool)
                             # and must not overwrite precise values from the usage tracker (3MF/G-code).
                             remain_raw = tray.get("remain")
-                            if remain_raw is not None and existing_assignment.spool:
+                            if (
+                                remain_raw is not None
+                                and existing_assignment.spool
+                                and not existing_assignment.spool.weight_locked
+                            ):
                                 try:
                                     remain_val = int(remain_raw)
                                 except (TypeError, ValueError):
@@ -982,7 +1027,7 @@ def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
         from backend.app.services.archive import extract_printable_objects_from_3mf
 
         file_path = app_settings.base_dir / archive.file_path
-        if file_path.exists() and str(file_path).endswith(".3mf"):
+        if file_path.is_file() and str(file_path).endswith(".3mf"):
             with open(file_path, "rb") as f:
                 threemf_data = f.read()
             # Extract with positions for UI overlay
@@ -1037,10 +1082,10 @@ async def on_print_start(printer_id: int, data: dict):
             from backend.app.api.routes.settings import get_setting
 
             _spoolman_on = await get_setting(db, "spoolman_enabled")
-        if not _spoolman_on or _spoolman_on.lower() != "true":
-            from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
+            if not _spoolman_on or _spoolman_on.lower() != "true":
+                from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
 
-            await usage_on_print_start(printer_id, data, printer_manager)
+                await usage_on_print_start(printer_id, data, printer_manager, db=db)
     except Exception as e:
         logger.warning("Usage tracker on_print_start failed: %s", e)
 
@@ -1218,7 +1263,7 @@ async def on_print_start(printer_id: int, data: dict):
             if archive:
                 # Update archive status to printing
                 archive.status = "printing"
-                archive.started_at = datetime.now()
+                archive.started_at = datetime.now(timezone.utc)
                 await db.commit()
 
                 # Track as active print
@@ -1226,10 +1271,6 @@ async def on_print_start(printer_id: int, data: dict):
                 if subtask_name:
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
 
-                # Mark as reprint so we add cost on completion
-                _reprint_archives.add(archive.id)
-                logger.info("Marked archive %s as reprint for cost addition on completion", archive.id)
-
                 # Set up energy tracking
                 try:
                     plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
@@ -1517,7 +1558,7 @@ async def on_print_start(printer_id: int, data: dict):
                     file_size=0,
                     print_name=print_name,
                     status="printing",
-                    started_at=datetime.now(),
+                    started_at=datetime.now(timezone.utc),
                     extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
                 )
 
@@ -2101,42 +2142,48 @@ async def on_print_complete(printer_id: int, data: dict):
     # auto-start files found in root on power cycle, causing ghost prints.
     # Must run before the archive_id early-return so it executes even when archiving is disabled.
     try:
-        printer_info = printer_manager.get_printer(printer_id)
-        if printer_info and subtask_name:
-            from backend.app.services.bambu_ftp import delete_file_async
-
-            # Try both .3mf and .gcode extensions — the printer may have either
-            for ext in (".3mf", ".gcode"):
-                remote_path = f"/{subtask_name}{ext}"
-                # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
-                for attempt in range(1, 4):
-                    try:
-                        delete_result = await delete_file_async(
-                            printer_info.ip_address,
-                            printer_info.access_code,
-                            remote_path,
-                            printer_model=printer_info.model,
-                        )
-                        if delete_result:
-                            logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
-                        break  # Success or file doesn't exist — no need to retry
-                    except Exception as e:
-                        if attempt < 3:
-                            logger.debug(
-                                "SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
+        if subtask_name:
+            async with async_session() as db:
+                from backend.app.models.printer import Printer
+
+                result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                printer = result.scalar_one_or_none()
+
+            if printer:
+                from backend.app.services.bambu_ftp import delete_file_async
+
+                # Try both .3mf and .gcode extensions — the printer may have either
+                for ext in (".3mf", ".gcode"):
+                    remote_path = f"/{subtask_name}{ext}"
+                    # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
+                    for attempt in range(1, 4):
+                        try:
+                            delete_result = await delete_file_async(
+                                printer.ip_address,
+                                printer.access_code,
+                                remote_path,
+                                printer_model=printer.model,
+                            )
+                            if delete_result:
+                                logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
+                                break
+                        except Exception as e:
+                            delete_result = False
+                            logger.warning(
+                                "SD card cleanup attempt %d/3 raised for %s: %s",
                                 attempt,
                                 remote_path,
                                 e,
                             )
+                        if not delete_result and attempt < 3:
                             await asyncio.sleep(2)
-                        else:
-                            logger.debug(
-                                "SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
+                        elif not delete_result:
+                            logger.warning(
+                                "SD card cleanup failed after 3 attempts for %s (file may linger on SD card)",
                                 remote_path,
-                                e,
                             )
     except Exception as e:
-        logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
+        logger.warning("SD card file cleanup failed for printer %s: %s", printer_id, e)
 
     log_timing("SD card cleanup")
 
@@ -2162,7 +2209,7 @@ async def on_print_complete(printer_id: int, data: dict):
             if queue_item:
                 queue_status = data.get("status", "completed")
                 queue_item.status = queue_status
-                queue_item.completed_at = datetime.now()
+                queue_item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
                 logger.info("Updated queue item %s status to %s", queue_item.id, queue_status)
 
@@ -2189,7 +2236,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     pending_count = count_result.scalar() or 0
 
                     if pending_count == 0:
-                        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+                        today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
                         completed_result = await db.execute(
                             select(sa_func.count(PrintQueueItem.id)).where(
                                 PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
@@ -2232,6 +2279,88 @@ async def on_print_complete(printer_id: int, data: dict):
 
     log_timing("Queue item update")
 
+    # Start bed cooldown monitor (polls bed temp until it drops below threshold)
+    # Must run before archive_id early-return so it fires for all prints (including
+    # prints started from BambuStudio/touchscreen that have no archive).
+    async def _background_bed_cooldown():
+        """Monitor bed temperature after print and notify when cooled."""
+        try:
+            from backend.app.api.routes.settings import get_setting
+
+            # Check threshold setting
+            async with async_session() as db:
+                threshold_str = await get_setting(db, "bed_cooled_threshold")
+            threshold = float(threshold_str) if threshold_str else 35.0
+
+            # Check if any provider has on_bed_cooled enabled (early exit if none)
+            async with async_session() as db:
+                providers = await notification_service._get_providers_for_event(db, "on_bed_cooled", printer_id)
+                if not providers:
+                    logger.debug("[BED-COOL] No providers enabled for bed_cooled on printer %s", printer_id)
+                    return
+
+            logger.info("[BED-COOL] Monitoring bed temp for printer %s (threshold: %.0f°C)", printer_id, threshold)
+
+            max_polls = 120  # 120 * 15s = 30 min timeout
+            for _ in range(max_polls):
+                await asyncio.sleep(15)
+
+                # Check if printer is still connected
+                status = printer_manager.get_status(printer_id)
+                if status is None:
+                    logger.info("[BED-COOL] Printer %s disconnected, stopping monitor", printer_id)
+                    return
+
+                # Check if a new print started (state == RUNNING)
+                if hasattr(status, "state") and status.state == "RUNNING":
+                    logger.info("[BED-COOL] New print started on printer %s, stopping monitor", printer_id)
+                    return
+
+                # Get bed temperature
+                bed_temp = None
+                if hasattr(status, "temperatures") and isinstance(status.temperatures, dict):
+                    bed_temp = status.temperatures.get("bed")
+
+                if bed_temp is None:
+                    continue
+
+                if bed_temp <= threshold:
+                    logger.info(
+                        "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)",
+                        bed_temp,
+                        printer_id,
+                        threshold,
+                    )
+                    printer_info = printer_manager.get_printer(printer_id)
+                    p_name = printer_info.name if printer_info else "Unknown"
+                    async with async_session() as db:
+                        await notification_service.on_bed_cooled(
+                            printer_id=printer_id,
+                            printer_name=p_name,
+                            bed_temp=bed_temp,
+                            threshold=threshold,
+                            filename=filename or subtask_name or "",
+                            db=db,
+                        )
+                    return
+
+            logger.info("[BED-COOL] Timeout waiting for bed to cool on printer %s", printer_id)
+        except asyncio.CancelledError:
+            logger.info("[BED-COOL] Bed cooldown monitor cancelled for printer %s", printer_id)
+        except Exception as e:
+            logger.warning("[BED-COOL] Failed: %s", e)
+        finally:
+            _bed_cooldown_tasks.pop(printer_id, None)
+
+    # Only start bed cooldown for completed prints
+    if data.get("status") == "completed":
+        # Cancel any existing task for this printer
+        existing_task = _bed_cooldown_tasks.pop(printer_id, None)
+        if existing_task and not existing_task.done():
+            existing_task.cancel()
+        task = asyncio.create_task(_background_bed_cooldown())
+        _bed_cooldown_tasks[printer_id] = task
+
     if not archive_id:
         logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
         return
@@ -2276,22 +2405,13 @@ async def on_print_complete(printer_id: int, data: dict):
             await service.update_archive_status(
                 archive_id,
                 status=status,
-                completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None,
+                completed_at=datetime.now(timezone.utc) if status in ("completed", "failed", "aborted") else None,
                 failure_reason=failure_reason,
             )
             logger.info(
                 "[ARCHIVE] Archive %s status updated to %s, failure_reason=%s", archive_id, status, failure_reason
             )
 
-            # Add cost for reprints (first prints have cost set in archive_print())
-            if status == "completed" and archive_id in _reprint_archives:
-                _reprint_archives.discard(archive_id)
-                try:
-                    await service.add_reprint_cost(archive_id)
-                    logger.info("[ARCHIVE] Added reprint cost for archive %s", archive_id)
-                except Exception as e:
-                    logger.warning("[ARCHIVE] Failed to add reprint cost for archive %s: %s", archive_id, e)
-
             await ws_manager.send_archive_updated(
                 {
                     "id": archive_id,
@@ -2378,6 +2498,7 @@ async def on_print_complete(printer_id: int, data: dict):
                         }
                     )
                     log_timing("Usage tracker")
+
     except Exception as e:
         logger.warning("Usage tracker on_print_complete failed: %s", e)
 
@@ -2423,7 +2544,7 @@ async def on_print_complete(printer_id: int, data: dict):
 
                         energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
                         cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
-                        energy_cost = round(energy_used * cost_per_kwh, 2)
+                        energy_cost = round(energy_used * cost_per_kwh, 3)
 
                         from backend.app.models.archive import PrintArchive
 
@@ -2468,7 +2589,11 @@ async def on_print_complete(printer_id: int, data: dict):
                             from datetime import datetime
                             from pathlib import Path
 
-                            archive_dir = app_settings.base_dir / Path(archive.file_path).parent
+                            if archive.file_path:
+                                archive_dir = app_settings.base_dir / Path(archive.file_path).parent
+                            else:
+                                logger.warning("[PHOTO-BG] Archive %s has no file_path, using fallback dir", archive_id)
+                                archive_dir = app_settings.archive_dir / str(archive.id)
                             photo_filename = None
 
                             # Check for external camera first
@@ -2736,86 +2861,6 @@ async def on_print_complete(printer_id: int, data: dict):
 
     asyncio.create_task(_background_layer_timelapse())
 
-    # Start bed cooldown monitor (polls bed temp until it drops below threshold)
-    async def _background_bed_cooldown():
-        """Monitor bed temperature after print and notify when cooled."""
-        try:
-            from backend.app.api.routes.settings import get_setting
-
-            # Check threshold setting
-            async with async_session() as db:
-                threshold_str = await get_setting(db, "bed_cooled_threshold")
-            threshold = float(threshold_str) if threshold_str else 35.0
-
-            # Check if any provider has on_bed_cooled enabled (early exit if none)
-            async with async_session() as db:
-                providers = await notification_service._get_providers_for_event(db, "on_bed_cooled", printer_id)
-                if not providers:
-                    logger.debug("[BED-COOL] No providers enabled for bed_cooled on printer %s", printer_id)
-                    return
-
-            logger.info("[BED-COOL] Monitoring bed temp for printer %s (threshold: %.0f°C)", printer_id, threshold)
-
-            max_polls = 120  # 120 * 15s = 30 min timeout
-            for _ in range(max_polls):
-                await asyncio.sleep(15)
-
-                # Check if printer is still connected
-                status = printer_manager.get_status(printer_id)
-                if status is None:
-                    logger.info("[BED-COOL] Printer %s disconnected, stopping monitor", printer_id)
-                    return
-
-                # Check if a new print started (state == RUNNING)
-                if hasattr(status, "state") and status.state == "RUNNING":
-                    logger.info("[BED-COOL] New print started on printer %s, stopping monitor", printer_id)
-                    return
-
-                # Get bed temperature
-                bed_temp = None
-                if hasattr(status, "temperatures") and status.temperatures:
-                    bed_temp = status.temperatures.get("bed")
-
-                if bed_temp is None:
-                    continue
-
-                if bed_temp <= threshold:
-                    logger.info(
-                        "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)",
-                        bed_temp,
-                        printer_id,
-                        threshold,
-                    )
-                    printer_info = printer_manager.get_printer(printer_id)
-                    p_name = printer_info.name if printer_info else "Unknown"
-                    async with async_session() as db:
-                        await notification_service.on_bed_cooled(
-                            printer_id=printer_id,
-                            printer_name=p_name,
-                            bed_temp=bed_temp,
-                            threshold=threshold,
-                            filename=filename or subtask_name or "",
-                            db=db,
-                        )
-                    return
-
-            logger.info("[BED-COOL] Timeout waiting for bed to cool on printer %s", printer_id)
-        except asyncio.CancelledError:
-            logger.info("[BED-COOL] Bed cooldown monitor cancelled for printer %s", printer_id)
-        except Exception as e:
-            logger.warning("[BED-COOL] Failed: %s", e)
-        finally:
-            _bed_cooldown_tasks.pop(printer_id, None)
-
-    # Only start bed cooldown for completed prints
-    if data.get("status") == "completed":
-        # Cancel any existing task for this printer
-        existing_task = _bed_cooldown_tasks.pop(printer_id, None)
-        if existing_task and not existing_task.done():
-            existing_task.cancel()
-        task = asyncio.create_task(_background_bed_cooldown())
-        _bed_cooldown_tasks[printer_id] = task
-
     log_timing("All background tasks scheduled")
 
     # Auto-scan for timelapse if recording was active during the print
@@ -2835,7 +2880,8 @@ _ams_history_task: asyncio.Task | None = None
 AMS_HISTORY_INTERVAL = 300  # Record every 5 minutes
 AMS_HISTORY_RETENTION_DAYS = 30  # Keep data for 30 days
 _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
-_ams_alarm_cooldown: dict[str, datetime] = {}  # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
+# Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
+_ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
 
@@ -2940,7 +2986,7 @@ async def record_ams_history():
                         if humidity is not None and humidity > humidity_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
-                            now = datetime.now()
+                            now = datetime.now(timezone.utc)
                             if (
                                 last_alarm is None
                                 or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
@@ -2966,7 +3012,7 @@ async def record_ams_history():
                         if temperature is not None and temperature > temp_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
-                            now = datetime.now()
+                            now = datetime.now(timezone.utc)
                             if (
                                 last_alarm is None
                                 or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
@@ -3004,7 +3050,7 @@ async def record_ams_history():
                     setting = result.scalar_one_or_none()
                     retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS
 
-                    cutoff = datetime.now() - timedelta(days=retention_days)
+                    cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
                     result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff))
                     await db.commit()
                     if result.rowcount > 0:
@@ -3060,7 +3106,7 @@ async def track_printer_runtime():
                 result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
                 printers = result.scalars().all()
 
-                now = datetime.now()
+                now = datetime.now(timezone.utc)
                 updated_count = 0
 
                 needs_commit = False
@@ -3134,6 +3180,40 @@ def stop_runtime_tracking():
         logging.getLogger(__name__).info("Printer runtime tracking stopped")
 
 
+# SpoolBuddy device watchdog
+_spoolbuddy_watchdog_task: asyncio.Task | None = None
+SPOOLBUDDY_WATCHDOG_INTERVAL = 15
+
+
+async def _spoolbuddy_watchdog_loop():
+    """Periodic check for SpoolBuddy devices that have gone offline."""
+    from backend.app.api.routes.spoolbuddy import spoolbuddy_watchdog
+
+    while True:
+        try:
+            await spoolbuddy_watchdog()
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logging.getLogger(__name__).warning("SpoolBuddy watchdog failed: %s", e)
+        await asyncio.sleep(SPOOLBUDDY_WATCHDOG_INTERVAL)
+
+
+def start_spoolbuddy_watchdog():
+    global _spoolbuddy_watchdog_task
+    if _spoolbuddy_watchdog_task is None:
+        _spoolbuddy_watchdog_task = asyncio.create_task(_spoolbuddy_watchdog_loop())
+        logging.getLogger(__name__).info("SpoolBuddy watchdog started")
+
+
+def stop_spoolbuddy_watchdog():
+    global _spoolbuddy_watchdog_task
+    if _spoolbuddy_watchdog_task:
+        _spoolbuddy_watchdog_task.cancel()
+        _spoolbuddy_watchdog_task = None
+        logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -3219,6 +3299,9 @@ async def lifespan(app: FastAPI):
     # Start the print scheduler
     asyncio.create_task(print_scheduler.run())
 
+    # Start background dispatch worker for send/start operations
+    await background_dispatch.start()
+
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
 
@@ -3237,76 +3320,49 @@ async def lifespan(app: FastAPI):
     # Start printer runtime tracking
     start_runtime_tracking()
 
-    # Initialize virtual printer manager
+    # Start SpoolBuddy device watchdog
+    start_spoolbuddy_watchdog()
+
+    # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
     virtual_printer_manager.set_session_factory(async_session)
-
-    # Auto-start virtual printer if enabled
-    async with async_session() as db:
-        from backend.app.api.routes.settings import get_setting
-
-        vp_enabled = await get_setting(db, "virtual_printer_enabled")
-        if vp_enabled and vp_enabled.lower() == "true":
-            vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
-            vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
-            vp_model = await get_setting(db, "virtual_printer_model") or ""
-            vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
-            vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
-
-            # Look up printer IP and serial if in proxy mode
-            vp_target_ip = ""
-            vp_target_serial = ""
-            if vp_mode == "proxy" and vp_target_printer_id:
-                from backend.app.models.printer import Printer
-
-                result = await db.execute(select(Printer).where(Printer.id == int(vp_target_printer_id)))
-                printer = result.scalar_one_or_none()
-                if printer:
-                    vp_target_ip = printer.ip_address
-                    vp_target_serial = printer.serial_number
-
-            # Proxy mode requires target IP, other modes require access code
-            can_start = (vp_mode == "proxy" and vp_target_ip) or (vp_mode != "proxy" and vp_access_code)
-
-            if can_start:
-                try:
-                    await virtual_printer_manager.configure(
-                        enabled=True,
-                        access_code=vp_access_code,
-                        mode=vp_mode,
-                        model=vp_model,
-                        target_printer_ip=vp_target_ip,
-                        target_printer_serial=vp_target_serial,
-                        remote_interface_ip=vp_remote_iface,
-                    )
-                    if vp_mode == "proxy":
-                        logging.info("Virtual printer proxy started (target=%s)", vp_target_ip)
-                    else:
-                        logging.info("Virtual printer started (model=%s)", vp_model or "default")
-                except Exception as e:
-                    logging.warning("Failed to start virtual printer: %s", e)
+    try:
+        await virtual_printer_manager.sync_from_db()
+        logging.info("Virtual printer manager synced from database")
+    except Exception as e:
+        logging.warning("Failed to sync virtual printers: %s", e)
 
     yield
 
     # Shutdown
     print_scheduler.stop()
+    await background_dispatch.stop()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     stop_ams_history_recording()
     stop_runtime_tracking()
+    stop_spoolbuddy_watchdog()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
-    # Stop virtual printer if running
-    if virtual_printer_manager.is_enabled:
-        await virtual_printer_manager.configure(enabled=False)
+    # Stop all virtual printer services
+    await virtual_printer_manager.stop_all()
 
     await mqtt_smart_plug_service.disconnect(timeout=2)
 
     await mqtt_relay.disconnect(timeout=2)
 
+    # Checkpoint WAL and close all database connections
+    try:
+        async with engine.begin() as conn:
+            await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
+        logging.info("WAL checkpoint completed")
+    except Exception as e:
+        logging.warning("WAL checkpoint failed: %s", e)
+    await engine.dispose()
+
 
 app = FastAPI(
     title=app_settings.app_name,
@@ -3325,7 +3381,8 @@ PUBLIC_API_ROUTES = {
     "/api/v1/auth/status",
     "/api/v1/auth/login",
     "/api/v1/auth/setup",  # Needed for initial setup and recovery
-    "/api/v1/auth/advanced-auth/status",  # Advanced auth status needed for login page
+    # Advanced auth status needed for login page
+    "/api/v1/auth/advanced-auth/status",
     "/api/v1/auth/forgot-password",  # Password reset for advanced auth
     # Version check for updates (no sensitive data)
     "/api/v1/updates/version",
@@ -3356,6 +3413,10 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
+    # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
+    # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
+    # download token in the URL path instead.
+    "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
 ]
 
 
@@ -3473,6 +3534,7 @@ app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
+app.include_router(background_dispatch_routes.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
@@ -3494,6 +3556,8 @@ app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(metrics.router, prefix=app_settings.api_prefix)
+app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
+app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)
 
 
 # Serve static files (React build)

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

@@ -22,6 +22,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
 
 __all__ = [
@@ -55,4 +56,5 @@ __all__ = [
     "SpoolCatalogEntry",
     "SpoolUsageHistory",
     "ColorCatalogEntry",
+    "SpoolBuddyDevice",
 ]

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

@@ -49,6 +49,11 @@ class PrintQueueItem(Base):
     # Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
     ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Filament overrides for model-based assignment: JSON array of override objects
+    # Format: '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]'
+    # Only slots with overrides are included (sparse). null = use original 3MF values.
+    filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
     plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 

+ 9 - 1
backend/app/models/spool.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -19,13 +19,21 @@ class Spool(Base):
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
+    core_weight_catalog_id: Mapped[int | None] = mapped_column(
+        Integer
+    )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
+    weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp
     nozzle_temp_max: Mapped[int | None] = mapped_column()  # Override max temp
     note: Mapped[str | None] = mapped_column(String(500))
     added_full: Mapped[bool | None] = mapped_column()  # Whether spool was added as full (unused)
+
+    # Cost tracking
+    cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
+
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
     tag_uid: Mapped[str | None] = mapped_column(String(16))  # RFID tag UID (16 hex chars)

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

@@ -15,7 +15,9 @@ class SpoolUsageHistory(Base):
     spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"))
     print_name: Mapped[str | None] = mapped_column(String(500))
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id"), nullable=True)
     weight_used: Mapped[float] = mapped_column(Float, default=0)
     percent_used: Mapped[int] = mapped_column(Integer, default=0)
     status: Mapped[str] = mapped_column(String(20), default="completed")  # completed/failed/aborted
+    cost: Mapped[float | None] = mapped_column(Float)  # Calculated cost for this usage event
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 29 - 0
backend/app/models/spoolbuddy_device.py

@@ -0,0 +1,29 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SpoolBuddyDevice(Base):
+    """SpoolBuddy device registration for RPi-based filament management stations."""
+
+    __tablename__ = "spoolbuddy_devices"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    device_id: Mapped[str] = mapped_column(String(50), unique=True, index=True)
+    hostname: Mapped[str] = mapped_column(String(100))
+    ip_address: Mapped[str] = mapped_column(String(45))
+    firmware_version: Mapped[str | None] = mapped_column(String(20))
+    has_nfc: Mapped[bool] = mapped_column(Boolean, default=True)
+    has_scale: Mapped[bool] = mapped_column(Boolean, default=True)
+    tare_offset: Mapped[int] = mapped_column(Integer, default=0)
+    calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
+    last_seen: Mapped[datetime | None] = mapped_column(DateTime)
+    pending_command: Mapped[str | None] = mapped_column(String(50))
+    nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
+    scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
+    uptime_s: Mapped[int] = mapped_column(Integer, default=0)
+    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())

+ 28 - 0
backend/app/models/virtual_printer.py

@@ -0,0 +1,28 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class VirtualPrinter(Base):
+    """Virtual printer configuration for multi-instance support."""
+
+    __tablename__ = "virtual_printers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    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
+    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(
+        Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
+    )  # proxy mode
+    bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)
+    remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP
+    serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001")  # unique per printer
+    position: Mapped[int] = mapped_column(Integer, default=0)
+    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())

+ 11 - 5
backend/app/schemas/archive.py

@@ -11,13 +11,15 @@ class ArchiveBase(BaseModel):
     cost: float | None = None
     failure_reason: str | None = None
     quantity: int | None = None  # Number of items printed
-    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
+    # User-defined link (Printables, Thingiverse, etc.)
+    external_url: str | None = None
 
 
 class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
     project_id: int | None = None
-    status: str | None = None  # Allow changing status (e.g., clearing failed flag)
+    # Allow changing status (e.g., clearing failed flag)
+    status: str | None = None
 
 
 class ArchiveDuplicate(BaseModel):
@@ -53,7 +55,8 @@ class ArchiveResponse(BaseModel):
     print_name: str | None
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
-    time_accuracy: float | None = None  # Percentage: 100 = perfect, >100 = faster than estimated
+    # Percentage: 100 = perfect, >100 = faster than estimated
+    time_accuracy: float | None = None
     filament_used_grams: float | None
     filament_type: str | None
     filament_color: str | None
@@ -73,7 +76,8 @@ class ArchiveResponse(BaseModel):
 
     makerworld_url: str | None
     designer: str | None
-    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
+    # User-defined link (Printables, Thingiverse, etc.)
+    external_url: str | None = None
 
     is_favorite: bool
     tags: str | None
@@ -116,7 +120,8 @@ class ArchiveStats(BaseModel):
     prints_by_filament_type: dict
     prints_by_printer: dict
     # Time accuracy stats
-    average_time_accuracy: float | None = None  # Average across all prints with data
+    # Average across all prints with data
+    average_time_accuracy: float | None = None
     time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
     # Energy stats
     total_energy_kwh: float = 0.0
@@ -181,6 +186,7 @@ class ReprintRequest(BaseModel):
     # Plate selection for multi-plate 3MF files
     # If not specified, auto-detects from file (legacy behavior for single-plate files)
     plate_id: int | None = None
+    plate_name: str | None = None
 
     # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
     # Global tray ID = (ams_id * 4) + slot_id, external = 254

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

@@ -181,6 +181,7 @@ class FilePrintRequest(BaseModel):
 
     # Print options (same as archive reprint)
     plate_id: int | None = None
+    plate_name: str | None = None
     ams_mapping: list[int] | None = None
     bed_levelling: bool = True
     flow_cali: bool = False

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

@@ -20,6 +20,7 @@ class PrintQueueItemCreate(BaseModel):
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_location: str | None = None  # Target location filter (only used with target_model)
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     library_file_id: int | None = None
@@ -45,6 +46,7 @@ class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_location: str | None = None  # Target location filter (only used with target_model)
+    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -67,6 +69,7 @@ class PrintQueueItemResponse(BaseModel):
     target_model: str | None = None  # Target printer model for model-based assignment
     target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
@@ -98,6 +101,11 @@ class PrintQueueItemResponse(BaseModel):
     printer_name: str | None = None
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
     filament_used_grams: float | None = None  # Estimated print weight from archive or library file
+    filament_type: str | None = None  # e.g. "PLA", "PETG" (from archive/library file)
+    filament_color: str | None = None  # e.g. "#FFFFFF" (from archive/library file)
+    layer_height: float | None = None  # e.g. 0.2 (from archive/library file)
+    nozzle_diameter: float | None = None  # e.g. 0.4 (from archive/library file)
+    sliced_for_model: str | None = None  # e.g. "P1S" (from archive/library file)
 
     # User tracking (Issue #206)
     created_by_id: int | None = None

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

@@ -245,5 +245,7 @@ class PrinterStatus(BaseModel):
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
     # Firmware version (from info.module[name="ota"].sw_ver)
     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

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

@@ -35,6 +35,7 @@ class AppSettings(BaseModel):
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
     check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
+    include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
 
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
@@ -165,6 +166,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_report_partial_usage: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
+    include_beta_updates: bool | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
     ams_humidity_good: int | None = None

+ 11 - 0
backend/app/schemas/spool.py

@@ -11,6 +11,7 @@ class SpoolBase(BaseModel):
     brand: str | None = None
     label_weight: int = 1000
     core_weight: int = 250
+    core_weight_catalog_id: int | None = None
     weight_used: float = 0
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
@@ -21,12 +22,19 @@ class SpoolBase(BaseModel):
     tray_uuid: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
+    cost_per_kg: float | None = Field(default=None, ge=0)
+    weight_locked: bool = False
 
 
 class SpoolCreate(SpoolBase):
     pass
 
 
+class SpoolBulkCreate(BaseModel):
+    spool: SpoolCreate
+    quantity: int = Field(default=1, ge=1, le=100)
+
+
 class SpoolUpdate(BaseModel):
     material: str | None = None
     subtype: str | None = None
@@ -35,6 +43,7 @@ class SpoolUpdate(BaseModel):
     brand: str | None = None
     label_weight: int | None = None
     core_weight: int | None = None
+    core_weight_catalog_id: int | None = None
     weight_used: float | None = None
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
@@ -45,6 +54,8 @@ class SpoolUpdate(BaseModel):
     tray_uuid: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
+    cost_per_kg: float | None = Field(default=None, ge=0)
+    weight_locked: bool | None = None
 
 
 class SpoolKProfileBase(BaseModel):

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

@@ -11,6 +11,7 @@ class SpoolUsageHistoryResponse(BaseModel):
     weight_used: float
     percent_used: int
     status: str
+    cost: float | None = None
     created_at: datetime
 
     class Config:

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

@@ -0,0 +1,102 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+# --- Device schemas ---
+
+
+class DeviceRegisterRequest(BaseModel):
+    device_id: str = Field(..., min_length=1, max_length=50)
+    hostname: str = Field(..., min_length=1, max_length=100)
+    ip_address: str = Field(..., min_length=1, max_length=45)
+    firmware_version: str | None = None
+    has_nfc: bool = True
+    has_scale: bool = True
+    tare_offset: int = 0
+    calibration_factor: float = 1.0
+
+
+class DeviceResponse(BaseModel):
+    id: int
+    device_id: str
+    hostname: str
+    ip_address: str
+    firmware_version: str | None = None
+    has_nfc: bool
+    has_scale: bool
+    tare_offset: int
+    calibration_factor: float
+    last_seen: datetime | None = None
+    pending_command: str | None = None
+    nfc_ok: bool
+    scale_ok: bool
+    uptime_s: int
+    online: bool = False
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class HeartbeatRequest(BaseModel):
+    nfc_ok: bool = False
+    scale_ok: bool = False
+    uptime_s: int = 0
+    firmware_version: str | None = None
+    ip_address: str | None = None
+
+
+class HeartbeatResponse(BaseModel):
+    pending_command: str | None = None
+    tare_offset: int
+    calibration_factor: float
+
+
+# --- NFC schemas ---
+
+
+class TagScannedRequest(BaseModel):
+    device_id: str
+    tag_uid: str
+    tray_uuid: str | None = None
+    sak: int | None = None
+    tag_type: str | None = None
+    raw_blocks: dict | None = None
+
+
+class TagRemovedRequest(BaseModel):
+    device_id: str
+    tag_uid: str
+
+
+# --- Scale schemas ---
+
+
+class ScaleReadingRequest(BaseModel):
+    device_id: str
+    weight_grams: float
+    stable: bool = False
+    raw_adc: int | None = None
+
+
+class UpdateSpoolWeightRequest(BaseModel):
+    spool_id: int
+    weight_grams: float
+
+
+# --- Calibration schemas ---
+
+
+class TareRequest(BaseModel):
+    pass
+
+
+class SetCalibrationFactorRequest(BaseModel):
+    known_weight_grams: float = Field(..., gt=0)
+    raw_adc: int
+
+
+class CalibrationResponse(BaseModel):
+    tare_offset: int
+    calibration_factor: float

+ 10 - 43
backend/app/services/archive.py

@@ -4,7 +4,7 @@ import logging
 import re
 import shutil
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 from defusedxml import ElementTree as ET
@@ -831,6 +831,7 @@ class ArchiveService:
         source_file: Path,
         print_data: dict | None = None,
         created_by_id: int | None = None,
+        original_filename: str | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -839,6 +840,8 @@ class ArchiveService:
             source_file: Path to the 3MF file
             print_data: Print data from MQTT (optional)
             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)
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -849,7 +852,8 @@ class ArchiveService:
 
         # Create archive directory structure
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-        archive_name = f"{timestamp}_{source_file.stem}"
+        display_stem = Path(original_filename).stem if original_filename else source_file.stem
+        archive_name = f"{timestamp}_{display_stem}"
         # Use "unassigned" folder for archives without a printer
         printer_folder = str(printer_id) if printer_id is not None else "unassigned"
         archive_dir = settings.archive_dir / printer_folder / archive_name
@@ -889,8 +893,8 @@ class ArchiveService:
 
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
-        started_at = datetime.now() if status == "printing" else None
-        completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
+        started_at = datetime.now(timezone.utc) if status == "printing" else None
+        completed_at = datetime.now(timezone.utc) if status in ("completed", "failed", "archived") else None
 
         # Calculate cost based on filament usage and type
         cost = None
@@ -923,12 +927,12 @@ class ArchiveService:
         # Create archive record
         archive = PrintArchive(
             printer_id=printer_id,
-            filename=source_file.name,
+            filename=original_filename or source_file.name,
             file_path=str(dest_file.relative_to(settings.base_dir)),
             file_size=dest_file.stat().st_size,
             content_hash=content_hash,
             thumbnail_path=thumbnail_path,
-            print_name=metadata.get("print_name") or source_file.stem,
+            print_name=metadata.get("print_name") or display_stem,
             print_time_seconds=metadata.get("print_time_seconds"),
             filament_used_grams=metadata.get("filament_used_grams"),
             filament_type=metadata.get("filament_type"),
@@ -988,43 +992,6 @@ class ArchiveService:
         await self.db.commit()
         return True
 
-    async def add_reprint_cost(self, archive_id: int) -> bool:
-        """Add cost for a reprint to the existing archive cost."""
-        archive = await self.get_archive(archive_id)
-        if not archive:
-            return False
-
-        if not archive.filament_used_grams or not archive.filament_type:
-            return False
-
-        # Calculate cost based on filament type or default
-        from backend.app.api.routes.settings import get_setting
-
-        primary_type = archive.filament_type.split(",")[0].strip()
-
-        # Look up filament cost_per_kg from database
-        filament_result = await self.db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
-        filament = filament_result.scalar_one_or_none()
-
-        if filament:
-            cost_per_kg = filament.cost_per_kg
-        else:
-            # Use default filament cost from settings
-            default_cost_setting = await get_setting(self.db, "default_filament_cost")
-            cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
-
-        additional_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
-
-        # Add to existing cost (or set if None)
-        if archive.cost is None:
-            archive.cost = additional_cost
-        else:
-            archive.cost = round(archive.cost + additional_cost, 2)
-
-        await self.db.commit()
-        logger.info("Added reprint cost %s to archive %s, new total: %s", additional_cost, archive_id, archive.cost)
-        return True
-
     async def list_archives(
         self,
         printer_id: int | None = None,

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

@@ -0,0 +1,881 @@
+"""Background dispatch for print/reprint jobs.
+
+This service is separate from the app's print queue feature. It exists only to
+decouple "send/start print" operations (FTP upload + start command) from API
+request latency so the UI can continue immediately after dispatch.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+import zipfile
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+from sqlalchemy import select
+
+from backend.app.core.config import settings
+from backend.app.core.database import async_session
+from backend.app.core.websocket import ws_manager
+from backend.app.models.library import LibraryFile
+from backend.app.models.printer import Printer
+from backend.app.services.archive import ArchiveService
+from backend.app.services.bambu_ftp import (
+    delete_file_async,
+    get_ftp_retry_settings,
+    upload_file_async,
+    with_ftp_retry,
+)
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+
+class DispatchJobCancelled(Exception):
+    """Raised when a dispatch job is cancelled by the user."""
+
+
+class DispatchEnqueueRejected(Exception):
+    """Raised when a dispatch job should not be accepted."""
+
+
+@dataclass(slots=True)
+class PrintDispatchJob:
+    id: int
+    kind: Literal["reprint_archive", "print_library_file"]
+    source_id: int
+    source_name: str
+    printer_id: int
+    printer_name: str
+    options: dict[str, Any] = field(default_factory=dict)
+    requested_by_user_id: int | None = None
+    requested_by_username: str | None = None
+
+
+@dataclass(slots=True)
+class ActiveDispatchState:
+    job: PrintDispatchJob
+    message: str
+    upload_bytes: int | None = None
+    upload_total_bytes: int | None = None
+
+
+class BackgroundDispatchService:
+    def __init__(self):
+        self._queued_jobs: deque[PrintDispatchJob] = deque()
+        self._dispatcher_task: asyncio.Task | None = None
+        self._running_tasks: dict[int, asyncio.Task] = {}
+        self._lock = asyncio.Lock()
+        self._job_event = asyncio.Event()
+        self._next_job_id = 1
+        self._active_jobs: dict[int, ActiveDispatchState] = {}
+        self._cancel_requested_job_ids: set[int] = set()
+
+        # Progress for the current "batch" (since queue became non-empty)
+        self._batch_total = 0
+        self._batch_completed = 0
+        self._batch_failed = 0
+
+    @staticmethod
+    def _printer_is_busy_printing(printer_id: int) -> bool:
+        state = printer_manager.get_status(printer_id)
+        if not state:
+            return False
+        return state.state in ("RUNNING", "PAUSE", "PAUSED") and bool(state.gcode_file)
+
+    async def start(self):
+        async with self._lock:
+            if self._dispatcher_task and not self._dispatcher_task.done():
+                return
+            self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name="background-dispatch-dispatcher")
+            logger.info("Background dispatch dispatcher started")
+
+    async def stop(self):
+        dispatcher: asyncio.Task | None = None
+        running_tasks: list[asyncio.Task] = []
+        async with self._lock:
+            dispatcher = self._dispatcher_task
+            self._dispatcher_task = None
+            running_tasks = list(self._running_tasks.values())
+            self._running_tasks.clear()
+            self._active_jobs.clear()
+            self._queued_jobs.clear()
+            self._cancel_requested_job_ids.clear()
+            self._job_event.set()
+
+        if dispatcher:
+            dispatcher.cancel()
+        for task in running_tasks:
+            task.cancel()
+
+        if dispatcher:
+            try:
+                await dispatcher
+            except asyncio.CancelledError:
+                pass
+
+        if running_tasks:
+            await asyncio.gather(*running_tasks, return_exceptions=True)
+
+        logger.info("Background dispatch dispatcher stopped")
+
+    async def dispatch_reprint_archive(
+        self,
+        *,
+        archive_id: int,
+        archive_name: str,
+        printer_id: int,
+        printer_name: str,
+        options: dict[str, Any],
+        requested_by_user_id: int | None,
+        requested_by_username: str | None,
+    ) -> dict[str, Any]:
+        return await self._dispatch(
+            kind="reprint_archive",
+            source_id=archive_id,
+            source_name=archive_name,
+            printer_id=printer_id,
+            printer_name=printer_name,
+            options=options,
+            requested_by_user_id=requested_by_user_id,
+            requested_by_username=requested_by_username,
+        )
+
+    async def get_state(self) -> dict[str, Any]:
+        """Get current dispatch queue state snapshot for newly connected clients."""
+        async with self._lock:
+            return self._build_state_payload_unlocked()
+
+    async def dispatch_print_library_file(
+        self,
+        *,
+        file_id: int,
+        filename: str,
+        printer_id: int,
+        printer_name: str,
+        options: dict[str, Any],
+        requested_by_user_id: int | None,
+        requested_by_username: str | None,
+    ) -> dict[str, Any]:
+        return await self._dispatch(
+            kind="print_library_file",
+            source_id=file_id,
+            source_name=filename,
+            printer_id=printer_id,
+            printer_name=printer_name,
+            options=options,
+            requested_by_user_id=requested_by_user_id,
+            requested_by_username=requested_by_username,
+        )
+
+    async def cancel_job(self, job_id: int) -> dict[str, Any]:
+        """Cancel a queued dispatch job.
+
+        Queued jobs are removed immediately. Active jobs are cancelled
+        cooperatively and will stop at the next cancellation checkpoint.
+        """
+        async with self._lock:
+            # Check active jobs first
+            active_state = self._active_jobs.get(job_id)
+            if active_state is not None:
+                logger.info("Cancel requested for active dispatch job %s", job_id)
+                self._cancel_requested_job_ids.add(job_id)
+                active_job = active_state.job
+                payload = self._build_state_payload_unlocked(
+                    recent_event={
+                        "status": "cancelling",
+                        "job_id": active_job.id,
+                        "source_name": active_job.source_name,
+                        "printer_id": active_job.printer_id,
+                        "printer_name": active_job.printer_name,
+                        "message": "Cancelling current dispatch...",
+                    }
+                )
+                result = {
+                    "cancelled": True,
+                    "pending": True,
+                    "job_id": active_job.id,
+                    "source_name": active_job.source_name,
+                    "printer_id": active_job.printer_id,
+                    "printer_name": active_job.printer_name,
+                }
+                await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+                return result
+
+            # Check queued jobs
+            cancelled_job: PrintDispatchJob | None = None
+            for job in self._queued_jobs:
+                if job.id == job_id:
+                    cancelled_job = job
+                    break
+
+            if not cancelled_job:
+                logger.info("Cancel requested for unknown dispatch job %s", job_id)
+                return {"cancelled": False, "reason": "not_found"}
+
+            self._queued_jobs.remove(cancelled_job)
+            logger.info("Cancelled queued dispatch job %s", cancelled_job.id)
+            self._batch_total = max(0, self._batch_total - 1)
+
+            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                self._batch_completed = 0
+                self._batch_failed = 0
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "cancelled",
+                    "job_id": cancelled_job.id,
+                    "source_name": cancelled_job.source_name,
+                    "printer_id": cancelled_job.printer_id,
+                    "printer_name": cancelled_job.printer_name,
+                    "message": "Cancelled from queue",
+                }
+            )
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+        return {
+            "cancelled": True,
+            "pending": False,
+            "job_id": cancelled_job.id,
+            "source_name": cancelled_job.source_name,
+            "printer_id": cancelled_job.printer_id,
+            "printer_name": cancelled_job.printer_name,
+        }
+
+    async def _dispatch(
+        self,
+        *,
+        kind: Literal["reprint_archive", "print_library_file"],
+        source_id: int,
+        source_name: str,
+        printer_id: int,
+        printer_name: str,
+        options: dict[str, Any],
+        requested_by_user_id: int | None,
+        requested_by_username: str | None,
+    ) -> dict[str, Any]:
+        async with self._lock:
+            has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
+            has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())
+
+            if has_pending_for_printer or has_active_for_printer:
+                raise DispatchEnqueueRejected(f"Printer {printer_name} already has a background dispatch in progress")
+
+            if self._printer_is_busy_printing(printer_id):
+                raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
+
+            dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
+            job = PrintDispatchJob(
+                id=self._next_job_id,
+                kind=kind,
+                source_id=source_id,
+                source_name=source_name,
+                printer_id=printer_id,
+                printer_name=printer_name,
+                options=options,
+                requested_by_user_id=requested_by_user_id,
+                requested_by_username=requested_by_username,
+            )
+            self._next_job_id += 1
+            self._batch_total += 1
+            self._queued_jobs.append(job)
+            self._job_event.set()
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "dispatched",
+                    "job_id": job.id,
+                    "source_name": source_name,
+                    "printer_id": printer_id,
+                    "printer_name": printer_name,
+                    "message": f"Dispatched to {printer_name}",
+                }
+            )
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+        return {
+            "dispatch_job_id": job.id,
+            "dispatch_position": dispatch_position,
+            "status": "dispatched",
+            "printer_id": printer_id,
+            "source_id": source_id,
+            "source_name": source_name,
+        }
+
+    async def _dispatcher_loop(self):
+        while True:
+            await self._job_event.wait()
+            self._job_event.clear()
+
+            while True:
+                payload: dict[str, Any] | None = None
+                job_to_start: PrintDispatchJob | None = None
+                async with self._lock:
+                    busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}
+                    start_index = next(
+                        (
+                            idx
+                            for idx, queued_job in enumerate(self._queued_jobs)
+                            if queued_job.printer_id not in busy_printer_ids
+                        ),
+                        None,
+                    )
+
+                    if start_index is None:
+                        break
+
+                    job_to_start = self._queued_jobs[start_index]
+                    del self._queued_jobs[start_index]
+                    self._active_jobs[job_to_start.id] = ActiveDispatchState(
+                        job=job_to_start,
+                        message="Preparing background dispatch...",
+                    )
+
+                    task = asyncio.create_task(
+                        self._run_active_job(job_to_start), name=f"background-dispatch-job-{job_to_start.id}"
+                    )
+                    self._running_tasks[job_to_start.id] = task
+
+                    payload = self._build_state_payload_unlocked(
+                        recent_event={
+                            "status": "processing",
+                            "job_id": job_to_start.id,
+                            "source_name": job_to_start.source_name,
+                            "printer_id": job_to_start.printer_id,
+                            "printer_name": job_to_start.printer_name,
+                            "message": "Preparing background dispatch...",
+                        }
+                    )
+
+                if payload:
+                    await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    async def _run_active_job(self, job: PrintDispatchJob):
+        try:
+            await self._process_job(job)
+            await self._mark_job_finished(job, failed=False, message="Background dispatch complete")
+        except DispatchJobCancelled:
+            await self._mark_job_cancelled(job)
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            logger.error("Background dispatch job %s failed: %s", job.id, e, exc_info=True)
+            await self._mark_job_finished(job, failed=True, message=str(e))
+        finally:
+            self._job_event.set()
+
+    async def _set_active_message(self, job: PrintDispatchJob, message: str):
+        async with self._lock:
+            active = self._active_jobs.get(job.id)
+            if not active:
+                return
+            active.message = message
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "processing",
+                    "job_id": active.job.id,
+                    "source_name": active.job.source_name,
+                    "printer_id": active.job.printer_id,
+                    "printer_name": active.job.printer_name,
+                    "message": message,
+                }
+            )
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):
+        async with self._lock:
+            active = self._active_jobs.get(job.id)
+            if not active:
+                return
+
+            active.upload_bytes = max(0, int(uploaded))
+            active.upload_total_bytes = max(0, int(total))
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "processing",
+                    "job_id": active.job.id,
+                    "source_name": active.job.source_name,
+                    "printer_id": active.job.printer_id,
+                    "printer_name": active.job.printer_name,
+                    "message": active.message,
+                }
+            )
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
+        async with self._lock:
+            if failed:
+                self._batch_failed += 1
+            else:
+                self._batch_completed += 1
+
+            self._active_jobs.pop(job.id, None)
+            self._running_tasks.pop(job.id, None)
+            self._cancel_requested_job_ids.discard(job.id)
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "failed" if failed else "completed",
+                    "job_id": job.id,
+                    "source_name": job.source_name,
+                    "printer_id": job.printer_id,
+                    "printer_name": job.printer_name,
+                    "message": message,
+                }
+            )
+            should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+        if should_reset_batch:
+            async with self._lock:
+                if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                    self._batch_total = 0
+                    self._batch_completed = 0
+                    self._batch_failed = 0
+
+    async def _mark_job_cancelled(self, job: PrintDispatchJob):
+        async with self._lock:
+            self._active_jobs.pop(job.id, None)
+            self._running_tasks.pop(job.id, None)
+            self._cancel_requested_job_ids.discard(job.id)
+            self._batch_total = max(0, self._batch_total - 1)
+
+            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                self._batch_completed = 0
+                self._batch_failed = 0
+
+            payload = self._build_state_payload_unlocked(
+                recent_event={
+                    "status": "cancelled",
+                    "job_id": job.id,
+                    "source_name": job.source_name,
+                    "printer_id": job.printer_id,
+                    "printer_name": job.printer_name,
+                    "message": "Cancelled during dispatch",
+                }
+            )
+
+        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+
+    def _is_cancel_requested(self, job_id: int) -> bool:
+        return job_id in self._cancel_requested_job_ids
+
+    def _raise_if_cancel_requested(self, job: PrintDispatchJob):
+        if self._is_cancel_requested(job.id):
+            raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
+
+    def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
+        processing = len(self._active_jobs)
+        dispatched = len(self._queued_jobs)
+
+        dispatched_jobs = [
+            {
+                "job_id": job.id,
+                "kind": job.kind,
+                "source_id": job.source_id,
+                "source_name": job.source_name,
+                "printer_id": job.printer_id,
+                "printer_name": job.printer_name,
+            }
+            for job in list(self._queued_jobs)
+        ]
+
+        active_jobs: list[dict[str, Any]] = []
+        for active in self._active_jobs.values():
+            upload_progress_pct = None
+            if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:
+                upload_progress_pct = round(
+                    max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1
+                )
+
+            active_jobs.append(
+                {
+                    "job_id": active.job.id,
+                    "kind": active.job.kind,
+                    "source_id": active.job.source_id,
+                    "source_name": active.job.source_name,
+                    "printer_id": active.job.printer_id,
+                    "printer_name": active.job.printer_name,
+                    "message": active.message,
+                    "upload_bytes": active.upload_bytes,
+                    "upload_total_bytes": active.upload_total_bytes,
+                    "upload_progress_pct": upload_progress_pct,
+                }
+            )
+
+        active_jobs.sort(key=lambda item: int(item["job_id"]))
+        active_job = active_jobs[0] if active_jobs else None
+
+        return {
+            "total": self._batch_total,
+            "dispatched": dispatched,
+            "processing": processing,
+            "completed": self._batch_completed,
+            "failed": self._batch_failed,
+            "dispatched_jobs": dispatched_jobs,
+            "active_jobs": active_jobs,
+            "active_job": active_job,
+            "recent_event": recent_event,
+        }
+
+    async def _process_job(self, job: PrintDispatchJob):
+        if job.kind == "reprint_archive":
+            await self._run_reprint_archive(job)
+            return
+        if job.kind == "print_library_file":
+            await self._run_print_library_file(job)
+            return
+        raise RuntimeError(f"Unknown dispatch job kind: {job.kind}")
+
+    async def _run_reprint_archive(self, job: PrintDispatchJob):
+        from backend.app.main import register_expected_print
+
+        async with async_session() as db:
+            service = ArchiveService(db)
+            archive = await service.get_archive(job.source_id)
+            if not archive:
+                raise RuntimeError("Archive not found")
+
+            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
+            if not printer:
+                raise RuntimeError("Printer not found")
+
+            printer_name = printer.name
+            printer_ip = printer.ip_address
+            printer_access_code = printer.access_code
+            printer_model = printer.model
+            archive_filename = archive.filename
+
+            if not printer_manager.is_connected(job.printer_id):
+                raise RuntimeError("Printer is not connected")
+
+            file_path = settings.base_dir / archive.file_path
+            if not file_path.exists():
+                raise RuntimeError("Archive file not found")
+
+            base_name = archive.filename
+            if base_name.endswith(".gcode.3mf"):
+                base_name = base_name[:-10]
+            elif base_name.endswith(".3mf"):
+                base_name = base_name[:-4]
+            remote_filename = f"{base_name}.3mf"
+            remote_path = f"/{remote_filename}"
+
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+            self._raise_if_cancel_requested(job)
+
+            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
+            await delete_file_async(
+                printer_ip,
+                printer_access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer_model,
+            )
+
+            self._raise_if_cancel_requested(job)
+
+            try:
+                await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
+                loop = asyncio.get_running_loop()
+                progress_state = {"last_emit": 0.0, "last_bytes": 0}
+
+                def upload_progress_callback(uploaded: int, total: int):
+                    if self._is_cancel_requested(job.id):
+                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
+
+                    now = time.monotonic()
+                    should_emit = (
+                        uploaded >= total
+                        or now - progress_state["last_emit"] >= 0.2
+                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
+                    )
+
+                    if should_emit:
+                        progress_state["last_emit"] = now
+                        progress_state["last_bytes"] = uploaded
+                        loop.call_soon_threadsafe(
+                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
+                        )
+
+                if ftp_retry_enabled:
+                    uploaded = await with_ftp_retry(
+                        upload_file_async,
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                        max_retries=ftp_retry_count,
+                        retry_delay=ftp_retry_delay,
+                        operation_name=f"Upload for reprint to {printer_name}",
+                        non_retry_exceptions=(DispatchJobCancelled,),
+                    )
+                else:
+                    uploaded = await upload_file_async(
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                    )
+
+                if uploaded:
+                    await self._set_active_upload_progress(job, 1, 1)
+
+                if not uploaded:
+                    raise RuntimeError(
+                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
+                    )
+
+                register_expected_print(
+                    job.printer_id,
+                    remote_filename,
+                    job.source_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                )
+
+                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
+
+                self._raise_if_cancel_requested(job)
+
+                await self._set_active_message(job, f"Starting print on {printer_name}...")
+                started = printer_manager.start_print(
+                    job.printer_id,
+                    remote_filename,
+                    plate_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                    timelapse=job.options.get("timelapse", False),
+                    bed_levelling=job.options.get("bed_levelling", True),
+                    flow_cali=job.options.get("flow_cali", False),
+                    vibration_cali=job.options.get("vibration_cali", False),
+                    layer_inspect=job.options.get("layer_inspect", False),
+                    use_ams=job.options.get("use_ams", True),
+                )
+
+                if not started:
+                    await self._cleanup_sd_card_file(
+                        printer_ip,
+                        printer_access_code,
+                        remote_path,
+                        printer_model,
+                    )
+                    raise RuntimeError("Failed to start print")
+
+                if job.requested_by_user_id and job.requested_by_username:
+                    printer_manager.set_current_print_user(
+                        job.printer_id,
+                        job.requested_by_user_id,
+                        job.requested_by_username,
+                    )
+            except DispatchJobCancelled:
+                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
+                raise
+
+    async def _run_print_library_file(self, job: PrintDispatchJob):
+        from backend.app.main import register_expected_print
+
+        async with async_session() as db:
+            lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))
+            if not lib_file:
+                raise RuntimeError("File not found")
+
+            if not self._is_sliced_file(lib_file.filename):
+                raise RuntimeError("Not a sliced file. Only .gcode or .gcode.3mf files can be printed.")
+
+            file_path = Path(settings.base_dir) / lib_file.file_path
+            if not file_path.exists():
+                raise RuntimeError("File not found on disk")
+
+            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
+            if not printer:
+                raise RuntimeError("Printer not found")
+
+            printer_name = printer.name
+            printer_ip = printer.ip_address
+            printer_access_code = printer.access_code
+            printer_model = printer.model
+            library_filename = lib_file.filename
+
+            if not printer_manager.is_connected(job.printer_id):
+                raise RuntimeError("Printer is not connected")
+
+            await self._set_active_message(job, f"Creating archive for {lib_file.filename}...")
+            archive_service = ArchiveService(db)
+            archive = await archive_service.archive_print(
+                printer_id=job.printer_id,
+                source_file=file_path,
+            )
+            if not archive:
+                raise RuntimeError("Failed to create archive")
+
+            await db.flush()
+
+            base_name = lib_file.filename
+            if base_name.endswith(".gcode.3mf"):
+                base_name = base_name[:-10]
+            elif base_name.endswith(".3mf"):
+                base_name = base_name[:-4]
+            remote_filename = f"{base_name}.3mf"
+            remote_path = f"/{remote_filename}"
+
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+            self._raise_if_cancel_requested(job)
+
+            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
+            await delete_file_async(
+                printer_ip,
+                printer_access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer_model,
+            )
+
+            self._raise_if_cancel_requested(job)
+
+            try:
+                await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
+                loop = asyncio.get_running_loop()
+                progress_state = {"last_emit": 0.0, "last_bytes": 0}
+
+                def upload_progress_callback(uploaded: int, total: int):
+                    if self._is_cancel_requested(job.id):
+                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
+
+                    now = time.monotonic()
+                    should_emit = (
+                        uploaded >= total
+                        or now - progress_state["last_emit"] >= 0.2
+                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
+                    )
+
+                    if should_emit:
+                        progress_state["last_emit"] = now
+                        progress_state["last_bytes"] = uploaded
+                        loop.call_soon_threadsafe(
+                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
+                        )
+
+                if ftp_retry_enabled:
+                    uploaded = await with_ftp_retry(
+                        upload_file_async,
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                        max_retries=ftp_retry_count,
+                        retry_delay=ftp_retry_delay,
+                        operation_name=f"Upload for print to {printer_name}",
+                        non_retry_exceptions=(DispatchJobCancelled,),
+                    )
+                else:
+                    uploaded = await upload_file_async(
+                        printer_ip,
+                        printer_access_code,
+                        file_path,
+                        remote_path,
+                        progress_callback=upload_progress_callback,
+                        socket_timeout=ftp_timeout,
+                        printer_model=printer_model,
+                    )
+
+                if uploaded:
+                    await self._set_active_upload_progress(job, 1, 1)
+
+                if not uploaded:
+                    await db.rollback()
+                    raise RuntimeError(
+                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
+                    )
+
+                register_expected_print(
+                    job.printer_id,
+                    remote_filename,
+                    archive.id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                )
+
+                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
+
+                self._raise_if_cancel_requested(job)
+
+                await self._set_active_message(job, f"Starting print on {printer_name}...")
+                started = printer_manager.start_print(
+                    job.printer_id,
+                    remote_filename,
+                    plate_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                    timelapse=job.options.get("timelapse", False),
+                    bed_levelling=job.options.get("bed_levelling", True),
+                    flow_cali=job.options.get("flow_cali", False),
+                    vibration_cali=job.options.get("vibration_cali", False),
+                    layer_inspect=job.options.get("layer_inspect", False),
+                    use_ams=job.options.get("use_ams", True),
+                )
+
+                if not started:
+                    await self._cleanup_sd_card_file(
+                        printer_ip,
+                        printer_access_code,
+                        remote_path,
+                        printer_model,
+                    )
+                    await db.rollback()
+                    raise RuntimeError("Failed to start print")
+
+                await db.commit()
+            except DispatchJobCancelled:
+                await db.rollback()
+                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
+                raise
+
+    @staticmethod
+    async def _cleanup_sd_card_file(
+        printer_ip: str,
+        access_code: str,
+        remote_path: str,
+        printer_model: str | None,
+    ):
+        """Best-effort delete of uploaded file from printer SD card."""
+        try:
+            await delete_file_async(printer_ip, access_code, remote_path, printer_model=printer_model)
+        except Exception:
+            pass  # Best-effort — don't fail the error handler
+
+    @staticmethod
+    def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:
+        if requested_plate_id is not None:
+            return requested_plate_id
+
+        plate_id = 1
+        try:
+            with zipfile.ZipFile(file_path, "r") as zf:
+                for name in zf.namelist():
+                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
+                        plate_str = name[15:-6]
+                        plate_id = int(plate_str)
+                        break
+        except (ValueError, zipfile.BadZipFile, OSError):
+            pass
+        return plate_id
+
+    @staticmethod
+    def _is_sliced_file(filename: str) -> bool:
+        lower = filename.lower()
+        return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
+
+
+background_dispatch = BackgroundDispatchService()

+ 6 - 6
backend/app/services/bambu_cloud.py

@@ -5,7 +5,7 @@ Handles authentication and profile management with Bambu Lab's cloud services.
 """
 
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 import httpx
 
@@ -42,7 +42,7 @@ class BambuCloudService:
         """Check if we have a valid token."""
         if not self.access_token:
             return False
-        return not (self.token_expiry and datetime.now() > self.token_expiry)
+        return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
 
     def _get_headers(self) -> dict:
         """Get headers for authenticated requests."""
@@ -200,9 +200,9 @@ class BambuCloudService:
             if response.status_code == 200 and access_token:
                 self.access_token = access_token
                 self.refresh_token = data.get("refreshToken")
-                from datetime import datetime, timedelta
+                from datetime import datetime, timedelta, timezone
 
-                self.token_expiry = datetime.now() + timedelta(days=30)
+                self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
                 return {"success": True, "message": "Login successful"}
 
             # Provide helpful error message
@@ -224,12 +224,12 @@ class BambuCloudService:
         self.access_token = data.get("accessToken")
         self.refresh_token = data.get("refreshToken")
         # Token typically valid for ~3 months, but we'll refresh more often
-        self.token_expiry = datetime.now() + timedelta(days=30)
+        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
 
     def set_token(self, access_token: str):
         """Set access token directly (for stored tokens)."""
         self.access_token = access_token
-        self.token_expiry = datetime.now() + timedelta(days=30)
+        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
 
     def logout(self):
         """Clear authentication state."""

+ 56 - 9
backend/app/services/bambu_ftp.py

@@ -76,13 +76,16 @@ class BambuFTPClient:
     """FTP client for retrieving files from Bambu Lab printers."""
 
     FTP_PORT = 990
-    DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
+    # Default timeout in seconds (increased for A1 printers)
+    DEFAULT_TIMEOUT = 30
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # These models have varying FTP SSL behavior depending on firmware version
     A1_MODELS = ("A1", "A1 Mini")
     # Chunk size for manual upload transfer (1MB)
     # Larger chunks reduce overhead and work better with A1 printers
     CHUNK_SIZE = 1024 * 1024
+    # Per-chunk data socket timeout during upload.
+    UPLOAD_CHUNK_TIMEOUT = 120
 
     # Cache for working FTP modes per printer IP
     # Maps IP -> "prot_p" or "prot_c"
@@ -359,6 +362,7 @@ class BambuFTPClient:
             logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
 
             uploaded = 0
+            callback_exception: Exception | None = None
 
             # Use manual transfer instead of storbinary() for A1 compatibility
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
@@ -368,7 +372,7 @@ class BambuFTPClient:
 
                 # Set explicit socket options for reliable transfer
                 conn.setblocking(True)
-                conn.settimeout(120)  # 2 minute timeout per chunk
+                conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
 
                 try:
                     while True:
@@ -382,14 +386,51 @@ class BambuFTPClient:
                         logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
 
                         if progress_callback:
-                            progress_callback(uploaded, file_size)
+                            try:
+                                progress_callback(uploaded, file_size)
+                            except Exception as e:
+                                callback_exception = e
+                                logger.info(
+                                    "FTP upload callback requested stop for %s at %s/%s bytes: %s",
+                                    remote_path,
+                                    uploaded,
+                                    file_size,
+                                    e,
+                                )
+                                break
 
                 except OSError as e:
                     logger.error("FTP connection lost during upload: %s", e)
-                    conn.close()
                     raise
+                finally:
+                    try:
+                        conn.close()
+                    except OSError:
+                        pass
+
+            # Skip voidresp() for A1 models — they hang after transfercmd uploads
+            if self.printer_model not in self.A1_MODELS:
+                try:
+                    self._ftp.voidresp()
+                except (OSError, ftplib.Error) as e:
+                    # Data transfer already completed — voidresp() failure is just a noisy
+                    # 226 acknowledgment issue, not an actual upload failure. Log and continue.
+                    logger.warning("FTP upload response for %s was not clean (data already sent): %s", remote_path, e)
+
+            if callback_exception is not None:
+                cleanup_ok = False
+                try:
+                    cleanup_ok = self.delete_file(remote_path)
+                except Exception as cleanup_error:
+                    logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
+
+                if cleanup_ok:
+                    logger.info("FTP cancel cleanup succeeded for %s", remote_path)
+                    raise callback_exception
 
-                conn.close()
+                raise RuntimeError(
+                    f"Upload cancelled but failed to remove partial file {remote_path} from printer"
+                ) from callback_exception
 
             logger.info("FTP upload complete: %s", remote_path)
             return True
@@ -421,7 +462,7 @@ class BambuFTPClient:
             # Use manual transfer instead of storbinary() for A1 compatibility
             conn = self._ftp.transfercmd(f"STOR {remote_path}")
             conn.setblocking(True)
-            conn.settimeout(120)
+            conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
 
             try:
                 # Send data in chunks
@@ -432,10 +473,12 @@ class BambuFTPClient:
                     offset += len(chunk)
             except OSError as e:
                 logger.error("FTP connection lost during upload_bytes: %s", e)
-                conn.close()
                 raise
-
-            conn.close()
+            finally:
+                try:
+                    conn.close()
+                except OSError:
+                    pass
             return True
         except (OSError, ftplib.Error):
             return False
@@ -827,6 +870,7 @@ async def with_ftp_retry(
     max_retries: int = 3,
     retry_delay: float = 2.0,
     operation_name: str = "FTP operation",
+    non_retry_exceptions: tuple[type[BaseException], ...] = (),
     **kwargs,
 ) -> T | None:
     """Execute FTP operation with retry logic.
@@ -837,6 +881,7 @@ async def with_ftp_retry(
         max_retries: Number of retry attempts (default: 3)
         retry_delay: Seconds to wait between retries (default: 2.0)
         operation_name: Name for logging purposes
+        non_retry_exceptions: Exception types that should immediately abort retries
         **kwargs: Keyword arguments for the operation
 
     Returns:
@@ -856,6 +901,8 @@ async def with_ftp_retry(
             if attempt > 0:
                 logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
         except Exception as e:
+            if non_retry_exceptions and isinstance(e, non_retry_exceptions):
+                raise
             last_error = e
             logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
 

+ 170 - 20
backend/app/services/bambu_mqtt.py

@@ -16,7 +16,7 @@ import time
 from collections import deque
 from collections.abc import Callable
 from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
 
 import paho.mqtt.client as mqtt
 
@@ -163,8 +163,14 @@ class PrinterState:
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Tray change history during current print: [(global_tray_id, layer_num), ...]
+    # Used by usage tracker to split filament weight on mid-print tray switch
+    tray_change_log: list = field(default_factory=list)
     # Firmware version info (from info.module[name="ota"].sw_ver)
     firmware_version: str | None = None
+    # Developer LAN mode: parsed from MQTT "fun" field bit 0x20000000
+    # True = dev mode ON (no encryption), False = dev mode OFF (encryption required), None = unknown
+    developer_mode: bool | None = None
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -236,6 +242,8 @@ STAGE_NAMES = {
     64: "Preparing Hotend",
     65: "Calibrating nozzle clumping detection",
     66: "Purifying the chamber air",
+    74: "Preparing",  # Seen on H2D during print preparation
+    77: "Preparing AMS",
 }
 
 
@@ -279,6 +287,9 @@ class BambuMQTTClient:
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
+        self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
+        self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
+        self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
@@ -449,7 +460,7 @@ class BambuMQTTClient:
             if self._logging_enabled:
                 self._message_log.append(
                     MQTTLogEntry(
-                        timestamp=datetime.now().isoformat(),
+                        timestamp=datetime.now(timezone.utc).isoformat(),
                         topic=msg.topic,
                         direction="in",
                         payload=payload,
@@ -530,6 +541,16 @@ class BambuMQTTClient:
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
 
+            # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
+            # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
+            if not self._is_dual_nozzle and "device" in print_data:
+                dev = print_data.get("device")
+                if isinstance(dev, dict):
+                    ext_info = dev.get("extruder", {}).get("info", [])
+                    if isinstance(ext_info, list) and len(ext_info) >= 2:
+                        self._is_dual_nozzle = True
+                        logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
+
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
                 try:
@@ -859,6 +880,34 @@ class BambuMQTTClient:
         if "filament_tangle_detect" in xcam_data:
             self.state.print_options.filament_tangle_detect = bool(xcam_data.get("filament_tangle_detect"))
 
+    @staticmethod
+    def _resolve_local_slot_from_mapping(local_slot: int, mapping_raw: list | None) -> int | None:
+        """Resolve a local AMS slot ID to a global tray ID using the MQTT mapping field.
+
+        The MQTT mapping field is an array of snow-encoded values:
+        each entry = ams_hw_id * 256 + slot_id (65535 = unmapped).
+
+        Finds entries where the local slot matches, then computes the global tray ID.
+        Returns the global ID if exactly one AMS matches, or None if ambiguous/unavailable.
+        """
+        if not isinstance(mapping_raw, list) or not mapping_raw:
+            return None
+
+        candidates: set[int] = set()
+        for value in mapping_raw:
+            if not isinstance(value, int) or value >= 65535:
+                continue
+            ams_hw_id = value >> 8
+            slot = value & 0xFF
+            if 0 <= ams_hw_id <= 3 and (slot & 0x03) == local_slot:
+                candidates.add(ams_hw_id * 4 + local_slot)
+            elif 128 <= ams_hw_id <= 135 and local_slot == 0:
+                candidates.add(ams_hw_id)
+
+        if len(candidates) == 1:
+            return candidates.pop()
+        return None
+
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
 
@@ -912,7 +961,10 @@ class BambuMQTTClient:
 
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
                 # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
-                if parsed_tray_now >= 0 and parsed_tray_now <= 3:
+                # Single-nozzle printers with multiple AMS (e.g. P2S) also report local slot IDs (#420)
+                # — disambiguated below using MQTT mapping field
+                ams_map = self.state.ams_extruder_map
+                if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     if pending_target is not None:
@@ -945,7 +997,8 @@ class BambuMQTTClient:
                         if snow_tray is not None and snow_tray != 255:
                             # snow_tray is already normalized to global ID
                             # Verify the slot matches what we see in tray_now
-                            snow_slot = snow_tray % 4 if snow_tray < 128 else -1
+                            # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0
+                            snow_slot = snow_tray % 4 if snow_tray < 128 else (0 if snow_tray <= 135 else -1)
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
                                     logger.debug(
@@ -962,7 +1015,6 @@ class BambuMQTTClient:
                                 self.state.tray_now = snow_tray
                         else:
                             # Fallback: snow not available, use ams_extruder_map (less reliable)
-                            ams_map = self.state.ams_extruder_map
                             # Find ALL AMS units on the active extruder
                             ams_on_extruder = []
                             for ams_id_str, ext_id in ams_map.items():
@@ -975,29 +1027,56 @@ class BambuMQTTClient:
                             if len(ams_on_extruder) == 1:
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
-                                global_tray_id = active_ams_id * 4 + parsed_tray_now
+                                if 128 <= active_ams_id <= 135:
+                                    # AMS-HT: single slot per unit, global ID = unit ID
+                                    global_tray_id = active_ams_id
+                                else:
+                                    global_tray_id = active_ams_id * 4 + parsed_tray_now
                                 logger.debug(
                                     f"[{self.serial_number}] H2D tray_now fallback: "
                                     f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}"
                                 )
                                 self.state.tray_now = global_tray_id
                             elif len(ams_on_extruder) > 1:
-                                # Multiple AMS on this extruder - keep current if valid, else use slot as-is
+                                # Multiple AMS on this extruder - keep current if valid, else try to narrow down
                                 current_tray = self.state.tray_now
-                                current_ams = current_tray // 4 if current_tray < 128 else -1
-                                if current_ams in ams_on_extruder and (current_tray % 4) == parsed_tray_now:
+                                # Determine which AMS unit and slot the current tray belongs to
+                                if 0 <= current_tray <= 15:
+                                    current_ams = current_tray // 4
+                                    current_slot = current_tray % 4
+                                elif 128 <= current_tray <= 135:
+                                    current_ams = current_tray  # AMS-HT: ID = tray ID
+                                    current_slot = 0
+                                else:
+                                    current_ams = -1
+                                    current_slot = -1
+                                if current_ams in ams_on_extruder and current_slot == parsed_tray_now:
                                     # Current is valid and matches slot - keep it
                                     logger.debug(
                                         f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
                                         f"keeping current {current_tray} (matches slot {parsed_tray_now})"
                                     )
                                 else:
-                                    # Can't disambiguate - use slot as-is (will be wrong for non-first AMS)
-                                    logger.warning(
-                                        f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, "
-                                        f"no snow field, using slot {parsed_tray_now} (may be incorrect)"
-                                    )
-                                    self.state.tray_now = parsed_tray_now
+                                    # Filter candidates: AMS-HT (128-135) only valid for slot 0
+                                    if parsed_tray_now > 0:
+                                        candidates = [a for a in ams_on_extruder if a <= 3]
+                                    else:
+                                        candidates = ams_on_extruder
+                                    if len(candidates) == 1:
+                                        cand = candidates[0]
+                                        resolved = cand if 128 <= cand <= 135 else cand * 4 + parsed_tray_now
+                                        logger.debug(
+                                            f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
+                                            f"narrowed to AMS {cand} -> global ID {resolved}"
+                                        )
+                                        self.state.tray_now = resolved
+                                    else:
+                                        # Genuinely ambiguous - use slot as-is (will be wrong for non-first AMS)
+                                        logger.warning(
+                                            f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, "
+                                            f"no snow field, using slot {parsed_tray_now} (may be incorrect)"
+                                        )
+                                        self.state.tray_now = parsed_tray_now
                             else:
                                 # No AMS on this extruder - use slot as-is
                                 logger.warning(
@@ -1005,6 +1084,37 @@ class BambuMQTTClient:
                                     f"using slot {parsed_tray_now}"
                                 )
                                 self.state.tray_now = parsed_tray_now
+                elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
+                    # Single-nozzle printer with tray_now in 0-3 range.
+                    # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
+                    # in tray_now, not global tray IDs (#420). Use the MQTT mapping field
+                    # (snow-encoded) to resolve the correct AMS unit.
+                    ams_exist_raw = ams_data.get("ams_exist_bits", "0")
+                    try:
+                        ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
+                    except (ValueError, TypeError):
+                        ams_exist = 0
+                    num_ams = bin(ams_exist).count("1")
+
+                    if num_ams > 1:
+                        # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
+                        # Cross-reference with MQTT mapping field to find the correct AMS unit.
+                        mapping_raw = self.state.raw_data.get("mapping")
+                        resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
+                        if resolved is not None:
+                            if resolved != parsed_tray_now:
+                                logger.debug(
+                                    f"[{self.serial_number}] Multi-AMS tray_now: "
+                                    f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
+                                )
+                            self.state.tray_now = resolved
+                        else:
+                            # No mapping available (not printing, or ambiguous) — use as-is.
+                            # This matches the old behavior and is correct for AMS 0.
+                            self.state.tray_now = parsed_tray_now
+                    else:
+                        # Single AMS — local slot 0-3 equals global ID
+                        self.state.tray_now = parsed_tray_now
                 else:
                     # tray_now > 3 means it's already a global ID, or 255 means unloaded
                     # Note: Do NOT clear pending_tray_target on tray_now=255 here.
@@ -1014,7 +1124,18 @@ class BambuMQTTClient:
                     self.state.tray_now = parsed_tray_now
 
                 # Track last valid tray for usage tracking (survives retract → 255 at print end)
-                if 0 <= self.state.tray_now <= 253:
+                # Valid physical trays: 0-15 (regular AMS), 128-135 (AMS-HT), 254 (external spool)
+                tn = self.state.tray_now
+                if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
+                    # Log tray change for mid-print usage splitting
+                    if tn != self.state.last_loaded_tray and self.state.state in ("RUNNING", "PAUSE"):
+                        self.state.tray_change_log.append((tn, self.state.layer_num))
+                        logger.info(
+                            "[%s] Tray change during print: tray=%d at layer=%d",
+                            self.serial_number,
+                            tn,
+                            self.state.layer_num,
+                        )
                     self.state.last_loaded_tray = self.state.tray_now
 
                 logger.debug("[%s] tray_now updated: %s", self.serial_number, self.state.tray_now)
@@ -1211,6 +1332,9 @@ class BambuMQTTClient:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
+            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
+            if self.state.progress > 0:
+                self._last_valid_progress = self.state.progress
             self.state.progress = float(data["mc_percent"])
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
@@ -1225,6 +1349,9 @@ class BambuMQTTClient:
         if "layer_num" in data:
             new_layer = int(data["layer_num"])
             old_layer = self.state.layer_num
+            # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
+            if old_layer > 0:
+                self._last_valid_layer_num = old_layer
             self.state.layer_num = new_layer
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
@@ -1929,6 +2056,15 @@ class BambuMQTTClient:
         ams_extruder_map_data = self.state.raw_data.get("ams_extruder_map")
         mapping_data = self.state.raw_data.get("mapping")
         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
         if ams_data is not None:
             self.state.raw_data["ams"] = ams_data
         if vt_tray_data is not None:
@@ -1984,6 +2120,14 @@ class BambuMQTTClient:
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False
+            # Reset last valid progress/layer for usage tracking
+            self._last_valid_progress = 0.0
+            self._last_valid_layer_num = 0
+            # Clear and seed tray change log for mid-print usage splitting
+            self.state.tray_change_log.clear()
+            tn = self.state.tray_now
+            if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
+                self.state.tray_change_log.append((tn, 0))
             # Initialize timelapse tracking based on current state
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # so self.state.timelapse may already be set from this message.
@@ -2067,6 +2211,9 @@ class BambuMQTTClient:
                     "timelapse_was_active": timelapse_was_active,
                     "hms_errors": hms_errors_data,
                     "ams_mapping": self._captured_ams_mapping,
+                    # Last valid progress/layer before firmware reset (for partial usage tracking)
+                    "last_progress": self._last_valid_progress,
+                    "last_layer_num": self._last_valid_layer_num,
                 }
             )
             self._captured_ams_mapping = None
@@ -2531,7 +2678,7 @@ class BambuMQTTClient:
             if self._logging_enabled:
                 self._message_log.append(
                     MQTTLogEntry(
-                        timestamp=datetime.now().isoformat(),
+                        timestamp=datetime.now(timezone.utc).isoformat(),
                         topic=self.topic_publish,
                         direction="out",
                         payload=command,
@@ -2639,13 +2786,16 @@ class BambuMQTTClient:
 
         # Signal that we received the response (only if we were waiting for one)
         # Use thread-safe method since MQTT callbacks run in a different thread
-        if self._pending_kprofile_response:
+        # Capture in local var to avoid TOCTOU race: asyncio thread can clear
+        # self._pending_kprofile_response between the check and the .set() call
+        event = self._pending_kprofile_response
+        if event:
             logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
             if self._loop and self._loop.is_running():
-                self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
+                self._loop.call_soon_threadsafe(event.set)
             else:
                 # Fallback for when loop is not available
-                self._pending_kprofile_response.set()
+                event.set()
 
     async def get_kprofiles(
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3

+ 6 - 6
backend/app/services/discovery.py

@@ -17,7 +17,7 @@ import re
 import socket
 import struct
 from dataclasses import dataclass
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 logger = logging.getLogger(__name__)
@@ -304,7 +304,7 @@ class PrinterDiscoveryService:
             name=name,
             ip_address=ip_address,
             model=model,
-            discovered_at=datetime.now().isoformat(),
+            discovered_at=datetime.now(timezone.utc).isoformat(),
         )
 
         self._discovered[serial] = printer
@@ -414,7 +414,7 @@ class SubnetScanner:
             name=name or f"Printer at {ip}",
             ip_address=ip,
             model=model,
-            discovered_at=datetime.now().isoformat(),
+            discovered_at=datetime.now(timezone.utc).isoformat(),
         )
         self._discovered[ip] = printer
 
@@ -609,7 +609,7 @@ class TasmotaScanner:
                             "name": f"Tasmota ({ip})",
                             "module": None,
                             "state": "UNKNOWN",
-                            "discovered_at": datetime.now().isoformat(),
+                            "discovered_at": datetime.now(timezone.utc).isoformat(),
                         }
                         self._discovered[ip] = device
                         return
@@ -627,7 +627,7 @@ class TasmotaScanner:
                             "name": f"Tasmota ({ip})",
                             "module": None,
                             "state": "UNKNOWN",
-                            "discovered_at": datetime.now().isoformat(),
+                            "discovered_at": datetime.now(timezone.utc).isoformat(),
                         }
                         self._discovered[ip] = device
                         return
@@ -668,7 +668,7 @@ class TasmotaScanner:
                     "name": device_name,
                     "module": module,
                     "state": power_state,
-                    "discovered_at": datetime.now().isoformat(),
+                    "discovered_at": datetime.now(timezone.utc).isoformat(),
                 }
 
                 self._discovered[ip] = device

+ 3 - 3
backend/app/services/failure_analysis.py

@@ -1,5 +1,5 @@
 from collections import defaultdict
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,7 +30,7 @@ class FailureAnalysisService:
         Returns:
             Dictionary with failure analysis results
         """
-        cutoff_date = datetime.utcnow() - timedelta(days=days)
+        cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
 
         # Build base query
         base_filter = [PrintArchive.created_at >= cutoff_date]
@@ -142,7 +142,7 @@ class FailureAnalysisService:
         # Failure rate trend (by week)
         trend_data = []
         for i in range(min(days // 7, 12)):  # Up to 12 weeks
-            week_end = datetime.utcnow() - timedelta(weeks=i)
+            week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_start = week_end - timedelta(weeks=1)
 
             week_filter = base_filter.copy()

+ 20 - 20
backend/app/services/mqtt_relay.py

@@ -10,7 +10,7 @@ import logging
 import ssl
 import threading
 import time
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import Any
 
 import paho.mqtt.client as mqtt
@@ -211,7 +211,7 @@ class MQTTRelayService:
         """Publish BamBuddy status (online/offline)."""
         self._publish(
             f"{self.topic_prefix}/status",
-            {"status": status, "timestamp": datetime.utcnow().isoformat()},
+            {"status": status, "timestamp": datetime.now(timezone.utc).isoformat()},
             retain=True,
         )
 
@@ -257,7 +257,7 @@ class MQTTRelayService:
             "printer_id": printer_id,
             "printer_name": printer_name,
             "printer_serial": printer_serial,
-            "timestamp": datetime.utcnow().isoformat(),
+            "timestamp": datetime.now(timezone.utc).isoformat(),
             "connected": state.connected,
             "state": state.state,
             "progress": state.progress,
@@ -294,7 +294,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -309,7 +309,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -333,7 +333,7 @@ class MQTTRelayService:
                 "printer_serial": printer_serial,
                 "filename": filename,
                 "subtask_name": subtask_name,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -365,7 +365,7 @@ class MQTTRelayService:
                 "filename": filename,
                 "subtask_name": subtask_name,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -387,7 +387,7 @@ class MQTTRelayService:
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "ams_units": ams_data,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -409,7 +409,7 @@ class MQTTRelayService:
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "errors": errors,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -435,7 +435,7 @@ class MQTTRelayService:
                 "filename": filename,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -459,7 +459,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -489,7 +489,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -517,7 +517,7 @@ class MQTTRelayService:
                 "maintenance_type": maintenance_type,
                 "current_value": current_value,
                 "threshold": threshold,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -537,7 +537,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "maintenance_type": maintenance_type,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -557,7 +557,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "maintenance_type": maintenance_type,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -583,7 +583,7 @@ class MQTTRelayService:
                 "print_name": print_name,
                 "printer_name": printer_name,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -603,7 +603,7 @@ class MQTTRelayService:
                 "archive_id": archive_id,
                 "print_name": print_name,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -629,7 +629,7 @@ class MQTTRelayService:
                 "spool_name": spool_name,
                 "remaining_weight": remaining_weight,
                 "remaining_percent": remaining_percent,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -659,7 +659,7 @@ class MQTTRelayService:
                 "state": state,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 
@@ -683,7 +683,7 @@ class MQTTRelayService:
                 "power_watts": power,
                 "energy_today_kwh": energy_today,
                 "energy_total_kwh": energy_total,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
         )
 

+ 3 - 3
backend/app/services/mqtt_smart_plug.py

@@ -8,7 +8,7 @@ import json
 import logging
 import threading
 from dataclasses import dataclass, field
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from typing import Any
 
 import paho.mqtt.client as mqtt
@@ -262,7 +262,7 @@ class MQTTSmartPlugService:
                     self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
 
                 data = self.plug_data[plug_id]
-                data.last_seen = datetime.utcnow()
+                data.last_seen = datetime.now(timezone.utc)
 
                 # Process based on data type
                 if data_type == "power":
@@ -473,7 +473,7 @@ class MQTTSmartPlugService:
             return False
 
         timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
-        return datetime.utcnow() - data.last_seen < timeout
+        return datetime.now(timezone.utc) - data.last_seen < timeout
 
     async def disconnect(self, timeout: float = 0):
         """Disconnect from MQTT broker."""

+ 97 - 2
backend/app/services/network_utils.py

@@ -1,15 +1,26 @@
 """Network utility functions for interface detection."""
 
 import ipaddress
+import json
 import logging
+import shutil
 import socket
 import struct
+import subprocess
 
 logger = logging.getLogger(__name__)
 
 # Interfaces to exclude from selection
 EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
 
+# Resolve full path to `ip` command (may not be in PATH for service users)
+_IP_CMD: str | None = shutil.which("ip") or shutil.which("ip", path="/usr/sbin:/sbin:/usr/bin:/bin")
+
+
+def _is_excluded(name: str) -> bool:
+    """Check if an interface name should be excluded."""
+    return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
+
 
 def get_network_interfaces() -> list[dict]:
     """Get all network interfaces with their IPs and subnets.
@@ -26,7 +37,7 @@ def get_network_interfaces() -> list[dict]:
             name = iface[1]
 
             # Skip excluded interfaces
-            if any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES):
+            if _is_excluded(name):
                 continue
 
             try:
@@ -76,6 +87,88 @@ def get_network_interfaces() -> list[dict]:
     return interfaces
 
 
+def get_all_interface_ips() -> list[dict]:
+    """Get all IPs (primary + aliases) for all non-excluded interfaces.
+
+    Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
+    Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
+
+    Returns:
+        List of dicts with name, ip, netmask, subnet, is_alias, label
+    """
+    if not _IP_CMD:
+        logger.debug("ip command not found, using ioctl fallback")
+        return _fallback_get_all_ips()
+
+    try:
+        result = subprocess.run(
+            [_IP_CMD, "-j", "addr", "show"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        if result.returncode != 0:
+            logger.warning("ip addr show failed: %s", result.stderr)
+            return _fallback_get_all_ips()
+
+        interfaces_data = json.loads(result.stdout)
+    except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
+        logger.warning("Failed to run ip -j addr show: %s", e)
+        return _fallback_get_all_ips()
+
+    entries = []
+    for iface in interfaces_data:
+        ifname = iface.get("ifname", "")
+        if _is_excluded(ifname):
+            continue
+
+        ipv4_count = 0
+        for addr_info in iface.get("addr_info", []):
+            if addr_info.get("family") != "inet":
+                continue
+
+            ip = addr_info.get("local", "")
+            prefix = addr_info.get("prefixlen", 24)
+            label = addr_info.get("label", ifname)
+
+            try:
+                network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
+                netmask = str(network.netmask)
+            except ValueError:
+                continue
+
+            # An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
+            is_alias = ":" in label or ipv4_count > 0
+
+            entries.append(
+                {
+                    "name": ifname,
+                    "ip": ip,
+                    "netmask": netmask,
+                    "subnet": str(network),
+                    "is_alias": is_alias,
+                    "label": label,
+                }
+            )
+            ipv4_count += 1
+
+    # Sort: primary IPs first per interface, then by interface name
+    entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
+    return entries
+
+
+def _fallback_get_all_ips() -> list[dict]:
+    """Fallback: wrap get_network_interfaces() result with alias fields."""
+    return [
+        {
+            **iface,
+            "is_alias": False,
+            "label": iface["name"],
+        }
+        for iface in get_network_interfaces()
+    ]
+
+
 def find_interface_for_ip(target_ip: str) -> dict | None:
     """Find which interface is on the same subnet as the target IP.
 
@@ -91,9 +184,11 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
         logger.error("Invalid target IP: %s", target_ip)
         return None
 
-    interfaces = get_network_interfaces()
+    interfaces = get_all_interface_ips()
 
     for iface in interfaces:
+        if iface.get("is_alias"):
+            continue
         try:
             network = ipaddress.IPv4Network(iface["subnet"], strict=False)
             if target in network:

+ 13 - 5
backend/app/services/notification_service.py

@@ -5,7 +5,7 @@ import json
 import logging
 import re
 import smtplib
-from datetime import datetime
+from datetime import datetime, timezone
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -208,10 +208,18 @@ class NotificationService:
         client = await self._get_client()
 
         if image_data:
-            # ntfy supports image attachments via multipart form-data
+            # ntfy supports image attachments via multipart form-data.
+            # HTTP headers cannot contain newlines, but ntfy interprets
+            # literal \n (backslash-n) as newlines in the Message header.
             headers["Filename"] = "photo.jpg"
-            headers["Message"] = message
+            headers["Message"] = message.replace("\n", "\\n")
             response = await client.put(url, content=image_data, headers=headers)
+
+            if response.status_code == 400 and "attachments not allowed" in response.text:
+                # Server has attachments disabled — retry without the image
+                headers.pop("Filename", None)
+                headers.pop("Message", None)
+                response = await client.post(url, content=message, headers=headers)
         else:
             response = await client.post(url, content=message, headers=headers)
 
@@ -493,10 +501,10 @@ class NotificationService:
         provider = result.scalar_one_or_none()
         if provider:
             if success:
-                provider.last_success = datetime.utcnow()
+                provider.last_success = datetime.now(timezone.utc)
             else:
                 provider.last_error = error
-                provider.last_error_at = datetime.utcnow()
+                provider.last_error_at = datetime.now(timezone.utc)
             await db.commit()
 
     async def _get_providers_for_event(

+ 141 - 25
backend/app/services/print_scheduler.py

@@ -4,7 +4,7 @@ import asyncio
 import json
 import logging
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 import defusedxml.ElementTree as ET
@@ -80,7 +80,7 @@ class PrintScheduler:
 
             for item in items:
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
-                if item.scheduled_time and item.scheduled_time > datetime.utcnow():
+                if item.scheduled_time and item.scheduled_time > datetime.now(timezone.utc):
                     continue
 
                 # Skip items that require manual start
@@ -124,7 +124,7 @@ class PrintScheduler:
                         if not await self._check_previous_success(db, item):
                             item.status = "skipped"
                             item.error_message = "Previous print failed or was aborted"
-                            item.completed_at = datetime.now()
+                            item.completed_at = datetime.now(timezone.utc)
                             await db.commit()
                             logger.info("Skipped queue item %s - previous print failed", item.id)
 
@@ -140,6 +140,16 @@ class PrintScheduler:
                             )
                             continue
 
+                    # Compute AMS mapping if not already set
+                    if not item.ams_mapping:
+                        computed_mapping = await self._compute_ams_mapping_for_printer(db, item.printer_id, item)
+                        if computed_mapping:
+                            item.ams_mapping = json.dumps(computed_mapping)
+                            logger.info(
+                                f"Queue item {item.id}: Computed AMS mapping for printer {item.printer_id}: {computed_mapping}"
+                            )
+                            await db.commit()
+
                     # Start the print
                     await self._start_print(db, item)
                     busy_printers.add(item.printer_id)
@@ -154,8 +164,29 @@ class PrintScheduler:
                         except json.JSONDecodeError:
                             pass  # Ignore malformed filament types; treat as no constraint
 
+                    # Parse filament overrides if present
+                    filament_overrides = None
+                    if item.filament_overrides:
+                        try:
+                            filament_overrides = json.loads(item.filament_overrides)
+                        except json.JSONDecodeError:
+                            pass
+
+                    # If overrides exist, use override types for validation instead
+                    effective_types = required_types
+                    if filament_overrides:
+                        override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
+                        if override_types:
+                            # Merge: keep original types for non-overridden slots, add override types
+                            effective_types = sorted(set(required_types or []) | set(override_types))
+
                     printer_id, waiting_reason = await self._find_idle_printer_for_model(
-                        db, item.target_model, busy_printers, required_types, item.target_location
+                        db,
+                        item.target_model,
+                        busy_printers,
+                        effective_types,
+                        item.target_location,
+                        filament_overrides=filament_overrides,
                     )
 
                     # Update waiting_reason if changed and send notification when first waiting
@@ -180,7 +211,7 @@ class PrintScheduler:
                             if not await self._check_previous_success(db, item):
                                 item.status = "skipped"
                                 item.error_message = "Previous print failed or was aborted"
-                                item.completed_at = datetime.now()
+                                item.completed_at = datetime.now(timezone.utc)
                                 await db.commit()
                                 logger.info("Skipped queue item %s - previous print failed", item.id)
 
@@ -233,6 +264,7 @@ class PrintScheduler:
         exclude_ids: set[int],
         required_filament_types: list[str] | None = None,
         target_location: str | None = None,
+        filament_overrides: list[dict] | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -272,6 +304,7 @@ class PrintScheduler:
         printers_busy = []
         printers_offline = []
         printers_missing_filament = []
+        candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)
 
         for printer in printers:
             if printer.id in exclude_ids:
@@ -297,8 +330,24 @@ class PrintScheduler:
                     logger.debug("Skipping printer %s (%s) - missing filaments: %s", printer.id, printer.name, missing)
                     continue
 
-            # Found a matching printer - clear waiting reason
-            return printer.id, None
+            # If filament overrides with colors, only consider printers that have at least one color match
+            if filament_overrides:
+                color_matches = self._count_override_color_matches(printer.id, filament_overrides)
+                if color_matches > 0:
+                    candidates.append((printer.id, color_matches))
+                else:
+                    override_colors = [f"{o.get('type', '?')} ({o.get('color', '?')})" for o in filament_overrides]
+                    printers_missing_filament.append((printer.name, override_colors))
+                    logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
+                    continue
+            else:
+                # No overrides - take first available (existing behavior)
+                return printer.id, None
+
+        # If we have candidates from override matching, pick the one with most color matches
+        if candidates:
+            candidates.sort(key=lambda c: c[1], reverse=True)
+            return candidates[0][0], None
 
         # Build waiting reason from what we found
         reasons = []
@@ -353,13 +402,45 @@ class PrintScheduler:
 
         return missing
 
+    def _count_override_color_matches(self, printer_id: int, overrides: list[dict]) -> int:
+        """Count how many filament overrides have an exact color match on the printer.
+
+        Used to prefer printers that already have the desired override colors loaded.
+        """
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            return 0
+
+        # Collect loaded filaments' type+color pairs
+        loaded: set[tuple[str, str]] = set()
+        for ams_unit in status.raw_data.get("ams", []):
+            for tray in ams_unit.get("tray", []):
+                tray_type = tray.get("tray_type")
+                tray_color = tray.get("tray_color", "")
+                if tray_type:
+                    color_norm = tray_color.replace("#", "").lower()[:6]
+                    loaded.add((tray_type.upper(), color_norm))
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
+            if vt_type:
+                color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                loaded.add((vt_type.upper(), color_norm))
+
+        matches = 0
+        for o in overrides:
+            o_type = (o.get("type") or "").upper()
+            o_color = (o.get("color") or "").replace("#", "").lower()[:6]
+            if (o_type, o_color) in loaded:
+                matches += 1
+        return matches
+
     async def _compute_ams_mapping_for_printer(
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
     ) -> list[int] | None:
         """Compute AMS mapping for a printer based on filament requirements.
 
-        This is called for model-based queue items after a printer is assigned,
-        to compute the correct AMS slot mapping for that specific printer's hardware.
+        Called when a queue item has no ams_mapping set — either for model-based
+        items after printer assignment, or printer-specific items (e.g. from VP).
 
         Args:
             db: Database session
@@ -381,6 +462,29 @@ class PrintScheduler:
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
 
+        # Apply filament overrides if present
+        if item.filament_overrides:
+            try:
+                overrides = json.loads(item.filament_overrides)
+                override_map = {o["slot_id"]: o for o in overrides}
+                for req in filament_reqs:
+                    if req["slot_id"] in override_map:
+                        override = override_map[req["slot_id"]]
+                        req["type"] = override["type"]
+                        req["color"] = override["color"]
+                        # Clear tray_info_idx so matching uses type+color instead of
+                        # the original 3MF's tray_info_idx (which would match the old filament)
+                        req["tray_info_idx"] = ""
+                        logger.debug(
+                            "Queue item %s: Override slot %d -> %s %s",
+                            item.id,
+                            req["slot_id"],
+                            override["type"],
+                            override["color"],
+                        )
+            except (json.JSONDecodeError, KeyError, TypeError) as e:
+                logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
+
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(status)
         if not loaded_filaments:
@@ -512,14 +616,14 @@ class PrintScheduler:
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
-            ams_id = ams_unit.get("id", 0)
+            ams_id = int(ams_unit.get("id", 0))
             trays = ams_unit.get("tray", [])
             is_ht = len(trays) == 1  # AMS-HT has single tray
 
             for tray in trays:
                 tray_type = tray.get("tray_type")
                 if tray_type:
-                    tray_id = tray.get("id", 0)
+                    tray_id = int(tray.get("id", 0))
                     tray_color = tray.get("tray_color", "")
                     # tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
                     tray_info_idx = tray.get("tray_info_idx", "")
@@ -558,7 +662,7 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_external": True,
                         "global_tray_id": tray_id,
-                        "extruder_id": (tray_id - 254) if ams_extruder_map else None,
+                        "extruder_id": (255 - tray_id) if ams_extruder_map else None,
                     }
                 )
 
@@ -634,12 +738,12 @@ class PrintScheduler:
             # Get available trays (not already used)
             available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
 
-            # Nozzle-aware filtering: restrict to trays on the correct nozzle
+            # Nozzle-aware filtering: restrict to trays on the correct nozzle.
+            # Hard filter — cross-nozzle assignment causes print failures
+            # ("position of left hotend is abnormal"), so never fall back.
             req_nozzle_id = req.get("nozzle_id")
             if req_nozzle_id is not None:
-                nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id]
-                if nozzle_filtered:
-                    available = nozzle_filtered
+                available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
@@ -852,7 +956,7 @@ class PrintScheduler:
         if not printer:
             item.status = "failed"
             item.error_message = "Printer not found"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             logger.error("Queue item %s: Printer %s not found", item.id, item.printer_id)
             await self._power_off_if_needed(db, item)
@@ -862,7 +966,7 @@ class PrintScheduler:
         if not printer_manager.is_connected(item.printer_id):
             item.status = "failed"
             item.error_message = "Printer not connected"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             logger.error("Queue item %s: Printer %s not connected", item.id, item.printer_id)
             await self._power_off_if_needed(db, item)
@@ -881,7 +985,7 @@ class PrintScheduler:
             if not archive:
                 item.status = "failed"
                 item.error_message = "Archive not found"
-                item.completed_at = datetime.utcnow()
+                item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
                 logger.error("Queue item %s: Archive %s not found", item.id, item.archive_id)
                 await self._power_off_if_needed(db, item)
@@ -897,7 +1001,7 @@ class PrintScheduler:
             if not library_file:
                 item.status = "failed"
                 item.error_message = "Library file not found"
-                item.completed_at = datetime.utcnow()
+                item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
                 logger.error("Queue item %s: Library file %s not found", item.id, item.library_file_id)
                 await self._power_off_if_needed(db, item)
@@ -915,6 +1019,7 @@ class PrintScheduler:
                 archive = await archive_service.archive_print(
                     printer_id=item.printer_id,
                     source_file=file_path,
+                    original_filename=filename,
                 )
                 if archive:
                     item.archive_id = archive.id
@@ -932,7 +1037,7 @@ class PrintScheduler:
             # Neither archive nor library file specified
             item.status = "failed"
             item.error_message = "No source file specified"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             logger.error("Queue item %s: No archive_id or library_file_id specified", item.id)
             await self._power_off_if_needed(db, item)
@@ -942,7 +1047,7 @@ class PrintScheduler:
         if not file_path.exists():
             item.status = "failed"
             item.error_message = "Source file not found on disk"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             logger.error("Queue item %s: File not found: %s", item.id, file_path)
             await self._power_off_if_needed(db, item)
@@ -1017,7 +1122,7 @@ class PrintScheduler:
             )
             item.status = "failed"
             item.error_message = error_msg
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             logger.error(
                 f"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, "
@@ -1057,7 +1162,7 @@ class PrintScheduler:
         # in "printing" status without actually printing - but that's safer than
         # accidentally reprinting the same file hours later.
         item.status = "printing"
-        item.started_at = datetime.utcnow()
+        item.started_at = datetime.now(timezone.utc)
         await db.commit()
 
         # Consume the plate-cleared flag now that we're starting a print
@@ -1111,10 +1216,21 @@ class PrintScheduler:
             except Exception:
                 pass  # Don't fail if MQTT fails
         else:
+            # Clean up uploaded file from SD card to prevent phantom prints
+            try:
+                await delete_file_async(
+                    printer.ip_address,
+                    printer.access_code,
+                    remote_path,
+                    printer_model=printer.model,
+                )
+            except Exception:
+                pass  # Best-effort — don't fail the error handler
+
             # Print command failed - revert status
             item.status = "failed"
             item.error_message = "Failed to send print command to printer"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             logger.error(
                 f"Queue item {item.id}: Failed to start print on {printer.name} ({printer.model}) - "

+ 2 - 2
backend/app/services/printer_manager.py

@@ -695,8 +695,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         ],
     }
     # Add cover URL if there's an active print and printer_id is provided
-    # Include PAUSE/PAUSED states so skip objects modal can show cover
-    if printer_id and state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file:
+    # Include PAUSE state so skip objects modal can show cover
+    if printer_id and state.state in ("RUNNING", "PAUSE") and state.gcode_file:
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
     else:
         result["cover_url"] = None

+ 7 - 7
backend/app/services/smart_plug_manager.py

@@ -2,7 +2,7 @@
 
 import asyncio
 import logging
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import TYPE_CHECKING
 
 from sqlalchemy import select
@@ -112,7 +112,7 @@ class SmartPlugManager:
                         success = await service.turn_on(plug)
                         if success:
                             plug.last_state = "ON"
-                            plug.last_checked = datetime.utcnow()
+                            plug.last_checked = datetime.now(timezone.utc)
                             self._last_schedule_check[plug.id] = f"on:{current_time}"
 
                 # Check if we should turn off
@@ -123,7 +123,7 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         if success:
                             plug.last_state = "OFF"
-                            plug.last_checked = datetime.utcnow()
+                            plug.last_checked = datetime.now(timezone.utc)
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
                             # Mark printer offline if linked
                             if plug.printer_id:
@@ -164,7 +164,7 @@ class SmartPlugManager:
         if success:
             # Update last state and reset auto_off_executed
             plug.last_state = "ON"
-            plug.last_checked = datetime.utcnow()
+            plug.last_checked = datetime.now(timezone.utc)
             plug.auto_off_executed = False  # Reset flag when turning on
             await db.commit()
 
@@ -391,7 +391,7 @@ class SmartPlugManager:
                 plug = result.scalar_one_or_none()
                 if plug:
                     plug.auto_off_pending = pending
-                    plug.auto_off_pending_since = datetime.utcnow() if pending else None
+                    plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
                     await db.commit()
                     logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
         except Exception as e:
@@ -412,7 +412,7 @@ class SmartPlugManager:
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
-                    plug.last_checked = datetime.utcnow()
+                    plug.last_checked = datetime.now(timezone.utc)
                     await db.commit()
                     logger.info("Auto-off executed and disabled for plug %s", plug_id)
         except Exception as e:
@@ -455,7 +455,7 @@ class SmartPlugManager:
                 for plug in pending_plugs:
                     # Check how long it's been pending (timeout after 2 hours)
                     if plug.auto_off_pending_since:
-                        elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
+                        elapsed = (datetime.now(timezone.utc) - plug.auto_off_pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "

+ 4 - 12
backend/app/services/tasmota.py

@@ -18,19 +18,10 @@ class TasmotaService:
     def __init__(self, timeout: float = 5.0):
         self.timeout = timeout
 
-    def _build_url(
-        self,
-        ip: str,
-        command: str,
-        username: str | None = None,
-        password: str | None = None,
-    ) -> str:
+    def _build_url(self, ip: str, command: str) -> str:
         """Build Tasmota command URL."""
         # URL encode the command
         cmd = command.replace(" ", "%20")
-
-        if username and password:
-            return f"http://{username}:{password}@{ip}/cm?cmnd={cmd}"
         return f"http://{ip}/cm?cmnd={cmd}"
 
     @staticmethod
@@ -53,11 +44,12 @@ class TasmotaService:
         if not self._validate_ip(ip):
             logger.warning("Blocked Tasmota request to invalid IP: %s", ip)
             return None
-        url = self._build_url(ip, command, username, password)
+        url = self._build_url(ip, command)
+        auth = (username, password) if username and password else None
 
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
-                response = await client.get(url)
+                response = await client.get(url, auth=auth)
                 response.raise_for_status()
                 return response.json()
         except httpx.TimeoutException:

+ 438 - 39
backend/app/services/usage_tracker.py

@@ -22,6 +22,131 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 logger = logging.getLogger(__name__)
 
 
+def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
+    """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
+
+    The printer's MQTT mapping field is an array indexed by slicer filament slot
+    (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
+    65535 means unmapped.
+
+    Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
+    no valid mappings found.
+    """
+    if not isinstance(mapping_raw, list) or not mapping_raw:
+        return None
+
+    result = []
+    for value in mapping_raw:
+        if not isinstance(value, int) or value >= 65535:
+            result.append(-1)
+            continue
+
+        ams_hw_id = value >> 8
+        slot = value & 0xFF
+
+        if 0 <= ams_hw_id <= 3:
+            # Regular AMS: sequential global ID
+            result.append(ams_hw_id * 4 + (slot & 0x03))
+        elif 128 <= ams_hw_id <= 135:
+            # AMS-HT: global ID is the hardware ID (one slot per unit)
+            result.append(ams_hw_id)
+        elif ams_hw_id in (254, 255):
+            # External spool
+            result.append(254 if slot != 255 else 255)
+        else:
+            result.append(-1)
+
+    # Only return if at least one valid mapping exists
+    if all(v < 0 for v in result):
+        return None
+
+    return result
+
+
+def _match_slots_by_color(
+    filament_usage: list[dict],
+    ams_raw: dict | list | None,
+) -> list[int] | None:
+    """Match 3MF filament slots to AMS trays by color.
+
+    Fallback mapping for printers that don't provide the MQTT mapping field
+    or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
+
+    Compares the 3MF slicer filament color (per slot) against each AMS tray's
+    color to find a unique match. Only returns a mapping if every used slot
+    matches exactly one tray (no ambiguity).
+
+    Args:
+        filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
+        ams_raw: raw_data["ams"] dict or list from printer state
+
+    Returns:
+        List of global tray IDs indexed by slicer slot (0-based), or None.
+    """
+    if not filament_usage or not ams_raw:
+        return None
+
+    ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    if not ams_data:
+        return None
+
+    # Build map of normalized color → list of global tray IDs
+    color_to_trays: dict[str, list[int]] = {}
+    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))
+            tray_color = tray.get("tray_color", "")
+            tray_type = tray.get("tray_type", "")
+            if not tray_color or not tray_type:
+                continue
+            # Normalize AMS color: strip alpha (last 2 chars), lowercase
+            norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
+            if ams_id >= 128:
+                global_id = ams_id  # AMS-HT
+            else:
+                global_id = ams_id * 4 + tray_id
+            color_to_trays.setdefault(norm, []).append(global_id)
+
+    if not color_to_trays:
+        return None
+
+    # Find max slot_id to size the result array
+    max_slot = max(u.get("slot_id", 0) for u in filament_usage)
+    if max_slot <= 0:
+        return None
+
+    result = [-1] * max_slot
+    used_trays: set[int] = set()
+
+    for usage in filament_usage:
+        slot_id = usage.get("slot_id", 0)
+        if slot_id <= 0:
+            continue
+        slot_color = usage.get("color", "").lstrip("#").lower()
+        if len(slot_color) < 6:
+            return None  # Can't match without a valid color
+
+        slot_color = slot_color[:6]  # Strip alpha if present
+        candidates = color_to_trays.get(slot_color, [])
+        # Filter out trays already claimed by another slot
+        available = [t for t in candidates if t not in used_trays]
+
+        if len(available) != 1:
+            # Ambiguous (multiple trays with same color) or no match
+            return None
+
+        result[slot_id - 1] = available[0]
+        used_trays.add(available[0])
+
+    # Only return if at least one valid mapping exists
+    if all(v < 0 for v in result):
+        return None
+
+    logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
+    return result
+
+
 @dataclass
 class PrintSession:
     printer_id: int
@@ -30,14 +155,17 @@ class PrintSession:
     tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
     # tray_now at print start (correct value, unlike at completion where it's 255)
     tray_now_at_start: int = -1
+    # 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)
 
 
 # Module-level storage, keyed by printer_id
 _active_sessions: dict[int, PrintSession] = {}
 
 
-async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
-    """Capture AMS tray remain% at print start."""
+async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
+    """Capture AMS tray remain% and spool assignments at print start."""
     state = printer_manager.get_status(printer_id)
     if not state or not state.raw_data:
         logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
@@ -90,6 +218,20 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
             )
         logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
 
+    # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
+    spool_assignments: dict[tuple[int, int], int] = {}
+    if db:
+        assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
+        for assignment in assign_result.scalars().all():
+            spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
+        if spool_assignments:
+            logger.info(
+                "[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
+                len(spool_assignments),
+                printer_id,
+                {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
+            )
+
     # Always create session (even without valid remain data) so print_name
     # is available at completion for 3MF-based tracking
     session = PrintSession(
@@ -98,6 +240,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
         started_at=datetime.now(timezone.utc),
         tray_remain_start=tray_remain_start,
         tray_now_at_start=tray_now_at_start,
+        spool_assignments=spool_assignments,
     )
     _active_sessions[printer_id] = session
 
@@ -128,11 +271,20 @@ async def on_print_complete(
 
     Returns a list of dicts describing what was logged (for WebSocket broadcast).
     """
+    from sqlalchemy import select
+
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
     session = _active_sessions.pop(printer_id, None)
     status = data.get("status", "completed")
     results = []
     handled_trays: set[tuple[int, int]] = set()
 
+    # Fetch default filament cost from settings for fallback
+    default_cost_str = await get_setting(db, "default_filament_cost")
+    default_filament_cost = float(default_cost_str) if default_cost_str else 0.0
+
     logger.info(
         "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
         printer_id,
@@ -167,6 +319,10 @@ async def on_print_complete(
             db,
             ams_mapping=ams_mapping,
             tray_now_at_start=session.tray_now_at_start if session else -1,
+            last_progress=data.get("last_progress", 0.0),
+            last_layer_num=data.get("last_layer_num", 0),
+            default_filament_cost=default_filament_cost,
+            spool_assignments=session.spool_assignments if session else None,
         )
         results.extend(threemf_results)
 
@@ -201,20 +357,23 @@ async def on_print_complete(
                     if delta_pct <= 0:
                         continue  # No consumption or tray was refilled
 
-                    # Look up SpoolAssignment for this slot
-                    result = await db.execute(
-                        select(SpoolAssignment).where(
-                            SpoolAssignment.printer_id == printer_id,
-                            SpoolAssignment.ams_id == ams_id,
-                            SpoolAssignment.tray_id == tray_id,
+                    # Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
+                    spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
+                    if spool_id is None:
+                        result = await db.execute(
+                            select(SpoolAssignment).where(
+                                SpoolAssignment.printer_id == printer_id,
+                                SpoolAssignment.ams_id == ams_id,
+                                SpoolAssignment.tray_id == tray_id,
+                            )
                         )
-                    )
-                    assignment = result.scalar_one_or_none()
-                    if not assignment:
-                        continue
+                        assignment = result.scalar_one_or_none()
+                        if not assignment:
+                            continue
+                        spool_id = assignment.spool_id
 
                     # Load spool
-                    spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+                    spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
                     spool = spool_result.scalar_one_or_none()
                     if not spool:
                         continue
@@ -226,6 +385,12 @@ async def on_print_complete(
                     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,
@@ -234,6 +399,8 @@ async def on_print_complete(
                         weight_used=round(weight_grams, 1),
                         percent_used=delta_pct,
                         status=status,
+                        cost=cost,
+                        archive_id=archive_id,
                     )
                     db.add(history)
 
@@ -246,6 +413,7 @@ async def on_print_complete(
                             "ams_id": ams_id,
                             "tray_id": tray_id,
                             "material": spool.material,
+                            "cost": cost,
                         }
                     )
 
@@ -263,6 +431,21 @@ async def on_print_complete(
     if results:
         await db.commit()
 
+    # --- Update PrintArchive.cost from THIS print session only ---
+
+    if archive_id and results:
+        from sqlalchemy import select
+
+        from backend.app.models.archive import PrintArchive
+
+        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        archive = archive_result.scalar_one_or_none()
+        if archive:
+            total_cost = sum(r.get("cost", 0) or 0 for r in results)
+            if total_cost > 0:
+                archive.cost = round(total_cost, 2)
+                await db.commit()
+
     return results
 
 
@@ -276,6 +459,10 @@ async def _track_from_3mf(
     db: AsyncSession,
     ams_mapping: list[int] | None = None,
     tray_now_at_start: int = -1,
+    last_progress: float = 0.0,
+    last_layer_num: int = 0,
+    default_filament_cost: float = 0.0,
+    spool_assignments: dict[tuple[int, int], int] | None = None,
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
 
@@ -285,9 +472,10 @@ async def _track_from_3mf(
 
     Slot-to-tray mapping priority:
     1. Stored ams_mapping from print command (reprints/direct prints)
-    2. Queue item ams_mapping (for queue-initiated prints)
-    3. tray_now from printer state (for single-filament non-queue prints)
-    4. Default mapping: slot_id - 1 = global_tray_id (last resort)
+    2. MQTT mapping field from printer state (universal, all print sources)
+    3. Queue item ams_mapping (for queue-initiated prints)
+    4. tray_now from printer state (for single-filament non-queue prints)
+    5. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
@@ -313,10 +501,25 @@ async def _track_from_3mf(
     logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
 
     # --- Resolve slot-to-tray mapping ---
+    mapping_source = None
+
     # 1. Use stored ams_mapping from the print command (reprints/direct prints)
     slot_to_tray = ams_mapping
+    if slot_to_tray:
+        mapping_source = "print_cmd"
 
-    # 2. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
+    # 2. Try MQTT mapping field from printer state (universal, all print sources)
+    if not slot_to_tray:
+        state = printer_manager.get_status(printer_id)
+        raw_data = getattr(state, "raw_data", None) if state else None
+        if raw_data:
+            mqtt_mapping = raw_data.get("mapping")
+            decoded = _decode_mqtt_mapping(mqtt_mapping)
+            if decoded:
+                slot_to_tray = decoded
+                mapping_source = "mqtt"
+
+    # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
     if not slot_to_tray:
         queue_result = await db.execute(
             select(PrintQueueItem)
@@ -327,23 +530,41 @@ async def _track_from_3mf(
         if queue_item and queue_item.ams_mapping:
             try:
                 slot_to_tray = json.loads(queue_item.ams_mapping)
+                mapping_source = "queue"
             except (json.JSONDecodeError, TypeError):
                 pass
 
+    # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
+    if not slot_to_tray:
+        state = printer_manager.get_status(printer_id)
+        raw_data = getattr(state, "raw_data", None) if state else None
+        if raw_data:
+            matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
+            if matched:
+                slot_to_tray = matched
+                mapping_source = "color_match"
+
     logger.info(
         "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
         slot_to_tray,
-        "print_cmd" if ams_mapping else ("queue" if slot_to_tray else "none"),
+        mapping_source or "none",
     )
 
-    # 3. For single-filament non-queue prints, use tray_now from printer state
-    #    Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
+    # 5. For single-filament non-queue prints, use tray_now from printer state
+    #    Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
+    #              > last_loaded_tray > vt_tray check
     nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
     tray_now_override: int | None = None
+    tray_changes: list[tuple[int, int]] = []  # [(global_tray_id, layer_num), ...]
     if not slot_to_tray and len(nonzero_slots) == 1:
         state = printer_manager.get_status(printer_id)
-        # Try tray_now_at_start first (captured at print start)
-        if 0 <= tray_now_at_start <= 254:
+        tray_changes = getattr(state, "tray_change_log", []) if state else []
+
+        if len(tray_changes) > 1:
+            # Multi-tray usage detected — will split in per-slot loop using per-layer gcode
+            logger.info("[UsageTracker] 3MF: tray change log: %s (will split weight)", tray_changes)
+        elif 0 <= tray_now_at_start <= 254:
+            # Try tray_now_at_start first (captured at print start)
             tray_now_override = tray_now_at_start
             logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
         elif state and 0 <= state.tray_now <= 254:
@@ -360,7 +581,7 @@ async def _track_from_3mf(
             if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
                 tray_now_override = state.tray_now
                 logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
-        if tray_now_override is None:
+        if tray_now_override is None and len(tray_changes) <= 1:
             logger.info(
                 "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
                 tray_now_at_start,
@@ -374,6 +595,10 @@ async def _track_from_3mf(
     else:
         state = printer_manager.get_status(printer_id)
         progress = state.progress if state else 0
+        # Firmware resets progress to 0 on cancel — use last valid progress captured during print
+        if progress <= 0 and last_progress > 0:
+            progress = last_progress
+            logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
         scale = max(0.0, min(progress / 100.0, 1.0))
 
     # Per-layer gcode accuracy for partial prints
@@ -381,6 +606,10 @@ async def _track_from_3mf(
     if status != "completed":
         state = printer_manager.get_status(printer_id)
         current_layer = state.layer_num if state else 0
+        # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
+        if current_layer <= 0 and last_layer_num > 0:
+            current_layer = last_layer_num
+            logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
         if current_layer > 0:
             try:
                 from backend.app.utils.threemf_tools import (
@@ -412,6 +641,164 @@ async def _track_from_3mf(
         if used_g <= 0:
             continue
 
+        # --- Mid-print tray switch: split weight across trays ---
+        if len(tray_changes) > 1:
+            # Compute total weight for this slot (same logic as normal path)
+            if layer_grams and slot_id in layer_grams:
+                total_weight = layer_grams[slot_id]
+            else:
+                total_weight = used_g * scale
+
+            if total_weight <= 0:
+                continue
+
+            # Extract per-layer gcode for segment splitting
+            split_layer_usage = None
+            split_props: dict = {}
+            try:
+                from backend.app.utils.threemf_tools import (
+                    extract_filament_properties_from_3mf,
+                    extract_layer_filament_usage_from_3mf,
+                    get_cumulative_usage_at_layer,
+                    mm_to_grams,
+                )
+
+                split_layer_usage = extract_layer_filament_usage_from_3mf(file_path)
+                filament_props = extract_filament_properties_from_3mf(file_path)
+                split_props = filament_props.get(slot_id, {})
+            except Exception:
+                pass  # Fall back to linear splitting
+
+            density = split_props.get("density", 1.24)
+            diameter = split_props.get("diameter", 1.75)
+            filament_id = slot_id - 1  # 0-based for gcode
+
+            sum_previous = 0.0
+            for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes):
+                is_last = seg_idx + 1 >= len(tray_changes)
+
+                if is_last:
+                    # Last segment: remainder to avoid rounding drift
+                    segment_grams = total_weight - sum_previous
+                elif split_layer_usage:
+                    seg_end_layer = tray_changes[seg_idx + 1][1]
+                    mm_at_start = get_cumulative_usage_at_layer(split_layer_usage, seg_start_layer).get(filament_id, 0)
+                    mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0)
+                    segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density)
+                else:
+                    # No per-layer data: linear fallback by layer ratio
+                    seg_end_layer = tray_changes[seg_idx + 1][1]
+                    total_layers = state.total_layers if state else 0
+                    if total_layers > 0:
+                        segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
+                    else:
+                        # Can't compute ratio — assign all to last segment
+                        segment_grams = 0.0
+
+                sum_previous += segment_grams
+                if segment_grams <= 0:
+                    continue
+
+                # Convert global tray ID to (ams_id, tray_id)
+                if tray_global >= 254:
+                    seg_ams_id = 255
+                    seg_tray_id = tray_global - 254
+                elif tray_global >= 128:
+                    seg_ams_id = tray_global
+                    seg_tray_id = 0
+                else:
+                    seg_ams_id = tray_global // 4
+                    seg_tray_id = tray_global % 4
+
+                seg_key = (seg_ams_id, seg_tray_id)
+                if seg_key in handled_trays:
+                    continue
+
+                logger.info(
+                    "[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg",
+                    seg_idx,
+                    tray_global,
+                    seg_ams_id,
+                    seg_tray_id,
+                    seg_start_layer,
+                    tray_changes[seg_idx + 1][1] if not is_last else "end",
+                    segment_grams,
+                )
+
+                # Find spool for this tray
+                seg_spool_id = spool_assignments.get(seg_key) if spool_assignments else None
+                if seg_spool_id is None:
+                    assign_result = await db.execute(
+                        select(SpoolAssignment).where(
+                            SpoolAssignment.printer_id == printer_id,
+                            SpoolAssignment.ams_id == seg_ams_id,
+                            SpoolAssignment.tray_id == seg_tray_id,
+                        )
+                    )
+                    assignment = assign_result.scalar_one_or_none()
+                    if not assignment:
+                        logger.info(
+                            "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
+                            printer_id,
+                            seg_ams_id,
+                            seg_tray_id,
+                        )
+                        continue
+                    seg_spool_id = assignment.spool_id
+
+                spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
+                spool = spool_result.scalar_one_or_none()
+                if not spool:
+                    continue
+
+                spool.weight_used = (spool.weight_used or 0) + segment_grams
+                spool.last_used = datetime.now(timezone.utc)
+
+                percent = round(segment_grams / (spool.label_weight or 1000) * 100)
+
+                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((segment_grams / 1000.0) * cost_per_kg, 2)
+
+                history = SpoolUsageHistory(
+                    spool_id=spool.id,
+                    printer_id=printer_id,
+                    print_name=print_name,
+                    weight_used=round(segment_grams, 1),
+                    percent_used=percent,
+                    status=status,
+                    cost=cost,
+                    archive_id=archive_id,
+                )
+                db.add(history)
+
+                handled_trays.add(seg_key)
+                results.append(
+                    {
+                        "spool_id": spool.id,
+                        "weight_used": round(segment_grams, 1),
+                        "percent_used": percent,
+                        "ams_id": seg_ams_id,
+                        "tray_id": seg_tray_id,
+                        "material": spool.material,
+                        "cost": cost,
+                    }
+                )
+
+                logger.info(
+                    "[UsageTracker] Spool %d consumed %.1fg (3MF split seg%d) on printer %d AMS%d-T%d (%s)",
+                    spool.id,
+                    segment_grams,
+                    seg_idx,
+                    printer_id,
+                    seg_ams_id,
+                    seg_tray_id,
+                    status,
+                )
+
+            continue  # Skip normal single-tray processing for this slot
+
         # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
         if tray_now_override is not None:
             # Single-filament non-queue print: use actual tray from printer state
@@ -449,21 +836,26 @@ async def _track_from_3mf(
         if key in handled_trays:
             continue
 
-        # Find spool assignment for this tray
-        assign_result = await db.execute(
-            select(SpoolAssignment).where(
-                SpoolAssignment.printer_id == printer_id,
-                SpoolAssignment.ams_id == ams_id,
-                SpoolAssignment.tray_id == tray_id,
+        # Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
+        spool_id = spool_assignments.get(key) if spool_assignments else None
+        if spool_id is None:
+            assign_result = await db.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
             )
-        )
-        assignment = assign_result.scalar_one_or_none()
-        if not assignment:
-            logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
-            continue
+            assignment = assign_result.scalar_one_or_none()
+            if not assignment:
+                logger.info(
+                    "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
+                )
+                continue
+            spool_id = assignment.spool_id
 
         # Load spool
-        spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+        spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
         spool = spool_result.scalar_one_or_none()
         if not spool:
             continue
@@ -483,6 +875,12 @@ async def _track_from_3mf(
 
         percent = round(weight_grams / (spool.label_weight or 1000) * 100)
 
+        # 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,
@@ -491,6 +889,8 @@ async def _track_from_3mf(
             weight_used=round(weight_grams, 1),
             percent_used=percent,
             status=status,
+            cost=cost,
+            archive_id=archive_id,
         )
         db.add(history)
 
@@ -503,16 +903,15 @@ async def _track_from_3mf(
                 "ams_id": ams_id,
                 "tray_id": tray_id,
                 "material": spool.material,
+                "cost": cost,
             }
         )
 
         # Determine mapping source for debug logging
         if tray_now_override is not None:
             map_src = ", tray_now"
-        elif slot_to_tray and ams_mapping:
-            map_src = ", print_cmd_map"
-        elif slot_to_tray:
-            map_src = ", queue_map"
+        elif mapping_source:
+            map_src = f", {mapping_source}_map"
         else:
             map_src = ""
         logger.info(

+ 48 - 31
backend/app/services/virtual_printer/bind_server.py

@@ -1,7 +1,8 @@
-"""Bind/detect server for virtual printer discovery (port 3000).
+"""Bind/detect server for virtual printer discovery (ports 3000 + 3002).
 
-Bambu slicers (BambuStudio, OrcaSlicer) connect to port 3000 on a printer
-to perform the "bind with access code" handshake before using MQTT/FTP.
+Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
+or 3002 to perform the "bind with access code" handshake before using
+MQTT/FTP. The port varies by slicer version, so we listen on both.
 
 Protocol:
   - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
@@ -19,7 +20,7 @@ import struct
 
 logger = logging.getLogger(__name__)
 
-BIND_PORT = 3000
+BIND_PORTS = [3000, 3002]
 FRAME_HEADER = b"\xa5\xa5"
 FRAME_TRAILER = b"\xa7\xa7"
 HEADER_SIZE = 4  # 2 bytes magic + 2 bytes length
@@ -27,10 +28,13 @@ TRAILER_SIZE = 2
 
 
 class BindServer:
-    """Responds to slicer bind/detect requests on port 3000.
+    """Responds to slicer bind/detect requests on ports 3000 and 3002.
 
     In server mode, Bambuddy IS the printer — it responds with its own
     identity so the slicer can discover and bind to it.
+
+    Different BambuStudio versions connect on different ports (3000 or 3002),
+    so we listen on both to ensure compatibility.
     """
 
     def __init__(
@@ -39,42 +43,55 @@ class BindServer:
         model: str,
         name: str,
         version: str = "01.00.00.00",
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.serial = serial
         self.model = model
         self.name = name
         self.version = version
+        self.bind_address = bind_address
 
-        self._server: asyncio.Server | None = None
+        self._servers: list[asyncio.Server] = []
         self._running = False
 
     async def start(self) -> None:
-        """Start the bind server on port 3000."""
+        """Start the bind server on ports 3000 and 3002."""
         if self._running:
             return
 
-        logger.info("Starting bind server on port %s (serial=%s, model=%s)", BIND_PORT, self.serial, self.model)
+        self._running = True
+        logger.info(
+            "Starting bind server on ports %s (serial=%s, model=%s)",
+            BIND_PORTS,
+            self.serial,
+            self.model,
+        )
 
         try:
-            self._running = True
-            self._server = await asyncio.start_server(
-                self._handle_client,
-                "0.0.0.0",  # nosec B104
-                BIND_PORT,
-            )
-
-            logger.info("Bind server listening on port %s", BIND_PORT)
-
-            async with self._server:
-                await self._server.serve_forever()
-
-        except OSError as e:
-            if e.errno == 98:
-                logger.error("Bind server port %s is already in use", BIND_PORT)
-            elif e.errno == 13:
-                logger.error("Bind server: cannot bind to port %s (permission denied)", BIND_PORT)
-            else:
-                logger.error("Bind server error: %s", e)
+            for port in BIND_PORTS:
+                try:
+                    server = await asyncio.start_server(
+                        self._handle_client,
+                        self.bind_address,
+                        port,
+                    )
+                    self._servers.append(server)
+                    logger.info("Bind server listening on %s:%s", self.bind_address, port)
+                except OSError as e:
+                    if e.errno == 98:
+                        logger.warning("Bind server port %s already in use, skipping", port)
+                    elif e.errno == 13:
+                        logger.warning("Bind server: cannot bind to port %s (permission denied), skipping", port)
+                    else:
+                        logger.warning("Bind server: failed to bind port %s: %s", port, e)
+
+            if not self._servers:
+                logger.error("Bind server: could not bind to any port")
+                return
+
+            # Serve all successfully bound ports
+            await asyncio.gather(*(s.serve_forever() for s in self._servers))
+
         except asyncio.CancelledError:
             logger.debug("Bind server task cancelled")
         except Exception as e:
@@ -87,13 +104,13 @@ class BindServer:
         logger.info("Stopping bind server")
         self._running = False
 
-        if self._server:
+        for server in self._servers:
             try:
-                self._server.close()
-                await self._server.wait_closed()
+                server.close()
+                await server.wait_closed()
             except OSError as e:
                 logger.debug("Error closing bind server: %s", e)
-            self._server = None
+        self._servers = []
 
     async def _handle_client(
         self,

+ 7 - 4
backend/app/services/virtual_printer/certificate.py

@@ -48,17 +48,20 @@ class CertificateService:
     - Printer cert with CN=serial_number, signed by the CA
     """
 
-    def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL):
+    def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL, shared_ca_dir: Path | None = None):
         """Initialize the certificate service.
 
         Args:
-            cert_dir: Directory to store certificates
+            cert_dir: Directory to store per-instance certificates
             serial: Serial number to use as CN in printer certificate
+            shared_ca_dir: If set, CA cert/key are read from this directory
+                instead of cert_dir (for multi-instance shared CA)
         """
         self.cert_dir = cert_dir
         self.serial = serial
-        self.ca_cert_path = cert_dir / "bbl_ca.crt"
-        self.ca_key_path = cert_dir / "bbl_ca.key"
+        ca_dir = shared_ca_dir or cert_dir
+        self.ca_cert_path = ca_dir / "bbl_ca.crt"
+        self.ca_key_path = ca_dir / "bbl_ca.key"
         self.cert_path = cert_dir / "virtual_printer.crt"
         self.key_path = cert_dir / "virtual_printer.key"
 

+ 8 - 2
backend/app/services/virtual_printer/ftp_server.py

@@ -34,6 +34,7 @@ class FTPSession:
         on_file_received: Callable[[Path, str], None] | None,
         passive_port_range: tuple[int, int] = (50000, 50100),
         pasv_address: str = "",
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.reader = reader
         self.writer = writer
@@ -43,6 +44,7 @@ class FTPSession:
         self.on_file_received = on_file_received
         self.passive_port_range = passive_port_range
         self.pasv_address = pasv_address
+        self.bind_address = bind_address
 
         self.authenticated = False
         self.username: str | None = None
@@ -218,7 +220,7 @@ class FTPSession:
             try:
                 self.data_server = await asyncio.start_server(
                     self._handle_data_connection,
-                    "0.0.0.0",  # nosec B104
+                    self.bind_address,
                     port,
                     ssl=self.ssl_context,
                 )
@@ -524,6 +526,7 @@ class VirtualPrinterFTPServer:
         key_path: Path,
         port: int = FTP_PORT,
         on_file_received: Callable[[Path, str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         """Initialize the FTPS server.
 
@@ -534,6 +537,7 @@ class VirtualPrinterFTPServer:
             key_path: Path to TLS private key file
             port: Port to listen on (default 990)
             on_file_received: Callback when file upload completes (path, source_ip)
+            bind_address: IP address to bind to (default 0.0.0.0)
         """
         self.upload_dir = upload_dir
         self.access_code = access_code
@@ -541,6 +545,7 @@ class VirtualPrinterFTPServer:
         self.key_path = key_path
         self.port = port
         self.on_file_received = on_file_received
+        self.bind_address = bind_address
         self._server: asyncio.Server | None = None
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
@@ -575,7 +580,7 @@ class VirtualPrinterFTPServer:
             # Create server with SSL - TLS handshake happens before any FTP data
             self._server = await asyncio.start_server(
                 self._handle_client,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.port,
                 ssl=self._ssl_context,  # This makes it implicit FTPS!
             )
@@ -619,6 +624,7 @@ class VirtualPrinterFTPServer:
             on_file_received=self.on_file_received,
             passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
             pasv_address=self._pasv_address,
+            bind_address=self.bind_address,
         )
 
         # Track the session task so we can cancel it on stop

+ 532 - 523
backend/app/services/virtual_printer/manager.py

@@ -1,10 +1,7 @@
 """Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
 
-Supports multiple modes:
-- immediate: Archive uploads immediately
-- review: Queue uploads for user review before archiving
-- print_queue: Archive and add to print queue (unassigned)
-- proxy: Transparent TCP proxy to a real printer (for remote slicer access)
+Each virtual printer runs its own independent services (FTP, MQTT, SSDP, Bind)
+bound to its dedicated IP address, regardless of mode.
 """
 
 import asyncio
@@ -78,497 +75,145 @@ MODEL_SERIAL_PREFIXES = {
 DEFAULT_VIRTUAL_PRINTER_MODEL = "3DPrinter-X1-Carbon"  # X1C
 
 
-class VirtualPrinterManager:
-    """Manages the virtual printer lifecycle and coordinates all services."""
-
-    # Fixed configuration
-    PRINTER_NAME = "Bambuddy"
-    SERIAL_SUFFIX = "391800001"  # Fixed suffix for virtual printer
+def _get_serial_for_model(model: str, serial_suffix: str) -> str:
+    """Get serial number for the given model and suffix."""
+    prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
+    return f"{prefix}{serial_suffix}"
 
-    def __init__(self):
-        """Initialize the virtual printer manager."""
-        self._session_factory: Callable | None = None
-        self._enabled = False
-        self._access_code = ""
-        self._mode = "immediate"
-        self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
-        self._target_printer_ip = ""  # For proxy mode
-        self._target_printer_serial = ""  # For proxy mode (real printer's serial)
-        self._remote_interface_ip = ""  # For proxy mode SSDP (LAN B - slicer network)
-
-        # Service instances
-        self._ssdp: VirtualPrinterSSDPServer | None = None
-        self._ssdp_proxy: SSDPProxy | None = None
-        self._ftp: VirtualPrinterFTPServer | None = None
-        self._mqtt: SimpleMQTTServer | None = None
-        self._bind: BindServer | None = None  # For server mode (bind/detect on port 3000)
-        self._proxy: SlicerProxyManager | None = None  # For proxy mode
 
-        # Background tasks
-        self._tasks: list[asyncio.Task] = []
+class VirtualPrinterInstance:
+    """Per-printer state and file handling logic.
 
-        # Directories
-        self._base_dir = app_settings.base_dir / "virtual_printer"
-        self._upload_dir = self._base_dir / "uploads"
-        self._cert_dir = self._base_dir / "certs"
+    Each instance represents one virtual printer with its own config,
+    upload directory, certificates, and file handling mode.
+    """
 
-        # Create directories early to avoid permission issues later
-        # If running in Docker, these need to be on a writable volume
-        self._ensure_directories()
+    def __init__(
+        self,
+        *,
+        vp_id: int,
+        name: str,
+        mode: str,
+        model: str,
+        access_code: str,
+        serial_suffix: str,
+        target_printer_ip: str = "",
+        target_printer_serial: str = "",
+        target_printer_id: int | None = None,
+        bind_ip: str = "",
+        remote_interface_ip: str = "",
+        base_dir: Path,
+        session_factory: Callable | None = None,
+    ):
+        self.id = vp_id
+        self.name = name
+        self.mode = mode
+        self.model = model
+        self.access_code = access_code
+        self.serial_suffix = serial_suffix
+        self.target_printer_ip = target_printer_ip
+        self.target_printer_serial = target_printer_serial
+        self.target_printer_id = target_printer_id
+        self.bind_ip = bind_ip
+        self.remote_interface_ip = remote_interface_ip
+        self._session_factory = session_factory
 
-        # Certificate service
-        self._cert_service = CertificateService(self._cert_dir)
+        # Directories
+        self.upload_dir = base_dir / "uploads" / str(vp_id)
+        self.cert_dir = base_dir / "certs" / str(vp_id)
+        shared_ca_dir = base_dir / "certs"
+
+        # Ensure directories exist
+        self.upload_dir.mkdir(parents=True, exist_ok=True)
+        (self.upload_dir / "cache").mkdir(exist_ok=True)
+        self.cert_dir.mkdir(parents=True, exist_ok=True)
+
+        # Certificate service (shared CA, per-instance printer cert)
+        self._cert_service = CertificateService(
+            cert_dir=self.cert_dir,
+            serial=self.serial,
+            shared_ca_dir=shared_ca_dir,
+        )
 
-        # Track pending uploads for MQTT correlation
+        # Pending files for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
-    def _ensure_directories(self) -> None:
-        """Create and verify virtual printer directories are writable.
-
-        Creates all required directories at startup to catch permission
-        issues early rather than when the user tries to enable features.
-        """
-        dirs_to_create = [
-            self._base_dir,
-            self._upload_dir,
-            self._upload_dir / "cache",
-            self._cert_dir,
-        ]
-
-        logger.info("Checking virtual printer directories in %s", self._base_dir)
-
-        for dir_path in dirs_to_create:
-            try:
-                dir_path.mkdir(parents=True, exist_ok=True)
-            except PermissionError:
-                logger.error(
-                    f"Cannot create directory {dir_path}: Permission denied. "
-                    f"For Docker: ensure the data volume is writable by the container user. "
-                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
-                )
-                continue
-
-            # Verify directory is writable by attempting to create a test file
-            test_file = dir_path / ".write_test"
-            try:
-                test_file.touch()
-                test_file.unlink(missing_ok=True)
-            except PermissionError:
-                logger.error(
-                    f"Directory {dir_path} exists but is not writable. "
-                    f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
-                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
-                )
-
-    def _get_serial_for_model(self, model: str) -> str:
-        """Get appropriate serial number for the given model.
-
-        Args:
-            model: SSDP model code (e.g., 'BL-P001', 'C11')
-
-        Returns:
-            Serial number with correct prefix for the model
-        """
-        prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
-        return f"{prefix}{self.SERIAL_SUFFIX}"
+        # Per-instance services
+        self._proxy: SlicerProxyManager | None = None
+        self._ftp: VirtualPrinterFTPServer | None = None
+        self._mqtt: SimpleMQTTServer | None = None
+        self._bind: BindServer | None = None
+        self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
+        self._tasks: list[asyncio.Task] = []
 
     @property
-    def printer_serial(self) -> str:
-        """Get the current printer serial number based on model."""
-        return self._get_serial_for_model(self._model)
+    def serial(self) -> str:
+        """Full serial number for this virtual printer."""
+        return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)
 
-    def set_session_factory(self, session_factory: Callable) -> None:
-        """Set the database session factory.
+    @property
+    def cert_path(self) -> Path:
+        return self._cert_service.cert_path
 
-        Args:
-            session_factory: Async context manager for database sessions
-        """
-        self._session_factory = session_factory
+    @property
+    def key_path(self) -> Path:
+        return self._cert_service.key_path
 
     @property
-    def is_enabled(self) -> bool:
-        """Check if virtual printer is enabled."""
-        return self._enabled
+    def is_proxy(self) -> bool:
+        return self.mode == "proxy"
 
     @property
     def is_running(self) -> bool:
-        """Check if virtual printer services are running."""
         return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
 
-    async def configure(
-        self,
-        enabled: bool,
-        access_code: str = "",
-        mode: str = "immediate",
-        model: str = "",
-        target_printer_ip: str = "",
-        target_printer_serial: str = "",
-        remote_interface_ip: str = "",
-    ) -> None:
-        """Configure and start/stop virtual printer.
-
-        Args:
-            enabled: Whether to enable the virtual printer
-            access_code: Authentication password for slicer connections
-            mode: Archive mode - 'immediate', 'review', 'print_queue', or 'proxy'
-            model: SSDP model code (e.g., 'BL-P001' for X1C)
-            target_printer_ip: Target printer IP for proxy mode
-            target_printer_serial: Target printer serial for proxy mode
-            remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
-        """
-        # Proxy mode has different requirements
-        if mode == "proxy":
-            if enabled and not target_printer_ip:
-                raise ValueError("Target printer IP is required for proxy mode")
-            # Access code not required for proxy mode (uses printer's credentials)
-        else:
-            if enabled and not access_code:
-                raise ValueError("Access code is required when enabling virtual printer")
-
-        # Validate model if provided
-        new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
-        model_changed = new_model != self._model
-        mode_changed = mode != self._mode
-        target_changed = target_printer_ip != self._target_printer_ip
-        serial_changed = target_printer_serial != self._target_printer_serial
-        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
-        old_mode = self._mode
-
-        logger.debug(
-            f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
-            f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
-            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
-            f"remote_interface_ip={remote_interface_ip}"
-        )
-
-        self._access_code = access_code
-        self._mode = mode
-        self._model = new_model
-        self._target_printer_ip = target_printer_ip
-        self._target_printer_serial = target_printer_serial
-        self._remote_interface_ip = remote_interface_ip
-
-        needs_restart = (
-            model_changed
-            or mode_changed
-            or remote_iface_changed
-            or (mode == "proxy" and (target_changed or serial_changed))
-        )
-
-        if enabled and not self._enabled:
-            logger.info("Starting virtual printer (was disabled)")
-            await self._start()
-        elif not enabled and self._enabled:
-            logger.info("Stopping virtual printer (was enabled)")
-            await self._stop()
-        elif enabled and self._enabled and needs_restart:
-            # Configuration changed while running - restart services
-            logger.info("Configuration changed (mode=%s→%s), restarting...", old_mode, mode)
-            await self._stop()
-            # Give time for ports to be released
-            await asyncio.sleep(0.5)
-            await self._start()
-            logger.info("Virtual printer restarted with new configuration")
-        else:
-            logger.debug("No state change needed (enabled=%s, self._enabled=%s)", enabled, self._enabled)
-
-        self._enabled = enabled
-
-    async def _start(self) -> None:
-        """Start all virtual printer services."""
-        logger.info("Starting virtual printer services (mode=%s)...", self._mode)
-
-        # Proxy mode uses different services
-        if self._mode == "proxy":
-            await self._start_proxy_mode()
-            return
-
-        # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
-        await self._start_server_mode()
-
-    async def _start_proxy_mode(self) -> None:
-        """Start virtual printer in proxy mode (TLS terminating relay)."""
-        logger.info("Starting proxy mode to %s", self._target_printer_ip)
-
-        # In proxy mode, use the REAL printer's serial number
-        # This ensures MQTT topic subscriptions match the real printer's topics
-        proxy_serial = self._target_printer_serial or self.printer_serial
-        logger.info("Proxy mode using serial: %s", proxy_serial)
-
-        # Update certificate service with the real printer's serial
-        self._cert_service.serial = proxy_serial
-
-        # Regenerate printer cert if needed (CA is preserved)
-        # Include remote interface IP in SAN so slicer TLS succeeds
-        additional_ips = []
-        if self._remote_interface_ip:
-            additional_ips.append(self._remote_interface_ip)
-        self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
-        logger.info("Generated certificate for proxy serial: %s", proxy_serial)
-
-        # Initialize TLS proxy with our certificates
-        self._proxy = SlicerProxyManager(
-            target_host=self._target_printer_ip,
-            cert_path=cert_path,
-            key_path=key_path,
-            on_activity=self._on_proxy_activity,
-        )
-
-        # Start services as background tasks
-        async def run_with_logging(coro, name):
-            try:
-                await coro
-            except Exception as e:
-                logger.error("Virtual printer %s failed: %s", name, e)
-
-        self._tasks = []
-
-        # SSDP setup: use SSDPProxy if remote interface is configured
-        # Local interface is auto-detected from target printer IP
-        if self._remote_interface_ip:
-            # Auto-detect local interface based on target printer IP
-            from backend.app.services.network_utils import find_interface_for_ip
-
-            local_iface = find_interface_for_ip(self._target_printer_ip)
-            if local_iface:
-                local_interface_ip = local_iface["ip"]
-                logger.info(
-                    f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
-                )
-                self._ssdp_proxy = SSDPProxy(
-                    local_interface_ip=local_interface_ip,
-                    remote_interface_ip=self._remote_interface_ip,
-                    target_printer_ip=self._target_printer_ip,
-                )
-                self._tasks.append(
-                    asyncio.create_task(
-                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
-                        name="virtual_printer_ssdp_proxy",
-                    )
-                )
-            else:
-                logger.warning(
-                    f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
-                    "falling back to single-interface SSDP"
-                )
-                self._start_fallback_ssdp(proxy_serial, run_with_logging)
-        else:
-            # Single interface: broadcast SSDP on same network (fallback)
-            self._start_fallback_ssdp(proxy_serial, run_with_logging)
-
-        # Add TLS proxy task
-        self._tasks.append(
-            asyncio.create_task(
-                run_with_logging(self._proxy.start(), "Proxy"),
-                name="virtual_printer_proxy",
-            )
-        )
-
-        logger.info(
-            "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d, Bind %s:%d",
-            self._target_printer_ip,
-            SlicerProxyManager.PRINTER_FTP_PORT,
-            self._target_printer_ip,
-            SlicerProxyManager.PRINTER_MQTT_PORT,
-            self._target_printer_ip,
-            SlicerProxyManager.PRINTER_BIND_PORT,
-        )
-
-    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
-        """Start single-interface SSDP server as fallback."""
-        logger.info("SSDP broadcast mode (single interface)")
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=f"{self.PRINTER_NAME} (Proxy)",
-            serial=proxy_serial,
-            model=self._model,
-        )
-        self._tasks.append(
-            asyncio.create_task(
-                run_with_logging(self._ssdp.start(), "SSDP"),
-                name="virtual_printer_ssdp",
-            )
-        )
-
-    async def _start_server_mode(self) -> None:
-        """Start virtual printer in server mode (FTP/MQTT servers)."""
-        # Update certificate service with current serial (based on model)
-        current_serial = self.printer_serial
-        self._cert_service.serial = current_serial
-
-        # Regenerate printer cert if serial changed (CA is preserved)
-        # Include remote interface IP in SAN so slicer TLS succeeds on that interface
-        additional_ips = []
-        if self._remote_interface_ip:
-            additional_ips.append(self._remote_interface_ip)
+    def generate_certificates(self) -> tuple[Path, Path]:
+        """Generate certificates for this instance."""
+        self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)
+        additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None
+        if self.bind_ip:
+            additional_ips = additional_ips or []
+            additional_ips.append(self.bind_ip)
         self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
-        logger.info("Generated certificate for serial: %s", current_serial)
-
-        # Create directories
-        self._upload_dir.mkdir(parents=True, exist_ok=True)
-        (self._upload_dir / "cache").mkdir(exist_ok=True)
-
-        # Initialize services
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=self.PRINTER_NAME,
-            serial=self.printer_serial,
-            model=self._model,
-            advertise_ip=self._remote_interface_ip,
-        )
-
-        self._ftp = VirtualPrinterFTPServer(
-            upload_dir=self._upload_dir,
-            access_code=self._access_code,
-            cert_path=cert_path,
-            key_path=key_path,
-            on_file_received=self._on_file_received,
-        )
+        return self._cert_service.generate_certificates(additional_ips=additional_ips)
 
-        self._mqtt = SimpleMQTTServer(
-            serial=self.printer_serial,
-            access_code=self._access_code,
-            cert_path=cert_path,
-            key_path=key_path,
-            on_print_command=self._on_print_command,
-        )
-
-        # Bind server responds to slicer detect/bind requests on port 3000
-        self._bind = BindServer(
-            serial=self.printer_serial,
-            model=self._model,
-            name=self.PRINTER_NAME,
-        )
-
-        # Start services as background tasks
-        # Wrap each in error handler so one failure doesn't stop others
-        async def run_with_logging(coro, name):
-            try:
-                await coro
-            except Exception as e:
-                logger.error("Virtual printer %s failed: %s", name, e)
+    # -- File handling callbacks --
 
-        self._tasks = [
-            asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
-            asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
-            asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
-            asyncio.create_task(run_with_logging(self._bind.start(), "Bind"), name="virtual_printer_bind"),
-        ]
+    async def on_file_received(self, file_path: Path, source_ip: str) -> None:
+        """Handle file upload completion from FTP."""
+        logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
 
-        logger.info("Virtual printer '%s' started (serial: %s)", self.PRINTER_NAME, self.printer_serial)
-
-    def _on_proxy_activity(self, name: str, message: str) -> None:
-        """Handle proxy activity for logging."""
-        logger.info("Proxy %s: %s", name, message)
-
-    async def _stop(self) -> None:
-        """Stop all virtual printer services."""
-        logger.info("Stopping virtual printer services...")
-
-        # Stop services first - this closes servers and cancels active sessions
-        if self._ftp:
-            await self._ftp.stop()
-            self._ftp = None
-
-        if self._mqtt:
-            await self._mqtt.stop()
-            self._mqtt = None
-
-        if self._ssdp:
-            await self._ssdp.stop()
-            self._ssdp = None
-
-        if self._bind:
-            await self._bind.stop()
-            self._bind = None
-
-        if self._ssdp_proxy:
-            await self._ssdp_proxy.stop()
-            self._ssdp_proxy = None
-
-        if self._proxy:
-            await self._proxy.stop()
-            self._proxy = None
-
-        # Cancel remaining tasks with short timeout
-        for task in self._tasks:
-            task.cancel()
-
-        if self._tasks:
-            try:
-                await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
-            except TimeoutError:
-                logger.debug("Some tasks didn't stop in time")
-
-        self._tasks = []
-
-        logger.info("Virtual printer stopped")
-
-    async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
-        """Handle file upload completion from FTP.
-
-        Args:
-            file_path: Path to uploaded file
-            source_ip: IP address of the uploading slicer
-        """
-        logger.info("Virtual printer received file: %s from %s", file_path.name, source_ip)
-
-        # Store file reference for MQTT correlation
         self._pending_files[file_path.name] = file_path
 
-        # Handle based on mode:
-        # - immediate: archive right away
-        # - review: create pending upload record for user review before archiving
-        # - print_queue: archive and add to print queue (unassigned)
-        if self._mode == "immediate":
+        if self.mode == "immediate":
             await self._archive_file(file_path, source_ip)
-        elif self._mode == "print_queue":
+        elif self.mode == "print_queue":
             await self._add_to_print_queue(file_path, source_ip)
         else:
-            # "review" mode (or legacy "queue" mode)
             await self._queue_file(file_path, source_ip)
 
-        # Reset MQTT status back to IDLE after file processing
-        # This tells the slicer the printer is done with the file
+        # Reset MQTT status back to IDLE
         if self._mqtt and file_path.suffix.lower() == ".3mf":
             self._mqtt.set_gcode_state("IDLE")
 
-    async def _on_print_command(self, filename: str, data: dict) -> None:
-        """Handle print command from MQTT.
-
-        In a real printer, this would start the print. For virtual printer,
-        we just log it since archiving is handled by file upload.
-
-        Args:
-            filename: Name of the file to print
-            data: Print command data (contains settings like timelapse, bed_leveling, etc.)
-        """
-        logger.info("Virtual printer received print command for: %s", filename)
-        logger.debug("Print command data: %s", data)
-
-        # The file should already be archived from FTP upload
-        # This command just confirms the slicer's intent to "print"
+    async def on_print_command(self, filename: str, data: dict) -> None:
+        """Handle print command from MQTT."""
+        logger.info("[VP %s] Print command for: %s", self.name, filename)
 
     async def _archive_file(self, file_path: Path, source_ip: str) -> None:
-        """Archive file immediately.
-
-        Args:
-            file_path: Path to the 3MF file
-            source_ip: IP address of uploader
-        """
+        """Archive file immediately."""
         if not self._session_factory:
             logger.error("Cannot archive: no database session factory configured")
             return
 
-        # Only archive 3MF files
         if file_path.suffix.lower() != ".3mf":
             logger.debug("Skipping non-3MF file: %s", file_path.name)
-            # Remove from pending and clean up
             self._pending_files.pop(file_path.name, None)
             try:
                 file_path.unlink()
             except OSError:
-                pass  # Best-effort removal of non-3MF file; may already be gone
+                pass
             return
 
         try:
@@ -576,10 +221,8 @@ class VirtualPrinterManager:
 
             async with self._session_factory() as db:
                 service = ArchiveService(db)
-
-                # Archive the print
                 archive = await service.archive_print(
-                    printer_id=None,  # No physical printer
+                    printer_id=None,
                     source_file=file_path,
                     print_data={
                         "status": "archived",
@@ -587,42 +230,30 @@ class VirtualPrinterManager:
                         "source_ip": source_ip,
                     },
                 )
-
                 if archive:
-                    logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
-
-                    # Clean up uploaded file (it's now copied to archive)
+                    logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     try:
                         file_path.unlink()
                     except OSError:
-                        pass  # Best-effort cleanup of uploaded file after archiving
-                    # Remove from pending
+                        pass
                     self._pending_files.pop(file_path.name, None)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
-
-        except Exception as e:  # Mixed async DB + archive operations
+        except Exception as e:
             logger.error("Error archiving file: %s", e)
 
     async def _queue_file(self, file_path: Path, source_ip: str) -> None:
-        """Queue file for user review.
-
-        Args:
-            file_path: Path to the 3MF file
-            source_ip: IP address of uploader
-        """
+        """Queue file for user review."""
         if not self._session_factory:
             logger.error("Cannot queue: no database session factory configured")
             return
 
-        # Only queue 3MF files
         if file_path.suffix.lower() != ".3mf":
-            logger.debug("Skipping non-3MF file: %s", file_path.name)
             self._pending_files.pop(file_path.name, None)
             try:
                 file_path.unlink()
             except OSError:
-                pass  # Best-effort removal of non-3MF file; may already be gone
+                pass
             return
 
         try:
@@ -639,34 +270,23 @@ class VirtualPrinterManager:
                 )
                 db.add(pending)
                 await db.commit()
-
-                logger.info("Queued virtual printer upload: %s - %s", pending.id, file_path.name)
-
-                # Remove from pending files dict
+                logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
                 self._pending_files.pop(file_path.name, None)
-
         except Exception as e:
             logger.error("Error queueing file: %s", e)
 
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
-        """Archive file and add to print queue (unassigned).
-
-        Args:
-            file_path: Path to the 3MF file
-            source_ip: IP address of uploader
-        """
+        """Archive file and add to print queue, assigned to target printer or model."""
         if not self._session_factory:
             logger.error("Cannot add to print queue: no database session factory configured")
             return
 
-        # Only process 3MF files
         if file_path.suffix.lower() != ".3mf":
-            logger.debug("Skipping non-3MF file: %s", file_path.name)
             self._pending_files.pop(file_path.name, None)
             try:
                 file_path.unlink()
             except OSError:
-                pass  # Best-effort removal of non-3MF file; may already be gone
+                pass
             return
 
         try:
@@ -675,10 +295,8 @@ class VirtualPrinterManager:
 
             async with self._session_factory() as db:
                 service = ArchiveService(db)
-
-                # First, archive the print
                 archive = await service.archive_print(
-                    printer_id=None,  # No physical printer
+                    printer_id=None,
                     source_file=file_path,
                     print_data={
                         "status": "archived",
@@ -686,62 +304,453 @@ class VirtualPrinterManager:
                         "source_ip": source_ip,
                     },
                 )
-
                 if archive:
-                    logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
-
-                    # Now add to print queue (unassigned)
+                    logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
+                    # Assign to specific printer if configured, otherwise use model for "Any X" scheduling
+                    target_model = None
+                    if not self.target_printer_id and self.model:
+                        target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
+                    plate_id = self._extract_plate_id(file_path)
                     queue_item = PrintQueueItem(
-                        printer_id=None,  # Unassigned - user will assign later
+                        printer_id=self.target_printer_id,
+                        target_model=target_model,
                         archive_id=archive.id,
-                        position=1,  # Will be adjusted when assigned to a printer
+                        plate_id=plate_id,
+                        position=1,
                         status="pending",
                     )
                     db.add(queue_item)
                     await db.commit()
-
-                    logger.info(
-                        "Added to print queue (unassigned): queue_id=%s, archive_id=%s", queue_item.id, archive.id
-                    )
-
-                    # Clean up uploaded file (it's now copied to archive)
+                    logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
                     try:
                         file_path.unlink()
                     except OSError:
-                        pass  # Best-effort cleanup of uploaded file after archiving and queuing
-                    # Remove from pending
+                        pass
                     self._pending_files.pop(file_path.name, None)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
-
-        except Exception as e:  # Mixed async DB + archive + queue operations
+        except Exception as e:
             logger.error("Error adding to print queue: %s", e)
 
-    def get_status(self) -> dict:
-        """Get virtual printer status.
+    @staticmethod
+    def _extract_plate_id(file_path: Path) -> int | None:
+        """Extract plate index from 3MF slice_info.config."""
+        try:
+            import xml.etree.ElementTree as ET
+            import zipfile
+
+            with zipfile.ZipFile(file_path, "r") as zf:
+                if "Metadata/slice_info.config" in zf.namelist():
+                    content = zf.read("Metadata/slice_info.config").decode()
+                    root = ET.fromstring(content)  # noqa: S314  # nosec B314
+                    plate = root.find(".//plate")
+                    if plate is not None:
+                        for meta in plate.findall("metadata"):
+                            if meta.get("key") == "index" and meta.get("value"):
+                                return int(meta.get("value"))
+        except Exception:
+            return None
+        return None
+
+    # -- Service lifecycle --
+
+    async def start_server(self) -> None:
+        """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
+        logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
+
+        cert_path, key_path = self.generate_certificates()
+        bind_addr = self.bind_ip or "0.0.0.0"  # nosec B104
+
+        async def run_with_logging(coro, svc_name):
+            try:
+                await coro
+            except Exception as e:
+                logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
+
+        self._tasks = []
+
+        # FTP server
+        self._ftp = VirtualPrinterFTPServer(
+            upload_dir=self.upload_dir,
+            access_code=self.access_code,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_file_received=self.on_file_received,
+            bind_address=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ftp.start(), "FTP"),
+                name=f"vp_{self.id}_ftp",
+            )
+        )
+
+        # MQTT server
+        self._mqtt = SimpleMQTTServer(
+            serial=self.serial,
+            access_code=self.access_code,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_print_command=self.on_print_command,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            bind_address=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._mqtt.start(), "MQTT"),
+                name=f"vp_{self.id}_mqtt",
+            )
+        )
+
+        # Bind server
+        self._bind = BindServer(
+            serial=self.serial,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            name=self.name,
+            bind_address=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._bind.start(), "Bind"),
+                name=f"vp_{self.id}_bind",
+            )
+        )
+
+        # SSDP server
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=self.name,
+            serial=self.serial,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            advertise_ip=self.remote_interface_ip or self.bind_ip or "",
+            bind_ip=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name=f"vp_{self.id}_ssdp",
+            )
+        )
+
+        logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
+
+    async def stop_server(self) -> None:
+        """Stop server-mode services."""
+        if self._ftp:
+            await self._ftp.stop()
+            self._ftp = None
+        if self._mqtt:
+            await self._mqtt.stop()
+            self._mqtt = None
+        if self._bind:
+            await self._bind.stop()
+            self._bind = None
+        if self._ssdp:
+            await self._ssdp.stop()
+            self._ssdp = None
+        await self._cancel_tasks()
+
+    async def start_proxy(self) -> None:
+        """Start proxy mode services for this instance."""
+        logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
+
+        cert_path, key_path = self.generate_certificates()
+
+        self._proxy = SlicerProxyManager(
+            target_host=self.target_printer_ip,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
+            bind_address=self.bind_ip or "0.0.0.0",  # nosec B104
+        )
+
+        async def run_with_logging(coro, svc_name):
+            try:
+                await coro
+            except Exception as e:
+                logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
+
+        self._tasks = []
+
+        # SSDP for proxy
+        proxy_serial = self.target_printer_serial or self.serial
+        if self.remote_interface_ip:
+            from backend.app.services.network_utils import find_interface_for_ip
+
+            local_iface = find_interface_for_ip(self.target_printer_ip)
+            if local_iface:
+                self._ssdp_proxy = SSDPProxy(
+                    local_interface_ip=local_iface["ip"],
+                    remote_interface_ip=self.remote_interface_ip,
+                    target_printer_ip=self.target_printer_ip,
+                )
+                self._tasks.append(
+                    asyncio.create_task(
+                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
+                        name=f"vp_{self.id}_ssdp_proxy",
+                    )
+                )
+            else:
+                self._start_fallback_ssdp(proxy_serial, run_with_logging)
+        else:
+            self._start_fallback_ssdp(proxy_serial, run_with_logging)
+
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._proxy.start(), "Proxy"),
+                name=f"vp_{self.id}_proxy",
+            )
+        )
+
+    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
+        """Start single-interface SSDP server as fallback for proxy mode."""
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.name} (Proxy)",
+            serial=proxy_serial,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            advertise_ip=self.bind_ip or "",
+            bind_ip=self.bind_ip or "",
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name=f"vp_{self.id}_ssdp",
+            )
+        )
+
+    async def stop_proxy(self) -> None:
+        """Stop proxy mode services for this instance."""
+        if self._proxy:
+            await self._proxy.stop()
+            self._proxy = None
+        if self._ssdp:
+            await self._ssdp.stop()
+            self._ssdp = None
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+        await self._cancel_tasks()
+
+    async def _cancel_tasks(self) -> None:
+        """Cancel all running tasks and wait for cleanup."""
+        for task in self._tasks:
+            task.cancel()
+        if self._tasks:
+            try:
+                await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
+            except TimeoutError:
+                pass
+        self._tasks = []
 
-        Returns:
-            Status dictionary with enabled, running, mode, etc.
-        """
-        status = {
-            "enabled": self._enabled,
+    def get_status(self) -> dict:
+        """Get status for this instance."""
+        status: dict = {
             "running": self.is_running,
-            "mode": self._mode,
-            "name": self.PRINTER_NAME,
-            "serial": self.printer_serial,
-            "model": self._model,
-            "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
             "pending_files": len(self._pending_files),
         }
+        if self.is_proxy and self._proxy:
+            status["proxy"] = self._proxy.get_status()
+        return status
 
-        # Add proxy-specific status
-        if self._mode == "proxy":
-            status["target_printer_ip"] = self._target_printer_ip
-            if self._proxy:
-                proxy_status = self._proxy.get_status()
-                status["proxy"] = proxy_status
 
-        return status
+class VirtualPrinterManager:
+    """Multi-instance virtual printer registry and orchestrator.
+
+    Every VP runs its own independent services on a dedicated bind IP.
+    """
+
+    def __init__(self):
+        self._session_factory: Callable | None = None
+        self._instances: dict[int, VirtualPrinterInstance] = {}
+
+        # Directories
+        self._base_dir = app_settings.base_dir / "virtual_printer"
+
+        # Ensure base directories exist
+        self._ensure_base_directories()
+
+    def _ensure_base_directories(self) -> None:
+        """Create base directories at startup."""
+        for dir_path in [self._base_dir, self._base_dir / "uploads", self._base_dir / "certs"]:
+            try:
+                dir_path.mkdir(parents=True, exist_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Cannot create directory {dir_path}: Permission denied. "
+                    f"For Docker: ensure the data volume is writable by the container user. "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+
+    def set_session_factory(self, session_factory: Callable) -> None:
+        """Set the database session factory."""
+        self._session_factory = session_factory
+
+    @property
+    def is_enabled(self) -> bool:
+        """Check if any virtual printer is running."""
+        return len(self._instances) > 0
+
+    async def sync_from_db(self) -> None:
+        """Load all VPs from DB, reconcile running state."""
+        if not self._session_factory:
+            logger.warning("Cannot sync virtual printers: no session factory")
+            return
+
+        from sqlalchemy import select
+
+        from backend.app.models.printer import Printer
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        async with self._session_factory() as db:
+            result = await db.execute(
+                select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position)  # noqa: E712
+            )
+            enabled_vps = result.scalars().all()
+
+        # Stop instances that are no longer enabled or changed mode
+        enabled_ids = {vp.id for vp in enabled_vps}
+        for vp_id in list(self._instances.keys()):
+            if vp_id not in enabled_ids:
+                await self.remove_instance(vp_id)
+
+        # Look up printer IPs for proxy VPs
+        proxy_vps = [vp for vp in enabled_vps if vp.mode == "proxy"]
+        proxy_ips: dict[int, tuple[str, str]] = {}
+        if proxy_vps:
+            async with self._session_factory() as db:
+                for pvp in proxy_vps:
+                    if pvp.target_printer_id:
+                        result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))
+                        printer = result.scalar_one_or_none()
+                        if printer:
+                            proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
+
+        # Start instances for all enabled VPs (skip already running)
+        for vp in enabled_vps:
+            if vp.id in self._instances:
+                continue
+
+            if vp.mode == "proxy":
+                ip_info = proxy_ips.get(vp.id)
+                if not ip_info:
+                    logger.warning("Proxy VP %s: target printer not found, skipping", vp.name)
+                    continue
+                target_ip, target_serial = ip_info
+                instance = VirtualPrinterInstance(
+                    vp_id=vp.id,
+                    name=vp.name,
+                    mode=vp.mode,
+                    model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                    access_code=vp.access_code or "",
+                    serial_suffix=vp.serial_suffix,
+                    target_printer_ip=target_ip,
+                    target_printer_serial=target_serial,
+                    bind_ip=vp.bind_ip or "",
+                    remote_interface_ip=vp.remote_interface_ip or "",
+                    base_dir=self._base_dir,
+                    session_factory=self._session_factory,
+                )
+                self._instances[vp.id] = instance
+                await instance.start_proxy()
+                logger.info("Started proxy VP: %s → %s", instance.name, target_ip)
+            else:
+                instance = VirtualPrinterInstance(
+                    vp_id=vp.id,
+                    name=vp.name,
+                    mode=vp.mode,
+                    model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                    access_code=vp.access_code or "",
+                    serial_suffix=vp.serial_suffix,
+                    target_printer_id=vp.target_printer_id,
+                    bind_ip=vp.bind_ip or "",
+                    remote_interface_ip=vp.remote_interface_ip or "",
+                    base_dir=self._base_dir,
+                    session_factory=self._session_factory,
+                )
+                self._instances[vp.id] = instance
+                await instance.start_server()
+                logger.info("Started server-mode VP: %s on %s", instance.name, vp.bind_ip)
+
+    async def remove_instance(self, vp_id: int) -> None:
+        """Stop and remove a single VP instance."""
+        instance = self._instances.pop(vp_id, None)
+        if instance:
+            if instance.is_proxy:
+                await instance.stop_proxy()
+            else:
+                await instance.stop_server()
+            logger.info("Removed VP instance: %s", instance.name)
+
+    async def stop_all(self) -> None:
+        """Shutdown all virtual printer services."""
+        logger.info("Stopping all virtual printer services...")
+
+        for vp_id in list(self._instances.keys()):
+            await self.remove_instance(vp_id)
+
+        logger.info("All virtual printer services stopped")
+
+    def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:
+        """Get a running instance by ID."""
+        return self._instances.get(vp_id)
+
+    def get_all_status(self) -> list[dict]:
+        """Get status for all running instances."""
+        return [
+            {
+                "id": inst.id,
+                "name": inst.name,
+                "mode": inst.mode,
+                **inst.get_status(),
+            }
+            for inst in self._instances.values()
+        ]
+
+    # -- Legacy single-printer compat --
+
+    def get_status(self) -> dict:
+        """Get status for first virtual printer (backward compat)."""
+        if self._instances:
+            first = next(iter(self._instances.values()))
+            return {
+                "enabled": True,
+                "running": first.is_running,
+                "mode": first.mode,
+                "name": first.name,
+                "serial": first.serial,
+                "model": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                "model_name": VIRTUAL_PRINTER_MODELS.get(
+                    first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                    first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                ),
+                "pending_files": first.get_status().get("pending_files", 0),
+                **({"target_printer_ip": first.target_printer_ip} if first.is_proxy else {}),
+                **({"proxy": first.get_status().get("proxy", {})} if first.is_proxy else {}),
+            }
+        return {
+            "enabled": False,
+            "running": False,
+            "mode": "immediate",
+            "name": "Bambuddy",
+            "serial": "",
+            "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
+            "model_name": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],
+            "pending_files": 0,
+        }
+
+    async def configure(
+        self,
+        enabled: bool,
+        access_code: str = "",
+        mode: str = "immediate",
+        model: str = "",
+        target_printer_ip: str = "",
+        target_printer_serial: str = "",
+        remote_interface_ip: str = "",
+    ) -> None:
+        """Legacy single-printer configure. Delegates to sync_from_db()."""
+        # This method is kept for backward compat with the settings endpoint.
+        # The actual work is done by sync_from_db() which reads from the DB.
+        await self.sync_from_db()
 
 
 # Global instance

+ 38 - 16
backend/app/services/virtual_printer/mqtt_server.py

@@ -16,6 +16,21 @@ logger = logging.getLogger(__name__)
 # Default MQTT port for Bambu printers (MQTT over TLS)
 MQTT_PORT = 8883
 
+# Model code → product_name for version response (must match what slicer expects)
+MODEL_PRODUCT_NAMES = {
+    "3DPrinter-X1-Carbon": "X1 Carbon",
+    "3DPrinter-X1": "X1",
+    "C13": "X1E",
+    "C11": "P1P",
+    "C12": "P1S",
+    "N7": "P2S",
+    "N2S": "A1",
+    "N1": "A1 mini",
+    "O1D": "H2D",
+    "O1C": "H2C",
+    "O1S": "H2S",
+}
+
 
 class VirtualPrinterMQTTServer:
     """MQTT broker that accepts connections from slicers.
@@ -168,13 +183,17 @@ class SimpleMQTTServer:
         key_path: Path,
         port: int = MQTT_PORT,
         on_print_command: Callable[[str, dict], None] | None = None,
+        model: str = "",
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.serial = serial
         self.access_code = access_code
+        self.model = model
         self.cert_path = cert_path
         self.key_path = key_path
         self.port = port
         self.on_print_command = on_print_command
+        self.bind_address = bind_address
         self._running = False
         self._server = None
         self._clients: dict[str, asyncio.StreamWriter] = {}
@@ -255,7 +274,7 @@ class SimpleMQTTServer:
 
             self._server = await asyncio.start_server(
                 connection_handler,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.port,
                 ssl=ssl_context,
             )
@@ -594,7 +613,7 @@ class SimpleMQTTServer:
                 }
             }
 
-            await self._publish_to_report(writer, status)
+            await self._publish_to_report(writer, status, self.serial)
 
         except OSError as e:
             logger.error("Failed to send status report: %s", e)
@@ -602,6 +621,9 @@ class SimpleMQTTServer:
     async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> 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
+
             # Build version response matching OrcaSlicer expectations
             # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
             version_info = {
@@ -611,55 +633,55 @@ class SimpleMQTTServer:
                     "module": [
                         {
                             "name": "ota",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "01.07.00.00",
                             "sw_new_ver": "",
                             "hw_ver": "OTA",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "esp32",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "01.07.22.25",
                             "sw_new_ver": "",
                             "hw_ver": "AP05",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "rv1126",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "00.00.27.38",
                             "sw_new_ver": "",
                             "hw_ver": "AP05",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "th",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "00.00.04.00",
                             "sw_new_ver": "",
                             "hw_ver": "TH07",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "mc",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "00.00.10.00",
                             "sw_new_ver": "",
                             "hw_ver": "MC07",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                     ],
                 }
             }
 
-            await self._publish_to_report(writer, version_info)
-            logger.info("Sent version response")
+            await self._publish_to_report(writer, version_info, serial)
+            logger.info("Sent version response (product_name=%s)", product_name)
 
         except OSError as e:
             logger.error("Failed to send version response: %s", e)
@@ -673,9 +695,9 @@ class SimpleMQTTServer:
         self._current_file = filename
         self._prepare_percent = prepare_percent
 
-    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None:
+    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = "") -> None:
         """Publish a message on the device report topic."""
-        topic = f"device/{self.serial}/report"
+        topic = f"device/{serial or self.serial}/report"
         message = json.dumps(payload)
 
         topic_bytes = topic.encode("utf-8")

+ 22 - 24
backend/app/services/virtual_printer/ssdp_server.py

@@ -34,21 +34,24 @@ class VirtualPrinterSSDPServer:
         serial: str = "00M09A391800001",  # X1C serial format for compatibility
         model: str = "BL-P001",  # X1C model code for best compatibility
         advertise_ip: str = "",
+        bind_ip: str = "",
     ):
         """Initialize the SSDP server.
 
         Args:
             name: Display name shown in slicer discovery
-            serial: Unique serial number for this virtual printer (must match cert CN)
-            model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
+            serial: Unique serial number
+            model: Model code
             advertise_ip: Override IP to advertise instead of auto-detecting
+            bind_ip: IP address to bind the SSDP socket to
         """
         self.name = name
         self.serial = serial
         self.model = model
+        self._bind_ip = bind_ip
         self._running = False
         self._socket: socket.socket | None = None
-        self._local_ip: str | None = advertise_ip or None
+        self._local_ip: str | None = advertise_ip or bind_ip or None
 
     def _get_local_ip(self) -> str:
         """Get the local IP address to advertise."""
@@ -67,14 +70,8 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
 
     def _build_notify_message(self) -> bytes:
-        """Build SSDP NOTIFY message for periodic announcements.
-
-        Format matches real Bambu printer SSDP broadcasts observed on the network.
-        Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
-        """
+        """Build SSDP NOTIFY message for periodic announcements."""
         ip = self._get_local_ip()
-        # Match exact format of real Bambu printers (captured via tcpdump)
-        # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "NOTIFY * HTTP/1.1\r\n"
             f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
@@ -98,13 +95,8 @@ class VirtualPrinterSSDPServer:
         return message.encode()
 
     def _build_response_message(self) -> bytes:
-        """Build SSDP response message for M-SEARCH requests.
-
-        Format matches real Bambu printer SSDP responses.
-        """
+        """Build SSDP response message for M-SEARCH requests."""
         ip = self._get_local_ip()
-        # Match format of real Bambu printers
-        # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "HTTP/1.1 200 OK\r\n"
             "Server: UPnP/1.0\r\n"
@@ -147,11 +139,18 @@ class VirtualPrinterSSDPServer:
             # Set non-blocking mode
             self._socket.setblocking(False)
 
-            # Bind to SSDP port
-            self._socket.bind(("", SSDP_PORT))
+            # Bind to SSDP port on specific interface (or all interfaces)
+            self._socket.bind((self._bind_ip or "", SSDP_PORT))
 
-            # Join multicast group
-            mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
+            # Join multicast group (on specific interface if bind_ip is set)
+            if self._bind_ip:
+                mreq = struct.pack(
+                    "4s4s",
+                    socket.inet_aton(SSDP_MULTICAST_ADDR),
+                    socket.inet_aton(self._bind_ip),
+                )
+            else:
+                mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
             # Enable broadcast
@@ -226,17 +225,16 @@ class VirtualPrinterSSDPServer:
             self._socket = None
 
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
+        """Send SSDP NOTIFY message via broadcast."""
         if not self._socket:
             return
 
         try:
             msg = self._build_notify_message()
-            # Real Bambu printers broadcast to 255.255.255.255, not multicast
             self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP NOTIFY for %s", self.name)
         except OSError as e:
-            logger.debug("Failed to send NOTIFY: %s", e)
+            logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
 
     async def _send_byebye(self) -> None:
         """Send SSDP byebye message when shutting down."""
@@ -282,7 +280,7 @@ class VirtualPrinterSSDPServer:
                 self._socket.sendto(response, addr)
                 logger.info("Sent SSDP response to %s for virtual printer '%s'", addr[0], self.name)
             except OSError as e:
-                logger.debug("Failed to send SSDP response: %s", e)
+                logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
 
 
 class SSDPProxy:

+ 43 - 26
backend/app/services/virtual_printer/tcp_proxy.py

@@ -81,6 +81,7 @@ class TLSProxy:
         server_key_path: Path,
         on_connect: Callable[[str], None] | None = None,
         on_disconnect: Callable[[str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         """Initialize the TLS proxy.
 
@@ -93,6 +94,7 @@ class TLSProxy:
             server_key_path: Path to server private key
             on_connect: Optional callback when client connects (receives client_id)
             on_disconnect: Optional callback when client disconnects (receives client_id)
+            bind_address: IP address to bind to (default: all interfaces)
         """
         self.name = name
         self.listen_port = listen_port
@@ -102,6 +104,7 @@ class TLSProxy:
         self.server_key_path = server_key_path
         self.on_connect = on_connect
         self.on_disconnect = on_disconnect
+        self.bind_address = bind_address
 
         self._server: asyncio.Server | None = None
         self._running = False
@@ -134,7 +137,7 @@ class TLSProxy:
             return
 
         logger.info(
-            f"Starting {self.name} TLS proxy: 0.0.0.0:{self.listen_port} → {self.target_host}:{self.target_port}"
+            f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
         )
 
         try:
@@ -147,7 +150,7 @@ class TLSProxy:
             # Start server with TLS
             self._server = await asyncio.start_server(
                 self._handle_client,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.listen_port,
                 ssl=self._server_ssl_context,
             )
@@ -343,7 +346,7 @@ class TLSProxy:
 class TCPProxy:
     """Raw TCP proxy that forwards data without TLS termination.
 
-    Used for protocols where the printer doesn't use TLS (e.g., port 3000
+    Used for protocols where the printer doesn't use TLS (e.g., port 3002
     binding/authentication protocol).
     """
 
@@ -355,6 +358,7 @@ class TCPProxy:
         target_port: int,
         on_connect: Callable[[str], None] | None = None,
         on_disconnect: Callable[[str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.name = name
         self.listen_port = listen_port
@@ -362,6 +366,7 @@ class TCPProxy:
         self.target_port = target_port
         self.on_connect = on_connect
         self.on_disconnect = on_disconnect
+        self.bind_address = bind_address
 
         self._server: asyncio.Server | None = None
         self._running = False
@@ -373,8 +378,9 @@ class TCPProxy:
             return
 
         logger.info(
-            "Starting %s TCP proxy: 0.0.0.0:%s → %s:%s",
+            "Starting %s TCP proxy: %s:%s → %s:%s",
             self.name,
+            self.bind_address,
             self.listen_port,
             self.target_host,
             self.target_port,
@@ -385,7 +391,7 @@ class TCPProxy:
 
             self._server = await asyncio.start_server(
                 self._handle_client,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.listen_port,
             )
 
@@ -1039,13 +1045,12 @@ class SlicerProxyManager:
     # Bambu printer ports
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
-    PRINTER_BIND_PORT = 3000
+    PRINTER_BIND_PORTS = [3000, 3002]
 
     # Local listen ports - must match what Bambu Studio expects
     # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
     LOCAL_FTP_PORT = 990
     LOCAL_MQTT_PORT = 8883
-    LOCAL_BIND_PORT = 3000
 
     def __init__(
         self,
@@ -1053,6 +1058,7 @@ class SlicerProxyManager:
         cert_path: Path,
         key_path: Path,
         on_activity: Callable[[str, str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         """Initialize the slicer proxy manager.
 
@@ -1061,15 +1067,17 @@ class SlicerProxyManager:
             cert_path: Path to server certificate
             key_path: Path to server private key
             on_activity: Optional callback for activity logging (name, message)
+            bind_address: IP address to bind proxy listeners to
         """
         self.target_host = target_host
         self.cert_path = cert_path
         self.key_path = key_path
         self.on_activity = on_activity
+        self.bind_address = bind_address
 
         self._ftp_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
-        self._bind_proxy: TCPProxy | None = None
+        self._bind_proxies: list[TCPProxy] = []
         self._tasks: list[asyncio.Task] = []
 
     async def start(self) -> None:
@@ -1100,6 +1108,7 @@ class SlicerProxyManager:
             server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
         )
 
         self._mqtt_proxy = TLSProxy(
@@ -1111,17 +1120,22 @@ class SlicerProxyManager:
             server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
         )
 
-        # Bind/auth proxy (port 3000) - raw TCP, no TLS
-        self._bind_proxy = TCPProxy(
-            name="Bind",
-            listen_port=self.LOCAL_BIND_PORT,
-            target_host=self.target_host,
-            target_port=self.PRINTER_BIND_PORT,
-            on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-            on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-        )
+        # Bind/auth proxy (ports 3000 + 3002) - raw TCP, no TLS
+        # Different BambuStudio versions use different ports
+        for bind_port in self.PRINTER_BIND_PORTS:
+            proxy = TCPProxy(
+                name="Bind",
+                listen_port=bind_port,
+                target_host=self.target_host,
+                target_port=bind_port,
+                on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                bind_address=self.bind_address,
+            )
+            self._bind_proxies.append(proxy)
 
         # Start as background tasks
         async def run_with_logging(proxy: TLSProxy) -> None:
@@ -1139,11 +1153,14 @@ class SlicerProxyManager:
                 run_with_logging(self._mqtt_proxy),
                 name="slicer_proxy_mqtt",
             ),
-            asyncio.create_task(
-                run_with_logging(self._bind_proxy),
-                name="slicer_proxy_bind",
-            ),
         ]
+        for bp in self._bind_proxies:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(bp),
+                    name=f"slicer_proxy_bind_{bp.listen_port}",
+                )
+            )
 
         logger.info("Slicer TLS proxy started for %s", self.target_host)
 
@@ -1167,9 +1184,9 @@ class SlicerProxyManager:
             await self._mqtt_proxy.stop()
             self._mqtt_proxy = None
 
-        if self._bind_proxy:
-            await self._bind_proxy.stop()
-            self._bind_proxy = None
+        for bp in self._bind_proxies:
+            await bp.stop()
+        self._bind_proxies = []
 
         # Cancel tasks
         for task in self._tasks:
@@ -1207,8 +1224,8 @@ class SlicerProxyManager:
             "target_host": self.target_host,
             "ftp_port": self.LOCAL_FTP_PORT,
             "mqtt_port": self.LOCAL_MQTT_PORT,
-            "bind_port": self.LOCAL_BIND_PORT,
+            "bind_ports": self.PRINTER_BIND_PORTS,
             "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
             "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
-            "bind_connections": (len(self._bind_proxy._active_connections) if self._bind_proxy else 0),
+            "bind_connections": sum(len(bp._active_connections) for bp in self._bind_proxies),
         }

+ 40 - 22
backend/app/utils/threemf_tools.py

@@ -265,15 +265,18 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
 
 
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
-    """Extract per-slot nozzle/extruder mapping from a 3MF file's project settings.
+    """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
     On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
-    specific nozzle. This reads the slicer's nozzle assignment from
-    Metadata/project_settings.config.
+    specific nozzle. The slicer may override user preferences when using "Auto For
+    Flush" mode, so the actual assignment comes from slice_info.config group_id
+    attributes, not from the user's filament_nozzle_map preference.
 
-    Translation chain:
-        filament_nozzle_map[slot_id - 1] -> slicer extruder index
-        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+    Priority:
+        1. group_id on <filament> elements in slice_info.config (actual assignment)
+        2. filament_nozzle_map in project_settings.config (user preference fallback)
+
+    Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
 
     Args:
         zf: An open ZipFile of the 3MF archive
@@ -289,33 +292,48 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         content = zf.read("Metadata/project_settings.config").decode()
         data = json.loads(content)
 
-        filament_nozzle_map = data.get("filament_nozzle_map")
         physical_extruder_map = data.get("physical_extruder_map")
+        if not physical_extruder_map or len(physical_extruder_map) <= 1:
+            return None  # Single-nozzle printer
 
-        if not filament_nozzle_map or not physical_extruder_map:
+        # 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] = {}
+        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"):
+                group_id_str = filament_elem.get("group_id")
+                filament_id_str = filament_elem.get("id")
+                if group_id_str is not None and filament_id_str:
+                    try:
+                        group_id = int(group_id_str)
+                        slot_id = int(filament_id_str)
+                        if group_id < len(physical_extruder_map):
+                            nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
+                    except (ValueError, TypeError, IndexError):
+                        pass
+
+        if nozzle_mapping:
+            return nozzle_mapping
+
+        # Priority 2: Fall back to filament_nozzle_map (user preference).
+        # This is correct when the user manually assigned nozzles, but may be
+        # wrong when the slicer overrides via "Auto For Flush".
+        filament_nozzle_map = data.get("filament_nozzle_map")
+        if not filament_nozzle_map:
             return None
 
-        # Build slot_id (1-based) -> extruder_id mapping
-        nozzle_mapping: dict[int, int] = {}
         for i, slicer_ext_str in enumerate(filament_nozzle_map):
             slot_id = i + 1
             try:
                 slicer_ext = int(slicer_ext_str)
                 if slicer_ext < len(physical_extruder_map):
-                    extruder_id = int(physical_extruder_map[slicer_ext])
-                    nozzle_mapping[slot_id] = extruder_id
+                    nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
             except (ValueError, TypeError, IndexError):
-                pass  # Skip slots with unparseable nozzle mapping
-
-        if not nozzle_mapping:
-            return None
-
-        # If all slots map to the same extruder, this is a single-nozzle printer
-        unique_extruders = set(nozzle_mapping.values())
-        if len(unique_extruders) <= 1:
-            return None
+                pass
 
-        return nozzle_mapping
+        return nozzle_mapping if nozzle_mapping else None
     except Exception:
         return None
 

+ 16 - 0
backend/tests/conftest.py

@@ -50,6 +50,8 @@ def event_loop():
     """Create an instance of the default event loop for each test session."""
     loop = asyncio.get_event_loop_policy().new_event_loop()
     yield loop
+    # Drain pending callbacks so aiosqlite threads can finish before loop closes
+    loop.run_until_complete(asyncio.sleep(0.1))
     loop.close()
 
 
@@ -75,6 +77,9 @@ async def test_engine():
         project,
         settings,
         smart_plug,
+        spool,
+        spool_assignment,
+        spool_usage_history,
         user,
     )
 
@@ -86,6 +91,10 @@ async def test_engine():
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.drop_all)
     await engine.dispose()
+    # Allow aiosqlite's background thread to finish processing the close
+    # response before the per-function event loop shuts down, preventing
+    # "RuntimeError: Event loop is closed" in call_soon_threadsafe.
+    await asyncio.sleep(0.1)
 
 
 @pytest.fixture
@@ -130,6 +139,13 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
 
+        # The app lifespan called init_db() which used the module-level engine
+        # (not the test engine), creating aiosqlite connections. Dispose those
+        # connections so their background threads finish before the event loop closes.
+        from backend.app.core.database import engine as real_engine
+
+        await real_engine.dispose()
+
     app.dependency_overrides.clear()
 
 

+ 243 - 0
backend/tests/integration/test_background_dispatch_api.py

@@ -0,0 +1,243 @@
+"""Integration tests for background dispatch API behavior."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.background_dispatch import DispatchEnqueueRejected
+
+
+class TestBackgroundDispatchArchivesAPI:
+    """Tests for archive reprint dispatch endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reprint_returns_dispatched_payload(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
+    ):
+        """Reprint endpoint returns background dispatch metadata."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename="widget.gcode.3mf",
+            file_path="archives/test/widget.gcode.3mf",
+        )
+
+        archive_file = tmp_path / archive.file_path
+        archive_file.parent.mkdir(parents=True, exist_ok=True)
+        archive_file.write_bytes(b"3mf-data")
+
+        with (
+            patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
+                new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
+            ) as mock_dispatch,
+        ):
+            response = await async_client.post(
+                f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
+                json={"plate_id": 2},
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["status"] == "dispatched"
+        assert data["dispatch_job_id"] == 15
+        assert data["dispatch_position"] == 1
+        assert data["filename"] == "widget.gcode.3mf"
+
+        mock_dispatch.assert_awaited_once()
+        kwargs = mock_dispatch.await_args.kwargs
+        assert kwargs["archive_name"].endswith("• Plate 2")
+        assert kwargs["options"]["plate_id"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reprint_returns_409_when_enqueue_rejected(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
+    ):
+        """Reprint endpoint maps enqueue rejection to HTTP 409."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename="widget2.gcode.3mf",
+            file_path="archives/test/widget2.gcode.3mf",
+        )
+
+        archive_file = tmp_path / archive.file_path
+        archive_file.parent.mkdir(parents=True, exist_ok=True)
+        archive_file.write_bytes(b"3mf-data")
+
+        with (
+            patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
+                new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
+            ),
+        ):
+            response = await async_client.post(
+                f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
+                json={"plate_id": 1},
+            )
+
+        assert response.status_code == 409
+        assert "already has a background dispatch" in response.json()["detail"]
+
+
+class TestBackgroundDispatchLibraryAPI:
+    """Tests for library print dispatch endpoint."""
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        """Factory to create library files."""
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            defaults = {
+                "filename": "library_part.gcode.3mf",
+                "file_path": "library/files/library_part.gcode.3mf",
+                "file_type": "gcode",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_print_returns_dispatched_payload(
+        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
+    ):
+        """Library print endpoint returns dispatch job metadata."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        disk_path = tmp_path / lib_file.file_path
+        disk_path.parent.mkdir(parents=True, exist_ok=True)
+        disk_path.write_bytes(b"library data")
+
+        with (
+            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
+                new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
+            ) as mock_dispatch,
+        ):
+            response = await async_client.post(
+                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
+                json={"plate_id": 4},
+            )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["status"] == "dispatched"
+        assert data["dispatch_job_id"] == 21
+        assert data["dispatch_position"] == 2
+        assert data["archive_id"] is None
+
+        mock_dispatch.assert_awaited_once()
+        kwargs = mock_dispatch.await_args.kwargs
+        assert kwargs["filename"].endswith("• Plate 4")
+        assert kwargs["options"]["plate_id"] == 4
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_print_returns_409_when_enqueue_rejected(
+        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
+    ):
+        """Library print endpoint maps enqueue rejection to HTTP 409."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory(filename="another_part.gcode")
+
+        disk_path = tmp_path / lib_file.file_path
+        disk_path.parent.mkdir(parents=True, exist_ok=True)
+        disk_path.write_bytes(b"library data")
+
+        with (
+            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
+                new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
+            ),
+        ):
+            response = await async_client.post(
+                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
+                json={"plate_id": 1},
+            )
+
+        assert response.status_code == 409
+        assert "queue conflict" in response.json()["detail"]
+
+
+class TestBackgroundDispatchCancelAPI:
+    """Tests for /background-dispatch cancel endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
+        """Cancel endpoint returns cancelled for queued job."""
+        with patch(
+            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
+            new=AsyncMock(
+                return_value={
+                    "cancelled": True,
+                    "pending": False,
+                    "job_id": 9,
+                    "source_name": "cube.gcode.3mf",
+                    "printer_id": 1,
+                    "printer_name": "Printer A",
+                }
+            ),
+        ):
+            response = await async_client.delete("/api/v1/background-dispatch/9")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["status"] == "cancelled"
+        assert data["job_id"] == 9
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
+        """Cancel endpoint returns cancelling while active upload is being interrupted."""
+        with patch(
+            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
+            new=AsyncMock(
+                return_value={
+                    "cancelled": True,
+                    "pending": True,
+                    "job_id": 10,
+                    "source_name": "cube.gcode.3mf",
+                    "printer_id": 1,
+                    "printer_name": "Printer A",
+                }
+            ),
+        ):
+            response = await async_client.delete("/api/v1/background-dispatch/10")
+
+        assert response.status_code == 200
+        assert response.json()["status"] == "cancelling"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
+        """Cancel endpoint returns 404 for unknown job id."""
+        with patch(
+            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
+            new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
+        ):
+            response = await async_client.delete("/api/v1/background-dispatch/999")
+
+        assert response.status_code == 404
+        assert response.json()["detail"] == "Dispatch job not found"

+ 412 - 0
backend/tests/integration/test_cost_statistics.py

@@ -0,0 +1,412 @@
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+
+@pytest.fixture(autouse=True)
+def cleanup_test_archive_files():
+    yield
+    import glob
+    import os
+
+    # Remove any test archive files created in archives/test/
+    for f in glob.glob("archives/test/test_print*.3mf"):
+        try:
+            os.remove(f)
+        except Exception:
+            pass
+
+
+"""Integration tests for cost tracking in archives and statistics.
+
+Tests the full flow of cost tracking from usage to statistics:
+- Archive cost field populated correctly
+- Statistics endpoint aggregates costs
+- Completed vs failed prints cost handling
+"""
+
+
+class TestArchiveCostTracking:
+    """Tests for cost field in PrintArchive."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_has_cost_field(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        # Verify PrintArchive includes cost field in response.
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Test Archive",
+            status="completed",
+            cost=5.50,  # Set a cost
+        )
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "cost" in result
+        assert result["cost"] == 5.50
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_cost_null_when_not_set(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        # Verify cost is null when not set.
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Test Archive",
+            status="completed",
+            # cost not set
+        )
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["cost"] is None or result["cost"] == 0
+        await db_session.rollback()
+
+
+class TestStatisticsCostAggregation:
+    """Tests for cost aggregation in statistics endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_statistics_includes_total_cost(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        # Verify statistics endpoint includes total_cost field.
+        printer = await printer_factory()
+
+        # Create archives with costs
+        await archive_factory(
+            printer.id,
+            status="completed",
+            cost=2.50,
+            filament_used_grams=100.0,
+        )
+        await archive_factory(
+            printer.id,
+            status="completed",
+            cost=3.75,
+            filament_used_grams=150.0,
+        )
+
+        response = await async_client.get("/api/v1/archives/stats")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "total_cost" in result
+        assert result["total_cost"] == 6.25
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_statistics_aggregates_costs_correctly(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        # Verify statistics correctly sums costs from all archives.
+        printer = await printer_factory()
+
+        # Create multiple archives with different costs
+        costs = [1.25, 2.50, 0.75, 5.00, 0.50]
+        for cost in costs:
+            await archive_factory(
+                printer.id,
+                status="completed",
+                cost=cost,
+                filament_used_grams=50.0,
+            )
+
+        response = await async_client.get("/api/v1/archives/stats")
+
+        assert response.status_code == 200
+        result = response.json()
+        expected_total = sum(costs)
+        assert result["total_cost"] == expected_total
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_statistics_handles_null_costs(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        # Verify statistics handles archives with null costs gracefully.
+        printer = await printer_factory()
+
+        # Mix of archives with and without costs
+        await archive_factory(printer.id, status="completed", cost=2.50)
+        await archive_factory(printer.id, status="completed", cost=None)
+        await archive_factory(printer.id, status="completed", cost=1.75)
+        await archive_factory(printer.id, status="completed")  # No cost field
+
+        response = await async_client.get("/api/v1/archives/stats")
+
+        assert response.status_code == 200
+        result = response.json()
+        # Should sum only non-null costs
+        assert result["total_cost"] == 4.25
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_statistics_includes_failed_print_costs(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        # Verify failed prints with costs are included in statistics.
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, status="completed", cost=5.00)
+        await archive_factory(printer.id, status="failed", cost=2.50)  # Failed but has cost
+        await archive_factory(printer.id, status="cancelled", cost=1.00)
+
+        response = await async_client.get("/api/v1/archives/stats")
+
+        assert response.status_code == 200
+        result = response.json()
+        # All prints should contribute to total cost
+        assert result["total_cost"] == 8.50
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_statistics_zero_cost_when_no_archives(self, async_client: AsyncClient):
+        """Verify total_cost is 0 when no archives exist."""
+        response = await async_client.get("/api/v1/archives/stats")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["total_cost"] == 0.0
+
+
+class TestSpoolCostPersistence:
+    """Tests for spool cost_per_kg field."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spool_cost_fields_persist(self, async_client: AsyncClient, db_session):
+        # Verify cost_per_kg is saved and retrieved.
+        # Create a spool with cost
+        spool_data = {
+            "material": "PLA",
+            "brand": "TestBrand",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "cost_per_kg": 25.50,
+        }
+
+        create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
+        assert create_response.status_code == 200
+        spool_id = create_response.json()["id"]
+
+        # Retrieve and verify
+        get_response = await async_client.get(f"/api/v1/inventory/spools/{spool_id}")
+        assert get_response.status_code == 200
+        result = get_response.json()
+
+        assert result["cost_per_kg"] == 25.50
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spool_update_cost_fields(self, async_client: AsyncClient, db_session):
+        # Verify cost fields can be updated.
+        # Create spool without cost
+        spool_data = {
+            "material": "PETG",
+            "brand": "TestBrand",
+            "label_weight": 1000,
+            "core_weight": 250,
+        }
+
+        create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
+        assert create_response.status_code == 200
+        spool_id = create_response.json()["id"]
+
+        # Update with cost
+        update_data = {
+            "cost_per_kg": 30.00,
+        }
+
+        update_response = await async_client.patch(f"/api/v1/inventory/spools/{spool_id}", json=update_data)
+        assert update_response.status_code == 200
+
+        result = update_response.json()
+        assert result["cost_per_kg"] == 30.00
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spool_cost_null_by_default(self, async_client: AsyncClient, db_session):
+        # Verify cost_per_kg defaults to null when not provided.
+        spool_data = {
+            "material": "ABS",
+            "label_weight": 1000,
+            "core_weight": 250,
+        }
+
+        create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
+        assert create_response.status_code == 200
+
+        result = create_response.json()
+        assert result["cost_per_kg"] is None
+        await db_session.rollback()
+
+
+class TestCostCalculationScenarios:
+    """End-to-end tests for various cost calculation scenarios."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cost_with_multiple_colors(self, async_client: AsyncClient, printer_factory, db_session):
+        # Verify cost tracking works for multi-color prints.
+
+        # Create two spools with different costs
+        spool1_data = {
+            "material": "ABS",
+            "brand": "TestBrand",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "cost_per_kg": 20.00,
+        }
+        spool2_data = {
+            "material": "PLA",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "cost_per_kg": 25.00,
+        }
+
+        spool1_response = await async_client.post("/api/v1/inventory/spools", json=spool1_data)
+        spool2_response = await async_client.post("/api/v1/inventory/spools", json=spool2_data)
+
+        assert spool1_response.status_code == 200
+        assert spool2_response.status_code == 200
+
+        # Verify spools created with correct costs
+        assert spool1_response.json()["cost_per_kg"] == 20.00
+        assert spool2_response.json()["cost_per_kg"] == 25.00
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cost_precision(self, async_client: AsyncClient, db_session):
+        # Verify cost calculations maintain proper precision.
+        # Create spool with specific cost
+        spool_data = {
+            "material": "PLA",
+            "brand": "TestBrand",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "cost_per_kg": 19.99,  # Specific price
+        }
+
+        response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
+        assert response.status_code == 200
+
+        result = response.json()
+        # Verify precision is maintained
+        assert result["cost_per_kg"] == 19.99
+        await db_session.rollback()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_cost_with_archive_id_and_print_name(
+        self, async_client, archive_factory, printer_factory, db_session
+    ):
+        """Test archive cost recalculation using both archive_id and print_name fallback."""
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+        printer = await printer_factory()
+
+        # Create spools and commit
+        spool_new = Spool(
+            material="PLA",
+            brand="BrandA",
+            label_weight=1000,
+            core_weight=250,
+            cost_per_kg=20.0,
+        )
+        spool_old = Spool(
+            material="ABS",
+            brand="BrandB",
+            label_weight=1000,
+            core_weight=250,
+            cost_per_kg=15.0,
+        )
+        db_session.add_all([spool_new, spool_old])
+        await db_session.commit()
+        await db_session.refresh(spool_new)
+        await db_session.refresh(spool_old)
+
+        # Create archive with new SpoolUsageHistory (archive_id set)
+        archive_new = await archive_factory(
+            printer.id,
+            print_name="UniquePrint",
+            status="completed",
+            cost=None,
+        )
+
+        history_new = SpoolUsageHistory(
+            spool_id=spool_new.id,
+            printer_id=printer.id,
+            print_name="UniquePrint",
+            weight_used=20.0,
+            percent_used=20,
+            status="completed",
+            cost=0.50,
+            archive_id=archive_new.id,
+        )
+        db_session.add(history_new)
+
+        # Create archive with old SpoolUsageHistory (archive_id NULL — legacy record)
+        archive_old = await archive_factory(
+            printer.id,
+            print_name="LegacyPrint",
+            status="completed",
+            cost=None,
+        )
+        archive_old.filament_used_grams = 30.0
+        await db_session.commit()
+
+        history_old = SpoolUsageHistory(
+            spool_id=spool_old.id,
+            printer_id=printer.id,
+            print_name="LegacyPrint",
+            weight_used=30.0,
+            percent_used=30,
+            status="completed",
+            cost=0.45,
+            archive_id=None,
+        )
+        db_session.add(history_old)
+
+        await db_session.commit()
+
+        # Recalculate costs for all archives
+        recalc_response = await async_client.post("/api/v1/archives/recalculate-costs")
+        assert recalc_response.status_code == 200
+        assert recalc_response.json()["updated"] >= 1
+
+        # Verify archive_new cost from archive_id-linked SpoolUsageHistory
+        response_new = await async_client.get(f"/api/v1/archives/{archive_new.id}")
+        assert response_new.status_code == 200
+        assert response_new.json()["cost"] == 0.50
+
+        # Verify archive_old cost from legacy print_name fallback
+        response_old = await async_client.get(f"/api/v1/archives/{archive_old.id}")
+        assert response_old.status_code == 200
+        assert response_old.json()["cost"] == 0.45
+
+        await db_session.rollback()

+ 3 - 3
backend/tests/integration/test_endpoint_auth.py

@@ -70,7 +70,7 @@ class TestEndpointAuthenticationEnforcement:
     async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
         """Verify filaments list is accessible when auth is disabled."""
         with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
-            response = await async_client.get("/api/v1/filaments/")
+            response = await async_client.get("/api/v1/filament-catalog/")
             assert response.status_code == 200
 
     @pytest.mark.asyncio
@@ -153,7 +153,7 @@ class TestAuthenticationPatterns:
         """Verify require_permission_if_auth_enabled allows access when auth disabled."""
         with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
             # Test a protected endpoint
-            response = await async_client.get("/api/v1/filaments/")
+            response = await async_client.get("/api/v1/filament-catalog/")
             assert response.status_code == 200
 
     @pytest.mark.asyncio
@@ -162,7 +162,7 @@ class TestAuthenticationPatterns:
         """Verify multiple protected endpoints are accessible when auth is disabled."""
         with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
             endpoints = [
-                "/api/v1/filaments/",
+                "/api/v1/filament-catalog/",
                 "/api/v1/external-links/",
                 "/api/v1/notifications/",
                 "/api/v1/maintenance/types",

+ 9 - 9
backend/tests/integration/test_filaments_api.py

@@ -5,7 +5,7 @@ from httpx import AsyncClient
 
 
 class TestFilamentsAPI:
-    """Integration tests for /api/v1/filaments/ endpoints."""
+    """Integration tests for /api/v1/filament-catalog/ (material types) endpoints."""
 
     @pytest.fixture
     async def filament_factory(self, db_session):
@@ -36,7 +36,7 @@ class TestFilamentsAPI:
     @pytest.mark.integration
     async def test_list_filaments_empty(self, async_client: AsyncClient):
         """Verify empty list when no filaments exist."""
-        response = await async_client.get("/api/v1/filaments/")
+        response = await async_client.get("/api/v1/filament-catalog/")
         assert response.status_code == 200
         assert isinstance(response.json(), list)
 
@@ -45,7 +45,7 @@ class TestFilamentsAPI:
     async def test_list_filaments_with_data(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify list returns existing filaments."""
         await filament_factory(name="Test Filament")
-        response = await async_client.get("/api/v1/filaments/")
+        response = await async_client.get("/api/v1/filament-catalog/")
         assert response.status_code == 200
         data = response.json()
         assert any(f["name"] == "Test Filament" for f in data)
@@ -62,7 +62,7 @@ class TestFilamentsAPI:
             "brand": "Bambu",
             "cost_per_kg": 30.0,
         }
-        response = await async_client.post("/api/v1/filaments/", json=data)
+        response = await async_client.post("/api/v1/filament-catalog/", json=data)
         assert response.status_code == 200
         result = response.json()
         assert result["name"] == "New PETG"
@@ -73,7 +73,7 @@ class TestFilamentsAPI:
     async def test_get_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify single filament can be retrieved."""
         filament = await filament_factory(name="Get Test")
-        response = await async_client.get(f"/api/v1/filaments/{filament.id}")
+        response = await async_client.get(f"/api/v1/filament-catalog/{filament.id}")
         assert response.status_code == 200
         assert response.json()["name"] == "Get Test"
 
@@ -81,7 +81,7 @@ class TestFilamentsAPI:
     @pytest.mark.integration
     async def test_get_filament_not_found(self, async_client: AsyncClient):
         """Verify 404 for non-existent filament."""
-        response = await async_client.get("/api/v1/filaments/9999")
+        response = await async_client.get("/api/v1/filament-catalog/9999")
         assert response.status_code == 404
 
     @pytest.mark.asyncio
@@ -90,7 +90,7 @@ class TestFilamentsAPI:
         """Verify filament can be updated."""
         filament = await filament_factory(name="Original")
         response = await async_client.patch(
-            f"/api/v1/filaments/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
+            f"/api/v1/filament-catalog/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
         )
         assert response.status_code == 200
         result = response.json()
@@ -102,8 +102,8 @@ class TestFilamentsAPI:
     async def test_delete_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify filament can be deleted."""
         filament = await filament_factory()
-        response = await async_client.delete(f"/api/v1/filaments/{filament.id}")
+        response = await async_client.delete(f"/api/v1/filament-catalog/{filament.id}")
         assert response.status_code == 200
         # Verify deleted
-        response = await async_client.get(f"/api/v1/filaments/{filament.id}")
+        response = await async_client.get(f"/api/v1/filament-catalog/{filament.id}")
         assert response.status_code == 404

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

@@ -0,0 +1,326 @@
+"""Integration tests for inventory spool assignment — tray_info_idx resolution.
+
+Tests that the spool's own slicer_filament (including PFUS* cloud-synced
+custom presets) takes priority, with slot reuse and generic fallback as
+lower-priority fallbacks.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    """Factory to create test spools."""
+    _counter = [0]
+
+    async def _create_spool(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Devil Design",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+            "slicer_filament": "PFUS9ac902733670a9",
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create_spool
+
+
+def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
+    """Build a mock printer status with optional AMS/nozzle data."""
+    status = MagicMock()
+    raw = {}
+    if ams_data is not None:
+        raw["ams"] = {"ams": ams_data}
+    if vt_tray is not None:
+        raw["vt_tray"] = vt_tray
+    status.raw_data = raw
+    status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
+    status.ams_extruder_map = ams_extruder_map
+    return status
+
+
+class TestAssignSpoolTrayInfoIdx:
+    """Tests for tray_info_idx resolution during spool assignment."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfus_slicer_filament_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": ""}]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spool_preset_takes_priority_over_slot(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool's own slicer_filament takes priority over slot's existing preset."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot already configured by slicer with cloud-synced preset
+        status = _make_mock_status(
+            ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Spool's own preset wins over slot's existing one
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spool_preset_used_even_if_different_material_on_slot(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool's own slicer_filament is used regardless of what's on the slot."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot currently has PLA but spool is PETG
+        status = _make_mock_status(
+            ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Standard GF* IDs from spool.slicer_filament are used directly."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Spool with no slicer_filament gets a generic ID from material type."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament=None, material="ABS")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Spool's own PFUS preset is used even when slot has a different PFUS."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot has a PFUS* ID from some previous config
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Spool's own preset wins
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS1111111111"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_generic_on_slot_not_reused_over_spool_preset(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Generic ID on slot (e.g. GFB99) must not override spool's own preset."""
+        printer = await printer_factory(name="P2S")
+        spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot stuck on generic ABS from a previous assignment
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Spool's preset wins — generic on slot must not be sticky
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUScda4c46fc9031"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_preset_with_generic_on_slot_still_uses_generic(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
+        printer = await printer_factory(name="P2S")
+        spool = await spool_factory(slicer_filament=None, material="ABS")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot has generic ABS
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Still gets generic, but via fallback — not via sticky reuse
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_preset_reuses_specific_slot_preset(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool without preset + specific preset on slot → reuse slot's preset."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament=None, material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot has a specific Bambu PLA preset (not generic)
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Slot's specific preset is reused when spool has no own preset
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"

+ 224 - 0
backend/tests/integration/test_printers_api.py

@@ -580,6 +580,230 @@ class TestAMSRefreshAPI:
             assert "unload" in response.json()["detail"].lower()
 
 
+class TestConfigureAMSSlotAPI:
+    """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/0/configure",
+                params={
+                    "tray_info_idx": "GFL99",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
+        """Standard Bambu GF* filament IDs are sent as-is."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = None  # No existing state
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
+        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}  # No existing tray data
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Devil Design PLA",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
+        """Provided PFUS* preset takes priority over slot's existing preset."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        # Simulate slot already configured by slicer with cloud-synced preset
+        mock_status = MagicMock()
+        mock_status.raw_data = {
+            "ams": {
+                "ams": [
+                    {
+                        "id": 2,
+                        "tray": [
+                            {
+                                "id": 3,
+                                "tray_info_idx": "P4d64437",
+                                "tray_type": "PLA",
+                                "tray_color": "FF0000FF",
+                            }
+                        ],
+                    }
+                ]
+            }
+        }
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Devil Design PLA",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Provided preset wins over slot's existing one
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
+        """Provided PFUS* preset is used even when slot has a different material."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        # Slot currently has PETG but user is configuring PLA
+        mock_status = MagicMock()
+        mock_status.raw_data = {
+            "ams": {
+                "ams": [
+                    {
+                        "id": 2,
+                        "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
+                    }
+                ]
+            }
+        }
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Devil Design PLA",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Provided preset wins — slot's material is irrelevant
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
+        """Empty tray_info_idx (local preset) is replaced with generic."""
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
+                params={
+                    "tray_info_idx": "",
+                    "tray_type": "PETG",
+                    "tray_sub_brands": "PETG Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 220,
+                    "nozzle_temp_max": 260,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
+
+
 class TestSkipObjectsAPI:
     """Integration tests for skip objects endpoints."""
 

+ 322 - 0
backend/tests/unit/services/test_background_dispatch.py

@@ -0,0 +1,322 @@
+"""Unit tests for background dispatch service."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.background_dispatch import (
+    ActiveDispatchState,
+    BackgroundDispatchService,
+    DispatchEnqueueRejected,
+    PrintDispatchJob,
+)
+
+
+@pytest.mark.asyncio
+async def test_dispatch_rejects_when_printer_busy_printing():
+    """Reject enqueue when target printer is already printing."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch(
+            "backend.app.services.background_dispatch.printer_manager.get_status",
+            return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
+        ),
+        pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
+    ):
+        await service.dispatch_reprint_archive(
+            archive_id=1,
+            archive_name="Test Archive",
+            printer_id=10,
+            printer_name="Printer A",
+            options={},
+            requested_by_user_id=None,
+            requested_by_username=None,
+        )
+
+
+@pytest.mark.asyncio
+async def test_dispatch_enqueues_job_and_broadcasts_state():
+    """Enqueue succeeds and emits websocket queue update."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+        ) as mock_broadcast,
+    ):
+        result = await service.dispatch_print_library_file(
+            file_id=22,
+            filename="cube.gcode.3mf",
+            printer_id=7,
+            printer_name="Printer B",
+            options={"plate_id": 2},
+            requested_by_user_id=5,
+            requested_by_username="tester",
+        )
+
+    assert result["status"] == "dispatched"
+    assert result["dispatch_job_id"] == 1
+    assert result["dispatch_position"] == 1
+    assert len(service._queued_jobs) == 1
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["type"] == "background_dispatch"
+    assert payload["data"]["recent_event"]["status"] == "dispatched"
+
+
+@pytest.mark.asyncio
+async def test_cancel_queued_job_removes_it_and_broadcasts():
+    """Cancelling queued job removes it immediately."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+        ) as mock_broadcast,
+    ):
+        result = await service.dispatch_reprint_archive(
+            archive_id=1,
+            archive_name="benchy.gcode.3mf",
+            printer_id=1,
+            printer_name="Printer 1",
+            options={},
+            requested_by_user_id=None,
+            requested_by_username=None,
+        )
+        mock_broadcast.reset_mock()
+
+        cancel_result = await service.cancel_job(result["dispatch_job_id"])
+
+    assert cancel_result["cancelled"] is True
+    assert cancel_result["pending"] is False
+    assert len(service._queued_jobs) == 0
+    assert service._batch_total == 0
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
+    """Cancelling active job marks it as pending cancellation."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=42,
+        kind="reprint_archive",
+        source_id=100,
+        source_name="gearbox.gcode.3mf",
+        printer_id=3,
+        printer_name="Printer C",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+    ) as mock_broadcast:
+        result = await service.cancel_job(job.id)
+
+    assert result["cancelled"] is True
+    assert result["pending"] is True
+    assert job.id in service._cancel_requested_job_ids
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelling"
+
+
+def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
+    """Explicit plate_id wins over auto-detection."""
+    file_path = tmp_path / "dummy.3mf"
+    file_path.write_text("not-a-zip")
+
+    plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
+    assert plate_id == 9
+
+
+def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
+    """Auto-detect plate from Metadata/plate_X.gcode entry."""
+    import zipfile
+
+    file_path = tmp_path / "multi.3mf"
+    with zipfile.ZipFile(file_path, "w") as zf:
+        zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
+
+    plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
+    assert plate_id == 7
+
+
+def test_is_sliced_file_recognizes_supported_extensions():
+    """Only .gcode and .gcode.3mf should be accepted."""
+    assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
+    assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
+    assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_not_found_returns_false():
+    """Cancelling a nonexistent job returns not_found."""
+    service = BackgroundDispatchService()
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        result = await service.cancel_job(999)
+
+    assert result["cancelled"] is False
+    assert result["reason"] == "not_found"
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_single_lock_covers_both_active_and_queued():
+    """cancel_job checks both active and queued jobs under a single lock acquisition.
+
+    Regression test for TOCTOU race: previously two separate lock acquisitions allowed
+    the dispatcher loop to move a job from queue to active between them, causing cancel
+    to find it in neither place.
+    """
+    service = BackgroundDispatchService()
+
+    # Set up a job in the queue AND an active job for a different printer
+    active_job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="active.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
+
+    queued_job = PrintDispatchJob(
+        id=2,
+        kind="reprint_archive",
+        source_id=20,
+        source_name="queued.3mf",
+        printer_id=2,
+        printer_name="Printer 2",
+    )
+    service._queued_jobs.append(queued_job)
+    service._batch_total = 2
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+    ) as mock_broadcast:
+        # Cancel the queued job — should find it in single lock acquisition
+        result = await service.cancel_job(2)
+
+    assert result["cancelled"] is True
+    assert result["pending"] is False
+    assert len(service._queued_jobs) == 0
+    # Active job should be untouched
+    assert 1 in service._active_jobs
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_resets_batch_when_all_done():
+    """Batch counters reset after last job completes."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._batch_total = 1
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    assert service._batch_total == 0
+    assert service._batch_completed == 0
+    assert service._batch_failed == 0
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_no_reset_when_jobs_remain():
+    """Batch counters NOT reset when queued jobs remain."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    remaining_job = PrintDispatchJob(
+        id=2,
+        kind="reprint_archive",
+        source_id=20,
+        source_name="next.3mf",
+        printer_id=2,
+        printer_name="Printer 2",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._queued_jobs.append(remaining_job)
+    service._batch_total = 2
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    # Batch counters should NOT be reset — remaining job still queued
+    assert service._batch_total == 2
+    assert service._batch_completed == 1
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_batch_reset_rechecks_under_lock():
+    """Batch reset re-checks condition inside second lock acquisition.
+
+    Regression test for TOCTOU: a new dispatch between the two lock acquisitions
+    could get its counters zeroed if the re-check is missing.
+    """
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._batch_total = 1
+
+    original_broadcast = AsyncMock()
+
+    async def inject_new_job_during_broadcast(msg):
+        """Simulate a new dispatch arriving between the two lock acquisitions."""
+        await original_broadcast(msg)
+        # After broadcast (lock released), inject a new job before reset re-check
+        if not service._queued_jobs:
+            new_job = PrintDispatchJob(
+                id=99,
+                kind="reprint_archive",
+                source_id=99,
+                source_name="injected.3mf",
+                printer_id=5,
+                printer_name="Printer 5",
+            )
+            service._queued_jobs.append(new_job)
+            service._batch_total = 1
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast",
+        side_effect=inject_new_job_during_broadcast,
+    ):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    # Re-check should prevent reset since a new job appeared
+    assert service._batch_total == 1
+    assert len(service._queued_jobs) == 1

+ 44 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -870,3 +870,47 @@ class TestFailureScenarios:
         result2 = client.download_file("/cache/retry.bin")
         assert result2 == b"data after retry"
         client.disconnect()
+
+    def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
+        """Upload returns True even when voidresp() gets a non-clean response.
+
+        Regression: Previously, a voidresp() error after successful data transfer
+        returned False, which caused with_ftp_retry to re-upload the entire file
+        in a loop.
+        """
+        content = b"voidresp test data"
+        local = tmp_path / "voidresp_test.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="X1C")
+        client.connect()
+        result = client.upload_file(local, "/cache/voidresp_test.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify the file is actually on the server
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/voidresp_test.3mf")
+        assert downloaded == content
+        client2.disconnect()
+
+    def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
+        """A1 models skip voidresp() entirely and still return True.
+
+        Regression: A1 printers hang on voidresp() after transfercmd uploads.
+        """
+        content = b"A1 upload test"
+        local = tmp_path / "a1_test.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="A1")
+        client.connect()
+        result = client.upload_file(local, "/cache/a1_test.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify the file is actually on the server
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/a1_test.3mf")
+        assert downloaded == content
+        client2.disconnect()

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

@@ -1205,3 +1205,1101 @@ class TestRequestTopicAmsMapping:
         assert complete_data["status"] == "completed"
         # Mapping cleared after completion
         assert mqtt_client._captured_ams_mapping is None
+
+
+# ---------------------------------------------------------------------------
+# tray_now disambiguation helpers
+# ---------------------------------------------------------------------------
+
+
+def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None, ams_exist_bits=None):
+    """Build minimal print.ams payload for tray_now disambiguation tests."""
+    ams = {"tray_now": str(tray_now)}
+    if ams_units is not None:
+        ams["ams"] = ams_units
+    if tray_exist_bits is not None:
+        ams["tray_exist_bits"] = tray_exist_bits
+    if ams_exist_bits is not None:
+        ams["ams_exist_bits"] = ams_exist_bits
+    return {"print": {"ams": ams}}
+
+
+def _extruder_info_payload(extruders):
+    """Build device.extruder.info payload (dual-nozzle detection + snow).
+
+    Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
+    """
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "info": extruders,
+                }
+            }
+        }
+    }
+
+
+def _extruder_state_payload(state_val):
+    """Build device.extruder.state payload (active extruder via bit 8)."""
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "state": state_val,
+                }
+            }
+        }
+    }
+
+
+# ---------------------------------------------------------------------------
+# 1. Single-nozzle X1E — direct passthrough
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleX1E:
+    """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_X1E",
+            access_code="12345678",
+        )
+
+    def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
+        """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
+        for slot in range(4):
+            mqtt_client._process_message(_ams_payload(slot))
+            assert mqtt_client.state.tray_now == slot
+
+    def test_tray_now_255_means_unloaded(self, mqtt_client):
+        """tray_now=255 means no filament loaded."""
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+
+    def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
+        """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
+        mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
+        assert mqtt_client._is_dual_nozzle is False
+
+    def test_last_loaded_tray_survives_unload(self, mqtt_client):
+        """Load tray 2, unload → last_loaded_tray stays 2."""
+        mqtt_client._process_message(_ams_payload(2))
+        assert mqtt_client.state.last_loaded_tray == 2
+
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+        assert mqtt_client.state.last_loaded_tray == 2
+
+
+# ---------------------------------------------------------------------------
+# 2. Single-nozzle P2S — multiple AMS, global IDs pass through
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleP2S:
+    """Single-nozzle, 2 AMS — tray_now > 3 passes through as global ID."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_P2S",
+            access_code="12345678",
+        )
+
+    def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
+        """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
+        for global_id in range(4, 8):
+            mqtt_client._process_message(_ams_payload(global_id))
+            assert mqtt_client.state.tray_now == global_id
+
+    def test_tray_change_across_ams_units(self, mqtt_client):
+        """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
+        mqtt_client._process_message(_ams_payload(1))
+        assert mqtt_client.state.tray_now == 1
+
+        mqtt_client._process_message(_ams_payload(6))
+        assert mqtt_client.state.tray_now == 6
+
+
+# ---------------------------------------------------------------------------
+# 2b. Single-nozzle P2S — multi-AMS local slot disambiguation (#420)
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowP2SMultiAmsDisambiguation:
+    """P2S firmware sends local slot IDs (0-3) in tray_now even with dual AMS.
+
+    When ams_exist_bits indicates >1 AMS unit and tray_now is 0-3, the backend
+    should use the MQTT mapping field (snow-encoded) to resolve the correct
+    global tray ID.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_P2S_DUAL",
+            access_code="12345678",
+        )
+        return client
+
+    def test_resolves_ams1_slot1_from_mapping(self, mqtt_client):
+        """tray_now=1 with mapping=[257] → global ID 5 (AMS1-T1).
+
+        257 snow-decoded: ams_hw_id=1, slot=1 → global 1*4+1=5.
+        """
+        # Set mapping field in raw_data (as the MQTT handler would)
+        mqtt_client.state.raw_data["mapping"] = [257]
+        mqtt_client._process_message(
+            _ams_payload(1, ams_exist_bits="3")  # '3' = 0b11 → AMS 0 and 1
+        )
+        assert mqtt_client.state.tray_now == 5
+
+    def test_resolves_ams1_slot0_from_mapping(self, mqtt_client):
+        """tray_now=0 with mapping=[256] → global ID 4 (AMS1-T0).
+
+        256 snow-decoded: ams_hw_id=1, slot=0 → global 1*4+0=4.
+        """
+        mqtt_client.state.raw_data["mapping"] = [256]
+        mqtt_client._process_message(_ams_payload(0, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 4
+
+    def test_resolves_ams1_slot3_from_mapping(self, mqtt_client):
+        """tray_now=3 with mapping=[259] → global ID 7 (AMS1-T3).
+
+        259 snow-decoded: ams_hw_id=1, slot=3 → global 1*4+3=7.
+        """
+        mqtt_client.state.raw_data["mapping"] = [259]
+        mqtt_client._process_message(_ams_payload(3, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 7
+
+    def test_ams0_slot_unchanged_when_mapping_confirms_ams0(self, mqtt_client):
+        """tray_now=1 with mapping=[1] → stays 1 (AMS0-T1).
+
+        1 snow-decoded: ams_hw_id=0, slot=1 → global 0*4+1=1.
+        """
+        mqtt_client.state.raw_data["mapping"] = [1]
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 1
+
+    def test_multicolor_resolves_ams1_from_multi_entry_mapping(self, mqtt_client):
+        """Multi-color print: mapping=[0, 257] → tray_now=1 resolves to AMS1-T1 (5).
+
+        Entry 0: ams_hw_id=0, slot=0 (local 0) — doesn't match tray_now=1.
+        Entry 257: ams_hw_id=1, slot=1 (local 1) — matches tray_now=1 → global 5.
+        """
+        mqtt_client.state.raw_data["mapping"] = [0, 257]
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 5
+
+    def test_multicolor_four_slot_mapping(self, mqtt_client):
+        """mapping=[65535, 65535, 65535, 257] → tray_now=1 resolves to global 5.
+
+        Only entry 257 has local slot=1, other entries are unmapped (65535).
+        Reproduces exact data from issue #420 support package.
+        """
+        mqtt_client.state.raw_data["mapping"] = [65535, 65535, 65535, 257]
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 5
+
+    def test_ambiguous_mapping_falls_back_to_local_slot(self, mqtt_client):
+        """Two AMS units with same local slot in mapping → ambiguous, keep local slot.
+
+        mapping=[1, 257]: both have local slot 1 (AMS0-T1 and AMS1-T1).
+        Cannot disambiguate → fall back to tray_now=1.
+        """
+        mqtt_client.state.raw_data["mapping"] = [1, 257]
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 1
+
+    def test_no_mapping_falls_back_to_local_slot(self, mqtt_client):
+        """No mapping field available → fall back to raw tray_now."""
+        # No mapping in raw_data (e.g. manual filament load, not during print)
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 1
+
+    def test_empty_mapping_falls_back_to_local_slot(self, mqtt_client):
+        """Empty mapping list → fall back to raw tray_now."""
+        mqtt_client.state.raw_data["mapping"] = []
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 1
+
+    def test_single_ams_passthrough(self, mqtt_client):
+        """Single AMS (ams_exist_bits='1') → tray_now 0-3 is direct global ID."""
+        mqtt_client._process_message(_ams_payload(2, ams_exist_bits="1"))
+        assert mqtt_client.state.tray_now == 2
+
+    def test_no_ams_exist_bits_passthrough(self, mqtt_client):
+        """No ams_exist_bits in payload → fall back to raw tray_now."""
+        mqtt_client._process_message(_ams_payload(1))
+        assert mqtt_client.state.tray_now == 1
+
+    def test_tray_now_255_unaffected_by_multi_ams(self, mqtt_client):
+        """tray_now=255 (unloaded) passes through regardless of AMS count."""
+        mqtt_client.state.raw_data["mapping"] = [257]
+        mqtt_client._process_message(_ams_payload(255, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 255
+
+    def test_tray_now_above_3_unaffected(self, mqtt_client):
+        """tray_now > 3 is already a global ID and passes through directly."""
+        mqtt_client._process_message(_ams_payload(6, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 6
+
+    def test_last_loaded_tray_uses_resolved_global_id(self, mqtt_client):
+        """last_loaded_tray should reflect the resolved global ID, not local slot."""
+        mqtt_client.state.raw_data["mapping"] = [257]
+        mqtt_client.state.state = "RUNNING"
+        mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
+        assert mqtt_client.state.tray_now == 5
+        assert mqtt_client.state.last_loaded_tray == 5
+
+
+class TestResolveLocalSlotFromMapping:
+    """Unit tests for _resolve_local_slot_from_mapping static method."""
+
+    def test_single_match_ams0(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1]) == 1
+
+    def test_single_match_ams1(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # 257 = 1*256 + 1 → AMS1 slot1 → global 5
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [257]) == 5
+
+    def test_single_match_ams2(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # 514 = 2*256 + 2 → AMS2 slot2 → global 10
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514]) == 10
+
+    def test_unmapped_entries_skipped(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [65535, 65535, 65535, 257]) == 5
+
+    def test_no_match_returns_none(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # mapping has slot 0 only, looking for slot 2
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [0]) is None
+
+    def test_ambiguous_returns_none(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # Both AMS0 slot1 (1) and AMS1 slot1 (257) → ambiguous
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 257]) is None
+
+    def test_none_mapping_returns_none(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, None) is None
+
+    def test_empty_mapping_returns_none(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, []) is None
+
+    def test_ams_ht_slot0_match(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # AMS-HT id=128: snow = 128*256 + 0 = 32768
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
+
+
+# ---------------------------------------------------------------------------
+# 3. H2D Pro — initial state detection
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSetup:
+    """H2D Pro initial state detection."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
+        """2 entries in device.extruder.info → _is_dual_nozzle=True."""
+        mqtt_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert mqtt_client._is_dual_nozzle is True
+
+    def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
+        """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
+        ams_units = [
+            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
+        # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
+        """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
+
+        If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
+        """
+        payload = {
+            "print": {
+                "device": {
+                    "extruder": {
+                        "info": [
+                            {"id": 0, "snow": 0xFF00FF},
+                            {"id": 1, "snow": 0xFF00FF},
+                        ],
+                        "state": 0x0001,
+                    }
+                },
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                    ],
+                    "tray_now": "2",
+                    "tray_exist_bits": "f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
+        # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
+        # Single AMS on extruder 0 → global_id = 0*4+2 = 2
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# Shared H2D fixture for classes 4-8
+# ---------------------------------------------------------------------------
+
+
+class _H2DFixtureMixin:
+    """Mixin providing a pre-configured H2D Pro client."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    @pytest.fixture
+    def h2d_client(self, mqtt_client):
+        """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "device": {
+                        "extruder": {
+                            "info": [
+                                {"id": 0, "snow": 0xFF00FF},
+                                {"id": 1, "snow": 0xFF00FF},
+                            ],
+                            "state": 0x0001,  # right extruder active
+                        }
+                    },
+                    "ams": {
+                        "ams": [
+                            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+                        ],
+                        "tray_now": "255",
+                        "tray_exist_bits": "1000f",
+                    },
+                }
+            }
+        )
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+        return mqtt_client
+
+
+# ---------------------------------------------------------------------------
+# 4. H2D Snow field disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
+    """Snow field disambiguation (primary path)."""
+
+    def test_snow_disambiguates_ams0_slot(self, h2d_client):
+        """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
+        # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
+        # so we need it in a prior message).
+        snow_val = 0 << 8 | 2  # AMS 0 slot 2 = raw 2
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 2
+
+        # Now send tray_now=2
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
+        """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
+        # Snow: extruder 1 → AMS 128 slot 0
+        snow_val = 128 << 8 | 0  # = 32768
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": snow_val},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(1) == 128
+
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        # tray_now="0" with left extruder active, snow says AMS HT (128)
+        # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
+        """Verify state.h2d_extruder_snow dict is populated correctly."""
+        snow_ext0 = 1 << 8 | 3  # AMS 1 slot 3 → global 7
+        snow_ext1 = 0 << 8 | 0  # AMS 0 slot 0 → global 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_ext0},
+                    {"id": 1, "snow": snow_ext1},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 7
+        assert h2d_client.state.h2d_extruder_snow[1] == 0
+
+    def test_snow_unloaded_value(self, h2d_client):
+        """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFFFF},
+                    {"id": 1, "snow": 0xFFFF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 255
+        assert h2d_client.state.h2d_extruder_snow[1] == 255
+
+    def test_snow_initial_sentinel_not_stored(self, h2d_client):
+        """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
+        # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Snow dict should remain empty (no matching branch)
+        assert h2d_client.state.h2d_extruder_snow == {}
+
+
+# ---------------------------------------------------------------------------
+# 5. H2D Pending target disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
+    """Pending target disambiguation (when Bambuddy initiates load)."""
+
+    def test_pending_target_matches_slot(self, h2d_client):
+        """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+        assert h2d_client.state.pending_tray_target is None  # cleared
+
+    def test_pending_target_slot_mismatch(self, h2d_client):
+        """pending=5, tray_now='2' → uses raw slot, clears pending."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(2))
+        # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
+        assert h2d_client.state.tray_now == 2
+        assert h2d_client.state.pending_tray_target is None
+
+    def test_pending_target_takes_priority_over_snow(self, h2d_client):
+        """When both pending and snow are set, pending wins."""
+        # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
+        snow_val = 0 << 8 | 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 1
+
+        # Set pending target to AMS 1 slot 1 (global 5)
+        h2d_client.state.pending_tray_target = 5
+        # tray_now="1" — matches pending (5%4=1), pending should win over snow
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+
+# ---------------------------------------------------------------------------
+# 6. H2D ams_extruder_map fallback
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
+    """ams_extruder_map fallback (no pending, no snow)."""
+
+    def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
+        """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
+        # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
+        h2d_client._process_message(_ams_payload(2))
+        # AMS 0 is the only AMS on extruder 0 (right, active by default)
+        # Fallback: single AMS → global = 0*4+2 = 2
+        assert h2d_client.state.tray_now == 2
+
+    def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
+        """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
+        # Set up: two AMS units on the same extruder (right, ext 0)
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
+        # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
+        h2d_client.state.tray_now = 5
+        # tray_now="1" → 5%4=1 matches → keep current=5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+    def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
+        """No AMS mapped to the active extruder → raw slot as global ID."""
+        # All AMS on left extruder, but right is active
+        h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):
+        """AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512)."""
+        # Switch to left extruder (where AMS-HT 128 is mapped)
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        # Only AMS-HT 128 on left extruder; no snow available
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_single_ams_ht_ignores_nonzero_slot(self, h2d_client):
+        """AMS-HT has single slot; even if printer reports slot 1, global ID = unit ID."""
+        h2d_client.state.ams_extruder_map = {"129": 0}
+        h2d_client._process_message(_ams_payload(1))
+        # AMS-HT 129: global ID = 129, not 129*4+1=517
+        assert h2d_client.state.tray_now == 129
+
+    def test_multiple_ams_keeps_current_ams_ht(self, h2d_client):
+        """Current tray is AMS-HT 128, slot 0 reported → keeps 128."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
+        h2d_client.state.tray_now = 128
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_multiple_ams_slot_nonzero_excludes_ams_ht(self, h2d_client):
+        """Slot > 0 eliminates AMS-HT candidates; single regular AMS left → resolves."""
+        # AMS 0 + AMS-HT 128 both on right extruder
+        h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
+        h2d_client.state.tray_now = 255  # no current match
+        # Slot 2 → can't be AMS-HT → only AMS 0 → global = 0*4+2 = 2
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_multiple_ams_slot_nonzero_narrows_to_single_ht_excluded(self, h2d_client):
+        """Two regular AMS + one AMS-HT, slot > 0 → AMS-HT excluded but still ambiguous."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "128": 0}
+        h2d_client.state.tray_now = 255
+        # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 3  # raw slot fallback
+
+
+# ---------------------------------------------------------------------------
+# 6b. H2D last_loaded_tray validation
+# ---------------------------------------------------------------------------
+
+
+class TestLastLoadedTrayValidation(_H2DFixtureMixin):
+    """last_loaded_tray only stores physically valid tray IDs."""
+
+    def test_regular_ams_tray_stored(self, h2d_client):
+        """Valid regular AMS tray (0-15) → stored in last_loaded_tray."""
+        h2d_client.state.tray_now = 7
+        # Trigger tray_now processing via AMS message
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 1 << 8 | 3},  # AMS 1 slot 3 → global 7
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 7
+        assert h2d_client.state.last_loaded_tray == 7
+
+    def test_ams_ht_tray_stored(self, h2d_client):
+        """Valid AMS-HT tray (128-135) → stored in last_loaded_tray."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+        assert h2d_client.state.last_loaded_tray == 128
+
+    def test_unloaded_not_stored(self, h2d_client):
+        """tray_now=255 (unloaded) → last_loaded_tray unchanged."""
+        h2d_client.state.last_loaded_tray = 5
+        h2d_client._process_message(_ams_payload(255))
+        assert h2d_client.state.tray_now == 255
+        assert h2d_client.state.last_loaded_tray == 5
+
+
+# ---------------------------------------------------------------------------
+# 7. H2D Active extruder switching
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
+    """Active extruder switching via device.extruder.state bit 8."""
+
+    def test_active_extruder_right_by_default(self, h2d_client):
+        """Initial state.active_extruder == 0 (right)."""
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_state_bit8_switches_to_left(self, h2d_client):
+        """state=0x100 → active_extruder=1 (left)."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+    def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
+        """Cycle 0 → 1 → 0."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
+        """Snow on both extruders; switching active changes which snow is used."""
+        # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},  # AMS 0 slot 1 → global 1
+                    {"id": 1, "snow": 128 << 8 | 0},  # AMS HT → global 128
+                ]
+            )
+        )
+
+        # Right active (default) — tray_now="1" → snow ext[0] says global 1
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+
+        # Switch to left
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+
+# ---------------------------------------------------------------------------
+# 8. H2D Full multi-message sequences
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
+    """Multi-message sequences simulating real H2D Pro prints."""
+
+    def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
+        """Setup → load AMS 0 slot 1 → verify tray_now=1."""
+        # Snow update: extruder 0 loading AMS 0 slot 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Printer reports tray_now="1"
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+        assert h2d_client.state.last_loaded_tray == 1
+
+    def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
+        """Setup → switch left → load AMS HT → verify tray_now=128."""
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Snow: ext 1 → AMS HT slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+
+        # Printer reports tray_now="0" (AMS HT single slot)
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+        assert h2d_client.state.last_loaded_tray == 128
+
+    def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
+        """Multi-color print alternating between right and left nozzles.
+
+        Sequence:
+        1. Right loads AMS 0 slot 0 (tray=0)
+        2. Switch left, load AMS HT (tray=128)
+        3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
+        4. Unload (255)
+        """
+        # Step 1: Right extruder loads AMS 0 slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 0
+
+        # Step 2: Switch to left, load AMS HT
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+        # Step 3: Switch back to right, load AMS 0 slot 2
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 2},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+        # Step 4: Unload
+        h2d_client._process_message(_ams_payload(255))
+        assert h2d_client.state.tray_now == 255
+        assert h2d_client.state.last_loaded_tray == 2
+
+
+class TestTrayChangeLog:
+    """Tests for tray_change_log tracking during prints (mid-print tray switch)."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TRAYLOG1",
+            access_code="12345678",
+        )
+        return client
+
+    def test_tray_change_log_defaults_empty(self, mqtt_client):
+        """tray_change_log starts as an empty list."""
+        assert mqtt_client.state.tray_change_log == []
+
+    def test_tray_change_log_seeded_on_print_start(self, mqtt_client):
+        """Print start clears log and seeds with initial tray at layer 0."""
+        mqtt_client.state.tray_now = 2
+        mqtt_client.state.last_loaded_tray = 2
+        mqtt_client._previous_gcode_state = "IDLE"
+
+        # Transition to RUNNING via _process_message
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "test.3mf",
+                }
+            }
+        )
+
+        assert mqtt_client.state.tray_change_log == [(2, 0)]
+
+    def test_tray_change_log_cleared_on_new_print(self, mqtt_client):
+        """Old log entries are cleared when a new print starts."""
+        mqtt_client.state.tray_change_log = [(5, 0), (3, 100)]
+        mqtt_client.state.tray_now = 1
+        mqtt_client.state.last_loaded_tray = 1
+        mqtt_client._previous_gcode_state = "IDLE"
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "new.3mf",
+                }
+            }
+        )
+
+        assert mqtt_client.state.tray_change_log == [(1, 0)]
+
+    def test_tray_change_recorded_during_running(self, mqtt_client):
+        """Tray change while RUNNING is appended to the log."""
+        mqtt_client.state.state = "RUNNING"
+        mqtt_client.state.layer_num = 50
+        mqtt_client.state.last_loaded_tray = 0
+        mqtt_client.state.tray_change_log = [(0, 0)]
+
+        # Simulate tray_now update via AMS data
+        mqtt_client.state.tray_now = 1
+        # Trigger the tracking code path
+        tn = mqtt_client.state.tray_now
+        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
+            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
+        mqtt_client.state.last_loaded_tray = tn
+
+        assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
+
+    def test_tray_change_not_recorded_when_idle(self, mqtt_client):
+        """Tray changes while IDLE are NOT logged."""
+        mqtt_client.state.state = "IDLE"
+        mqtt_client.state.layer_num = 0
+        mqtt_client.state.last_loaded_tray = 0
+        mqtt_client.state.tray_change_log = []
+
+        mqtt_client.state.tray_now = 3
+        tn = mqtt_client.state.tray_now
+        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
+            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
+        mqtt_client.state.last_loaded_tray = tn
+
+        assert mqtt_client.state.tray_change_log == []
+
+    def test_tray_change_recorded_during_pause(self, mqtt_client):
+        """Tray change while PAUSE is also logged (AMS can swap during pause)."""
+        mqtt_client.state.state = "PAUSE"
+        mqtt_client.state.layer_num = 75
+        mqtt_client.state.last_loaded_tray = 2
+        mqtt_client.state.tray_change_log = [(2, 0)]
+
+        mqtt_client.state.tray_now = 5
+        tn = mqtt_client.state.tray_now
+        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
+            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
+        mqtt_client.state.last_loaded_tray = tn
+
+        assert mqtt_client.state.tray_change_log == [(2, 0), (5, 75)]
+
+    def test_same_tray_not_logged_twice(self, mqtt_client):
+        """Same tray value doesn't create duplicate log entries."""
+        mqtt_client.state.state = "RUNNING"
+        mqtt_client.state.layer_num = 30
+        mqtt_client.state.last_loaded_tray = 2
+        mqtt_client.state.tray_change_log = [(2, 0)]
+
+        # Same tray again
+        mqtt_client.state.tray_now = 2
+        tn = mqtt_client.state.tray_now
+        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
+            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
+        mqtt_client.state.last_loaded_tray = tn
+
+        assert mqtt_client.state.tray_change_log == [(2, 0)]
+
+    def test_multiple_tray_changes(self, mqtt_client):
+        """Multiple tray changes create a full history."""
+        mqtt_client.state.state = "RUNNING"
+        mqtt_client.state.last_loaded_tray = 0
+        mqtt_client.state.tray_change_log = [(0, 0)]
+
+        changes = [(1, 50), (3, 120), (0, 200)]
+        for tray, layer in changes:
+            mqtt_client.state.tray_now = tray
+            mqtt_client.state.layer_num = layer
+            tn = mqtt_client.state.tray_now
+            if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
+                mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
+            mqtt_client.state.last_loaded_tray = tn
+
+        assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50), (3, 120), (0, 200)]
+
+
+class TestDeveloperModeDetection:
+    """Tests for developer LAN mode detection from MQTT 'fun' field."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_developer_mode_initially_none(self, mqtt_client):
+        """Verify developer_mode starts as None (unknown)."""
+        assert mqtt_client.state.developer_mode is None
+
+    def test_developer_mode_on_when_bit_clear(self, mqtt_client):
+        """Verify developer_mode is True when bit 0x20000000 is clear."""
+        # Bit 29 clear in lower 32 bits = developer mode ON
+        payload = {
+            "print": {
+                "gcode_state": "IDLE",
+                "fun": "1C8187FF9CFF",
+            }
+        }
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.developer_mode is True
+
+    def test_developer_mode_off_when_bit_set(self, mqtt_client):
+        """Verify developer_mode is False when bit 0x20000000 is set."""
+        # Bit 29 set in lower 32 bits = developer mode OFF (encryption required)
+        payload = {
+            "print": {
+                "gcode_state": "IDLE",
+                "fun": "1C81A7FF9CFF",
+            }
+        }
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.developer_mode is False
+
+    def test_developer_mode_exact_bit_check(self, mqtt_client):
+        """Verify only bit 0x20000000 matters, not other bits."""
+        # 0x20000000 in hex = bit 29. Set ONLY that bit.
+        payload = {
+            "print": {
+                "gcode_state": "IDLE",
+                "fun": "000020000000",
+            }
+        }
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.developer_mode is False
+
+        # All zeros = all bits clear = developer mode ON
+        payload["print"]["fun"] = "000000000000"
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.developer_mode is True
+
+    def test_developer_mode_invalid_fun_ignored(self, mqtt_client):
+        """Verify invalid fun values don't crash or change state."""
+        mqtt_client.state.developer_mode = True
+
+        payload = {
+            "print": {
+                "gcode_state": "IDLE",
+                "fun": "not_a_hex_value",
+            }
+        }
+        mqtt_client._process_message(payload)
+        # Should remain unchanged
+        assert mqtt_client.state.developer_mode is True
+
+    def test_developer_mode_missing_fun_preserves_state(self, mqtt_client):
+        """Verify messages without fun field don't reset developer_mode."""
+        mqtt_client.state.developer_mode = False
+
+        payload = {
+            "print": {
+                "gcode_state": "RUNNING",
+                "mc_percent": 50,
+            }
+        }
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.developer_mode is False
+
+    def test_developer_mode_persists_across_messages(self, mqtt_client):
+        """Verify developer_mode set by fun persists across messages without fun."""
+        # First message sets developer_mode
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "IDLE",
+                    "fun": "3EC1AFFF9CFF",
+                }
+            }
+        )
+        assert mqtt_client.state.developer_mode is False
+
+        # Subsequent messages without fun don't change it
+        for _ in range(3):
+            mqtt_client._process_message(
+                {
+                    "print": {
+                        "gcode_state": "RUNNING",
+                        "mc_percent": 50,
+                    }
+                }
+            )
+        assert mqtt_client.state.developer_mode is False

+ 5 - 5
backend/tests/unit/services/test_smart_plug_manager.py

@@ -4,7 +4,7 @@ These tests specifically target the auto-off behavior and toggle functionality
 that were identified as common regression points.
 """
 
-from datetime import datetime
+from datetime import datetime, timezone
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -354,7 +354,7 @@ class TestScheduleLoop:
             mock_now = MagicMock()
             mock_now.strftime.return_value = "08:00"
             mock_datetime.now.return_value = mock_now
-            mock_datetime.utcnow.return_value = datetime.utcnow()
+            mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
 
             # Set up async session mock
             mock_db = AsyncMock()
@@ -395,7 +395,7 @@ class TestScheduleLoop:
             mock_now = MagicMock()
             mock_now.strftime.return_value = "22:00"
             mock_datetime.now.return_value = mock_now
-            mock_datetime.utcnow.return_value = datetime.utcnow()
+            mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
 
             # Set up async session mock
             mock_db = AsyncMock()
@@ -466,7 +466,7 @@ class TestPendingAutoOffPersistence:
         mock_plug.password = None
         mock_plug.printer_id = 1
         mock_plug.auto_off_pending = True
-        mock_plug.auto_off_pending_since = datetime.utcnow()
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_temp_threshold = 70
 
@@ -497,7 +497,7 @@ class TestPendingAutoOffPersistence:
         mock_plug.password = None
         mock_plug.printer_id = 1
         mock_plug.auto_off_pending = True
-        mock_plug.auto_off_pending_since = datetime.utcnow()
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
         mock_plug.off_delay_mode = "time"
 
         with (

+ 5 - 4
backend/tests/unit/services/test_tasmota.py

@@ -38,10 +38,11 @@ class TestTasmotaService:
         url = service._build_url("192.168.1.100", "Power On")
         assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
 
-    def test_build_url_with_auth(self, service):
-        """Verify URL includes credentials when provided."""
-        url = service._build_url("192.168.1.100", "Power On", username="admin", password="secret")
-        assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On"
+    def test_build_url_never_includes_credentials(self, service):
+        """Verify URL never contains credentials (they go via httpx auth param)."""
+        url = service._build_url("192.168.1.100", "Power On")
+        assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
+        assert "@" not in url
 
     def test_build_url_encodes_special_characters(self, service):
         """Verify special characters in commands are encoded."""

+ 294 - 0
backend/tests/unit/services/test_usage_tracker.py

@@ -27,6 +27,8 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
     spool.tag_uid = tag_uid
     spool.tray_uuid = tray_uuid
     spool.last_used = None
+    spool.cost_per_kg = None
+    spool.material = "PLA"
     return spool
 
 
@@ -112,6 +114,15 @@ class TestOnPrintCompleteAMSDelta:
         yield
         _active_sessions.clear()
 
+    @pytest.fixture(autouse=True)
+    def _mock_get_setting(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            yield
+
     @pytest.mark.asyncio
     async def test_computes_delta_and_updates_spool(self):
         """Spool weight_used updated by remain% delta * label_weight."""
@@ -399,3 +410,286 @@ class TestTrackFrom3MF:
         assert len(results) == 1
         assert results[0]["ams_id"] == 1
         assert results[0]["tray_id"] == 0
+
+
+class TestSpoolAssignmentSnapshot:
+    """Tests for spool assignment snapshotting at print start (#459).
+
+    When a spool runs empty mid-print, on_ams_change deletes the SpoolAssignment.
+    The snapshot captured at print start ensures usage is still attributed correctly.
+    """
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.fixture(autouse=True)
+    def _mock_get_setting(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            yield
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_snapshots_assignments_with_db(self):
+        """on_print_start captures spool assignments when db is provided."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 60}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
+
+        assignment_0 = _make_assignment(spool_id=10, printer_id=1, ams_id=0, tray_id=0)
+        assignment_1 = _make_assignment(spool_id=20, printer_id=1, ams_id=0, tray_id=1)
+
+        db = AsyncMock()
+        scalars_mock = MagicMock()
+        scalars_mock.all.return_value = [assignment_0, assignment_1]
+        result_mock = MagicMock()
+        result_mock.scalars.return_value = scalars_mock
+        db.execute = AsyncMock(return_value=result_mock)
+
+        await on_print_start(1, {"subtask_name": "Benchy"}, pm, db=db)
+
+        session = _active_sessions[1]
+        assert session.spool_assignments == {(0, 0): 10, (0, 1): 20}
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_empty_snapshot_without_db(self):
+        """on_print_start creates empty snapshot when no db provided."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))
+
+        await on_print_start(1, {"subtask_name": "Benchy"}, pm)
+
+        session = _active_sessions[1]
+        assert session.spool_assignments == {}
+
+    @pytest.mark.asyncio
+    async def test_3mf_uses_snapshot_instead_of_live_query(self):
+        """_track_from_3mf uses snapshot spool_id without querying SpoolAssignment."""
+        spool = _make_spool(id=42, label_weight=1000)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        # db: archive, queue_item(None), spool — NO assignment query needed
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+                spool_assignments={(0, 0): 42},
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 42
+        assert results[0]["weight_used"] == 15.0
+
+    @pytest.mark.asyncio
+    async def test_3mf_falls_back_to_live_query_without_snapshot(self):
+        """_track_from_3mf queries SpoolAssignment when no snapshot exists."""
+        spool = _make_spool(id=5, label_weight=1000)
+        assignment = _make_assignment(spool_id=5)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        # db: archive, queue_item(None), assignment, spool
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+                spool_assignments=None,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 5
+
+    @pytest.mark.asyncio
+    async def test_ams_delta_uses_snapshot_over_live_query(self):
+        """AMS remain% fallback uses snapshot spool_id instead of live query."""
+        spool = _make_spool(id=77, label_weight=1000)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            spool_assignments={(0, 0): 77},
+        )
+
+        # Current remain = 70% → 10% delta → 100g
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        # db only returns spool (NO assignment query)
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        results = await on_print_complete(
+            printer_id=1,
+            data={"status": "completed"},
+            printer_manager=pm,
+            db=db,
+            archive_id=None,
+        )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 77
+        assert results[0]["weight_used"] == 100.0
+
+    @pytest.mark.asyncio
+    async def test_ams_delta_falls_back_to_live_query_without_snapshot(self):
+        """AMS remain% fallback queries SpoolAssignment when snapshot is empty."""
+        spool = _make_spool(id=33, label_weight=1000)
+        assignment = _make_assignment(spool_id=33)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            spool_assignments={},  # Empty snapshot (pre-upgrade session)
+        )
+
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        # db returns assignment then spool
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        results = await on_print_complete(
+            printer_id=1,
+            data={"status": "completed"},
+            printer_manager=pm,
+            db=db,
+            archive_id=None,
+        )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 33
+
+    @pytest.mark.asyncio
+    async def test_snapshot_survives_mid_print_unlink(self):
+        """Core bug scenario: snapshot provides spool_id after mid-print unlink.
+
+        Simulates the #459 scenario: spool runs empty mid-print, on_ams_change
+        deletes the SpoolAssignment, but the snapshot from print start still
+        has the spool_id so usage is correctly attributed at print completion.
+        """
+        spool = _make_spool(id=8, label_weight=1000, weight_used=50)
+        archive = MagicMock()
+        archive.file_path = "archives/big_print.3mf"
+
+        # Session was created at print start WITH snapshot
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Big Print",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 90},
+            spool_assignments={(0, 0): 8},  # Snapshot from print start
+        )
+
+        pm = _make_printer_manager(
+            _make_printer_state(
+                [{"id": 0, "tray": [{"id": 0, "remain": 75}]}],
+                tray_now=0,
+            )
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
+
+        # db: archive, queue_item(None), spool, then cost aggregation queries
+        # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+                # Cost aggregation: sum query (uses .scalar()), archive lookup
+                MagicMock(scalar=MagicMock(return_value=0)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+            ]
+        )
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=pm,
+                db=db,
+                archive_id=100,
+            )
+
+        # Usage should be tracked despite assignment being deleted mid-print
+        assert len(results) >= 1
+        assert results[0]["spool_id"] == 8
+        assert results[0]["weight_used"] == 14.2
+        # Spool weight should be updated: 50 + 14.2 = 64.2
+        assert spool.weight_used == 64.2

+ 414 - 427
backend/tests/unit/services/test_virtual_printer.py

@@ -10,193 +10,305 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 
-class TestVirtualPrinterManager:
-    """Tests for VirtualPrinterManager class."""
+class TestVirtualPrinterInstance:
+    """Tests for VirtualPrinterInstance class."""
 
     @pytest.fixture
-    def manager(self):
-        """Create a VirtualPrinterManager instance."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+    def instance(self, tmp_path):
+        """Create a VirtualPrinterInstance with test defaults."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        return VirtualPrinterManager()
+        return VirtualPrinterInstance(
+            vp_id=1,
+            name="TestPrinter",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
 
     # ========================================================================
-    # Tests for configuration
+    # Tests for instance properties
     # ========================================================================
 
-    @pytest.mark.asyncio
-    async def test_configure_sets_parameters(self, manager):
-        """Verify configure stores parameters correctly."""
-        # Mock the start/stop methods to avoid actually starting services
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
+    def test_instance_stores_parameters(self, instance):
+        """Verify constructor stores parameters correctly."""
+        assert instance.id == 1
+        assert instance.name == "TestPrinter"
+        assert instance.mode == "immediate"
+        assert instance.model == "C11"
+        assert instance.access_code == "12345678"
+        assert instance.serial_suffix == "391800001"
+
+    def test_instance_serial_property(self, instance):
+        """Verify serial is generated from model prefix + suffix."""
+        # C11 = P1P, prefix = 01S00A
+        assert instance.serial == "01S00A391800001"
+
+    def test_instance_serial_x1c(self, tmp_path):
+        """Verify X1C serial uses correct prefix."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=2,
+            name="X1C",
             mode="immediate",
-        )
-
-        assert manager._enabled is True
-        assert manager._access_code == "12345678"
-        assert manager._mode == "immediate"
-
-    @pytest.mark.asyncio
-    async def test_configure_disabled_stops_services(self, manager):
-        """Verify disabling stops all services."""
-        # First simulate enabled state
-        manager._enabled = True
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-
-        await manager.configure(enabled=False, access_code="12345678")
-
-        assert manager._enabled is False
-        manager._stop.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_configure_requires_access_code_when_enabling(self, manager):
-        """Verify access code is required when enabling."""
-        with pytest.raises(ValueError, match="Access code is required"):
-            await manager.configure(enabled=True)
-
-    @pytest.mark.asyncio
-    async def test_configure_sets_model(self, manager):
-        """Verify configure stores model correctly."""
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
+            model="3DPrinter-X1-Carbon",
             access_code="12345678",
-            mode="immediate",
-            model="C11",  # P1S model code
+            serial_suffix="391800002",
+            base_dir=tmp_path,
         )
+        assert inst.serial == "00M00A391800002"
 
-        assert manager._model == "C11"
+    def test_instance_is_proxy_false(self, instance):
+        """Verify is_proxy is False for non-proxy mode."""
+        assert instance.is_proxy is False
 
-    @pytest.mark.asyncio
-    async def test_configure_ignores_invalid_model(self, manager):
-        """Verify configure ignores invalid model codes."""
-        manager._start = AsyncMock()
+    def test_instance_is_proxy_true(self, tmp_path):
+        """Verify is_proxy is True for proxy mode."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            model="INVALID",
+        inst = VirtualPrinterInstance(
+            vp_id=3,
+            name="Proxy",
+            mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800003",
+            target_printer_ip="192.168.1.100",
+            base_dir=tmp_path,
         )
+        assert inst.is_proxy is True
 
-        # Should keep default model (3DPrinter-X1-Carbon = X1C)
-        assert manager._model == "3DPrinter-X1-Carbon"
+    def test_instance_is_running_with_active_tasks(self, instance):
+        """Verify is_running is True when tasks are active."""
+        mock_task = MagicMock()
+        mock_task.done.return_value = False
+        instance._tasks = [mock_task]
+        assert instance.is_running is True
 
-    @pytest.mark.asyncio
-    async def test_configure_restarts_on_model_change(self, manager):
-        """Verify model change restarts services when running."""
-        # Simulate running state
-        manager._enabled = True
-        manager._model = "3DPrinter-X1-Carbon"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            model="C11",  # P1P
-        )
+    def test_instance_is_running_with_no_tasks(self, instance):
+        """Verify is_running is False when no tasks."""
+        assert instance.is_running is False
 
-        # Should have stopped and started
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
+    def test_instance_creates_directories(self, instance, tmp_path):
+        """Verify instance creates upload and cert directories."""
+        assert (tmp_path / "uploads" / "1").exists()
+        assert (tmp_path / "uploads" / "1" / "cache").exists()
+        assert (tmp_path / "certs" / "1").exists()
 
     # ========================================================================
     # Tests for status
     # ========================================================================
 
-    def test_get_status_returns_correct_format(self, manager):
+    def test_get_status_returns_correct_format(self, instance):
         """Verify get_status returns expected fields."""
-        manager._enabled = True
-        manager._mode = "immediate"
-        manager._model = "C11"  # P1P
-        manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
-        # Simulate running tasks
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-
-        status = manager.get_status()
+        instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}  # nosec B108
+        mock_task = MagicMock(done=MagicMock(return_value=False))
+        instance._tasks = [mock_task]
 
-        assert status["enabled"] is True
+        status = instance.get_status()
         assert status["running"] is True
-        assert status["mode"] == "immediate"
-        assert status["name"] == "Bambuddy"
-        assert status["serial"] == "01S00A391800001"  # C11 (P1P) serial prefix
-        assert status["model"] == "C11"
-        assert status["model_name"] == "P1P"
         assert status["pending_files"] == 1
 
-    def test_get_status_when_stopped(self, manager):
-        """Verify get_status when not running."""
-        manager._enabled = False
-        manager._tasks = []
-
-        status = manager.get_status()
-
-        assert status["enabled"] is False
+    def test_get_status_not_running(self, instance):
+        """Verify get_status when no tasks."""
+        status = instance.get_status()
         assert status["running"] is False
-
-    def test_is_running_with_active_tasks(self, manager):
-        """Verify is_running is True when tasks are active."""
-        mock_task = MagicMock()
-        mock_task.done.return_value = False
-        manager._tasks = [mock_task]
-
-        assert manager.is_running is True
-
-    def test_is_running_with_no_tasks(self, manager):
-        """Verify is_running is False when no tasks."""
-        manager._tasks = []
-
-        assert manager.is_running is False
+        assert status["pending_files"] == 0
 
     # ========================================================================
     # Tests for file handling
     # ========================================================================
 
     @pytest.mark.asyncio
-    async def test_on_file_received_adds_to_pending(self, manager):
-        """Verify received file is added to pending list."""
-        manager._mode = "queue"
-        manager._session_factory = None  # Disable actual archiving
+    async def test_on_file_received_adds_to_pending(self, instance):
+        """Verify received file is added to pending list in review mode."""
+        instance.mode = "review"
 
         file_path = Path("/tmp/test.3mf")
 
-        with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue:
-            await manager._on_file_received(file_path, "192.168.1.100")
+        with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue:
+            await instance.on_file_received(file_path, "192.168.1.100")
 
-            assert "test.3mf" in manager._pending_files
+            assert "test.3mf" in instance._pending_files
             mock_queue.assert_called_once()
 
     @pytest.mark.asyncio
-    async def test_on_file_received_archives_immediately(self, manager):
+    async def test_on_file_received_archives_immediately(self, instance):
         """Verify file is archived in immediate mode."""
-        manager._mode = "immediate"
-        manager._session_factory = None  # Will prevent actual archiving
-
         file_path = Path("/tmp/test.3mf")
 
-        with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive:
-            await manager._on_file_received(file_path, "192.168.1.100")
+        with patch.object(instance, "_archive_file", new_callable=AsyncMock) as mock_archive:
+            await instance.on_file_received(file_path, "192.168.1.100")
 
             mock_archive.assert_called_once_with(file_path, "192.168.1.100")
 
     @pytest.mark.asyncio
-    async def test_archive_file_skips_non_3mf(self, manager):
+    async def test_archive_file_skips_non_3mf(self, instance):
         """Verify non-3MF files are skipped and cleaned up."""
-        manager._session_factory = MagicMock()
-        manager._pending_files["verify_job"] = Path("/tmp/verify_job")
+        instance._session_factory = MagicMock()
+        instance._pending_files["verify_job"] = Path("/tmp/verify_job")  # nosec B108
 
         with patch("pathlib.Path.unlink"):
-            await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
+            await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100")  # nosec B108
 
-            # Should be removed from pending
-            assert "verify_job" not in manager._pending_files
+            assert "verify_job" not in instance._pending_files
+
+
+class TestVirtualPrinterManager:
+    """Tests for VirtualPrinterManager orchestrator."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a VirtualPrinterManager instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        return VirtualPrinterManager()
+
+    def test_manager_starts_empty(self, manager):
+        """Verify manager starts with no instances."""
+        assert len(manager._instances) == 0
+        assert manager.is_enabled is False
+
+    def test_manager_get_status_empty(self, manager):
+        """Verify get_status returns disabled state when no instances."""
+        status = manager.get_status()
+        assert status["enabled"] is False
+        assert status["running"] is False
+        assert status["mode"] == "immediate"
+
+    def test_manager_is_enabled_with_instance(self, manager, tmp_path):
+        """Verify is_enabled is True when instances exist."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="Test",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        manager._instances[1] = inst
+        assert manager.is_enabled is True
+
+    @pytest.mark.asyncio
+    async def test_manager_remove_instance_server(self, manager, tmp_path):
+        """Verify remove_instance stops and removes a server-mode instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="Test",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        await manager.remove_instance(1)
+
+        assert 1 not in manager._instances
+        inst.stop_server.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_manager_remove_instance_proxy(self, manager, tmp_path):
+        """Verify remove_instance stops proxy-mode instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=2,
+            name="Proxy",
+            mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800002",
+            target_printer_ip="192.168.1.100",
+            base_dir=tmp_path,
+        )
+        inst.stop_proxy = AsyncMock()
+        manager._instances[2] = inst
+
+        await manager.remove_instance(2)
+
+        assert 2 not in manager._instances
+        inst.stop_proxy.assert_called_once()
+
+    def test_manager_get_status_with_instance(self, manager, tmp_path):
+        """Verify legacy get_status returns first instance data."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="Bambuddy",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        mock_task = MagicMock(done=MagicMock(return_value=False))
+        inst._tasks = [mock_task]
+        inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}  # nosec B108
+        manager._instances[1] = inst
+
+        status = manager.get_status()
+        assert status["enabled"] is True
+        assert status["running"] is True
+        assert status["mode"] == "immediate"
+        assert status["name"] == "Bambuddy"
+        assert status["serial"] == "01S00A391800001"
+        assert status["model"] == "C11"
+        assert status["model_name"] == "P1P"
+        assert status["pending_files"] == 1
+
+    def test_manager_get_all_status(self, manager, tmp_path):
+        """Verify get_all_status returns status for all instances."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        for i in range(1, 3):
+            inst = VirtualPrinterInstance(
+                vp_id=i,
+                name=f"VP{i}",
+                mode="immediate",
+                model="C11",
+                access_code="12345678",
+                serial_suffix=f"39180000{i}",
+                base_dir=tmp_path,
+            )
+            manager._instances[i] = inst
+
+        statuses = manager.get_all_status()
+        assert len(statuses) == 2
+        assert statuses[0]["name"] == "VP1"
+        assert statuses[1]["name"] == "VP2"
+
+    @pytest.mark.asyncio
+    async def test_manager_stop_all(self, manager, tmp_path):
+        """Verify stop_all removes all instances."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        for i in range(1, 3):
+            inst = VirtualPrinterInstance(
+                vp_id=i,
+                name=f"VP{i}",
+                mode="immediate",
+                model="C11",
+                access_code="12345678",
+                serial_suffix=f"39180000{i}",
+                base_dir=tmp_path,
+            )
+            inst.stop_server = AsyncMock()
+            manager._instances[i] = inst
+
+        await manager.stop_all()
+        assert len(manager._instances) == 0
 
 
 class TestFTPSession:
@@ -510,7 +622,7 @@ class TestCertificateService:
 
 
 class TestBindServer:
-    """Tests for BindServer (port 3000 bind/detect protocol)."""
+    """Tests for BindServer (port 3002 bind/detect protocol)."""
 
     @pytest.fixture
     def bind_server(self):
@@ -628,6 +740,18 @@ class TestBindServer:
         )
         assert server.version == "02.03.04.05"
 
+    def test_bind_ports_constant(self):
+        """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
+        from backend.app.services.virtual_printer.bind_server import BIND_PORTS
+
+        assert 3000 in BIND_PORTS
+        assert 3002 in BIND_PORTS
+
+    def test_bind_server_initializes_empty_servers_list(self, bind_server):
+        """Verify bind server starts with empty servers list."""
+        assert bind_server._servers == []
+        assert bind_server._running is False
+
 
 class TestSlicerProxyManager:
     """Tests for SlicerProxyManager (proxy mode)."""
@@ -657,6 +781,8 @@ class TestSlicerProxyManager:
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
+        # Bind ports: both 3000 and 3002 for slicer compatibility
+        assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
 
     def test_proxy_manager_stores_target_host(self, proxy_manager):
         """Verify proxy manager stores target host."""
@@ -740,42 +866,28 @@ class TestSSDPProxy:
 class TestVirtualPrinterManagerDirectories:
     """Tests for VirtualPrinterManager directory management."""
 
-    def test_ensure_directories_creates_subdirs(self, tmp_path):
-        """Verify _ensure_directories creates all required subdirectories."""
+    def test_ensure_base_directories_creates_subdirs(self, tmp_path):
+        """Verify _ensure_base_directories creates required base directories."""
         from backend.app.services.virtual_printer.manager import VirtualPrinterManager
 
-        # Create a manager and manually call _ensure_directories with our tmp path
         manager = VirtualPrinterManager()
-        # Override the paths
         manager._base_dir = tmp_path / "virtual_printer"
-        manager._upload_dir = manager._base_dir / "uploads"
-        manager._cert_dir = manager._base_dir / "certs"
-
-        # Call the method
-        manager._ensure_directories()
+        manager._ensure_base_directories()
 
-        # All directories should be created
         assert (tmp_path / "virtual_printer").exists()
         assert (tmp_path / "virtual_printer" / "uploads").exists()
-        assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
         assert (tmp_path / "virtual_printer" / "certs").exists()
 
-    def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
-        """Verify _ensure_directories logs error on permission failure."""
+    def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
+        """Verify _ensure_base_directories logs error on permission failure."""
         import logging
-        from unittest.mock import patch
 
         from backend.app.services.virtual_printer.manager import VirtualPrinterManager
 
-        # Create manager and override paths
         manager = VirtualPrinterManager()
         vp_dir = tmp_path / "virtual_printer"
-
         manager._base_dir = vp_dir
-        manager._upload_dir = vp_dir / "uploads"
-        manager._cert_dir = vp_dir / "certs"
 
-        # Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
         original_mkdir = type(vp_dir).mkdir
 
         def mock_mkdir(self, *args, **kwargs):
@@ -784,333 +896,190 @@ class TestVirtualPrinterManagerDirectories:
             return original_mkdir(self, *args, **kwargs)
 
         with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
-            # This should log errors but not raise
-            manager._ensure_directories()
-            # Check that error was logged
+            manager._ensure_base_directories()
             assert "Permission denied" in caplog.text
 
+    def test_instance_creates_per_vp_directories(self, tmp_path):
+        """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-class TestVirtualPrinterManagerProxyMode:
-    """Tests for VirtualPrinterManager proxy mode."""
+        VirtualPrinterInstance(
+            vp_id=42,
+            name="Test",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800042",
+            base_dir=tmp_path,
+        )
 
-    @pytest.fixture
-    def manager(self):
-        """Create a VirtualPrinterManager instance."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+        assert (tmp_path / "uploads" / "42").exists()
+        assert (tmp_path / "uploads" / "42" / "cache").exists()
+        assert (tmp_path / "certs" / "42").exists()
 
-        return VirtualPrinterManager()
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_requires_target_ip(self, manager):
-        """Verify proxy mode requires target_printer_ip."""
-        with pytest.raises(ValueError, match="Target printer IP is required"):
-            await manager.configure(
-                enabled=True,
-                mode="proxy",
-                target_printer_ip="",  # Empty target IP
-            )
+class TestVirtualPrinterInstanceProxyMode:
+    """Tests for VirtualPrinterInstance proxy mode."""
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
-        """Verify proxy mode does not require access code (uses real printer's)."""
-        manager._start = AsyncMock()
+    @pytest.fixture
+    def proxy_instance(self, tmp_path):
+        """Create a proxy-mode VirtualPrinterInstance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        # Should not raise - proxy mode doesn't need access code
-        await manager.configure(
-            enabled=True,
+        return VirtualPrinterInstance(
+            vp_id=10,
+            name="ProxyTest",
             mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800010",
             target_printer_ip="192.168.1.100",
+            target_printer_serial="01P00A000000001",
+            base_dir=tmp_path,
         )
 
-        assert manager._mode == "proxy"
-        assert manager._target_printer_ip == "192.168.1.100"
+    def test_proxy_instance_properties(self, proxy_instance):
+        """Verify proxy instance stores config correctly."""
+        assert proxy_instance.is_proxy is True
+        assert proxy_instance.mode == "proxy"
+        assert proxy_instance.target_printer_ip == "192.168.1.100"
+        assert proxy_instance.target_printer_serial == "01P00A000000001"
 
-    def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
-        """Verify get_status includes proxy-specific fields in proxy mode."""
-        manager._enabled = True
-        manager._mode = "proxy"
-        manager._target_printer_ip = "192.168.1.100"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+    def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
+        """Verify proxy mode can have empty access code."""
+        assert proxy_instance.access_code == ""
 
-        # Create a mock proxy with get_status
+    def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
+        """Verify get_status includes proxy fields when proxy is active."""
         mock_proxy = MagicMock()
         mock_proxy.get_status.return_value = {
             "running": True,
-            "ftp_port": 990,  # Privileged port for Bambu Studio compatibility
+            "ftp_port": 990,
             "mqtt_port": 8883,
             "ftp_connections": 1,
             "mqtt_connections": 2,
             "target_host": "192.168.1.100",
         }
-        manager._proxy = mock_proxy
-
-        status = manager.get_status()
+        proxy_instance._proxy = mock_proxy
 
-        assert status["mode"] == "proxy"
-        assert status["target_printer_ip"] == "192.168.1.100"
+        status = proxy_instance.get_status()
         assert "proxy" in status
-        assert status["proxy"]["ftp_port"] == 990  # Privileged port for Bambu Studio compatibility
-        assert status["proxy"]["mqtt_port"] == 8883
-        assert status["proxy"]["ftp_connections"] == 1
+        assert status["proxy"]["ftp_port"] == 990
         assert status["proxy"]["mqtt_connections"] == 2
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_with_remote_interface(self, manager):
-        """Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
-        manager._start = AsyncMock()
+    def test_proxy_instance_stores_remote_interface(self, tmp_path):
+        """Verify proxy instance stores remote_interface_ip."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        await manager.configure(
-            enabled=True,
+        inst = VirtualPrinterInstance(
+            vp_id=11,
+            name="Proxy2",
             mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800011",
             target_printer_ip="192.168.1.100",
             remote_interface_ip="10.0.0.50",
+            base_dir=tmp_path,
         )
+        assert inst.remote_interface_ip == "10.0.0.50"
 
-        assert manager._mode == "proxy"
-        assert manager._target_printer_ip == "192.168.1.100"
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
-        """Verify changing remote_interface_ip restarts services in proxy mode."""
-        # Simulate running state
-        manager._enabled = True
-        manager._mode = "proxy"
-        manager._target_printer_ip = "192.168.1.100"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            mode="proxy",
-            target_printer_ip="192.168.1.100",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        # Should have stopped and started
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
 
-
-class TestVirtualPrinterManagerServerModeIPOverride:
-    """Tests for remote_interface_ip in server mode (immediate/review/print_queue)."""
+class TestVirtualPrinterInstanceIPOverride:
+    """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
 
     @pytest.fixture
-    def manager(self):
-        """Create a VirtualPrinterManager instance."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
-
-        return VirtualPrinterManager()
+    def instance_with_remote_ip(self, tmp_path):
+        """Create an instance with remote_interface_ip set."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-    @pytest.mark.asyncio
-    async def test_configure_immediate_mode_stores_remote_interface_ip(self, manager):
-        """Verify immediate mode stores remote_interface_ip."""
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
+        return VirtualPrinterInstance(
+            vp_id=20,
+            name="IPTest",
             mode="immediate",
-            remote_interface_ip="10.0.0.50",
-        )
-
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_configure_review_mode_stores_remote_interface_ip(self, manager):
-        """Verify review mode stores remote_interface_ip."""
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
+            model="3DPrinter-X1-Carbon",
             access_code="12345678",
-            mode="review",
+            serial_suffix="391800020",
+            bind_ip="192.168.1.50",
             remote_interface_ip="10.0.0.50",
+            base_dir=tmp_path,
         )
 
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_configure_print_queue_mode_stores_remote_interface_ip(self, manager):
-        """Verify print_queue mode stores remote_interface_ip."""
-        manager._start = AsyncMock()
+    def test_instance_stores_bind_ip(self, instance_with_remote_ip):
+        """Verify bind_ip is stored."""
+        assert instance_with_remote_ip.bind_ip == "192.168.1.50"
 
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="print_queue",
-            remote_interface_ip="10.0.0.50",
-        )
-
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_remote_interface_change_restarts_immediate_mode(self, manager):
-        """Verify changing remote_interface_ip restarts services in immediate mode."""
-        manager._enabled = True
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="immediate",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_remote_interface_change_restarts_review_mode(self, manager):
-        """Verify changing remote_interface_ip restarts services in review mode."""
-        manager._enabled = True
-        manager._mode = "review"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="review",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_remote_interface_change_restarts_print_queue_mode(self, manager):
-        """Verify changing remote_interface_ip restarts services in print_queue mode."""
-        manager._enabled = True
-        manager._mode = "print_queue"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="print_queue",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_no_restart_when_remote_interface_unchanged(self, manager):
-        """Verify no restart if remote_interface_ip hasn't changed."""
-        manager._enabled = True
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="immediate",
-            remote_interface_ip="10.0.0.50",  # Same
-        )
-
-        manager._stop.assert_not_called()
-        manager._start.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_server_mode_passes_advertise_ip_to_ssdp(self, manager):
-        """Verify _start_server_mode passes remote_interface_ip as advertise_ip to SSDP."""
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._model = "3DPrinter-X1-Carbon"
+    def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
+        """Verify remote_interface_ip is stored."""
+        assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
 
+    def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
+        """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer") as mock_ssdp_cls,
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer"),
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                instance_with_remote_ip._cert_service,
                 "generate_certificates",
                 return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
-            ),
+            ) as mock_gen,
         ):
-            mock_ssdp_cls.return_value.start = AsyncMock()
-            await manager._start_server_mode()
+            instance_with_remote_ip.generate_certificates()
+            mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
 
-            mock_ssdp_cls.assert_called_once_with(
-                name="Bambuddy",
-                serial=manager.printer_serial,
-                model="3DPrinter-X1-Carbon",
-                advertise_ip="10.0.0.50",
-            )
+    def test_generate_certificates_no_remote_ip(self, tmp_path):
+        """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-    @pytest.mark.asyncio
-    async def test_server_mode_passes_additional_ips_to_certificate(self, manager):
-        """Verify _start_server_mode includes remote_interface_ip in certificate SANs."""
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._model = "3DPrinter-X1-Carbon"
+        inst = VirtualPrinterInstance(
+            vp_id=21,
+            name="NoRemote",
+            mode="immediate",
+            model="3DPrinter-X1-Carbon",
+            access_code="12345678",
+            serial_suffix="391800021",
+            bind_ip="192.168.1.50",
+            base_dir=tmp_path,
+        )
 
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer"),
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                inst._cert_service,
                 "generate_certificates",
                 return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
-            ) as mock_gen_certs,
+            ) as mock_gen,
         ):
-            await manager._start_server_mode()
+            inst.generate_certificates()
+            mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
 
-            mock_gen_certs.assert_called_once_with(additional_ips=["10.0.0.50"])
+    def test_generate_certificates_no_ips(self, tmp_path):
+        """Verify generate_certificates passes None when no IPs configured."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-    @pytest.mark.asyncio
-    async def test_server_mode_no_additional_ips_without_remote_interface(self, manager):
-        """Verify _start_server_mode passes None for additional_ips when no remote interface."""
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = ""
-        manager._model = "3DPrinter-X1-Carbon"
+        inst = VirtualPrinterInstance(
+            vp_id=22,
+            name="NoIPs",
+            mode="immediate",
+            model="3DPrinter-X1-Carbon",
+            access_code="12345678",
+            serial_suffix="391800022",
+            base_dir=tmp_path,
+        )
 
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer"),
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                inst._cert_service,
                 "generate_certificates",
                 return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
-            ) as mock_gen_certs,
+            ) as mock_gen,
         ):
-            await manager._start_server_mode()
-
-            mock_gen_certs.assert_called_once_with(additional_ips=None)
+            inst.generate_certificates()
+            mock_gen.assert_called_once_with(additional_ips=None)
 
 
 class TestBindServer:
-    """Tests for the BindServer (port 3000 bind/detect protocol)."""
+    """Tests for the BindServer (port 3002 bind/detect protocol)."""
 
     @pytest.fixture
     def bind_server(self):
@@ -1203,33 +1172,51 @@ class TestBindServer:
         )
         assert server.version == "01.09.00.10"
 
+    def test_bind_ports_includes_both(self):
+        """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
+        from backend.app.services.virtual_printer.bind_server import BIND_PORTS
+
+        assert 3000 in BIND_PORTS
+        assert 3002 in BIND_PORTS
+
+    def test_bind_server_initializes_empty_servers_list(self, bind_server):
+        """Verify bind server starts with empty servers list."""
+        assert bind_server._servers == []
+        assert bind_server._running is False
+
     @pytest.mark.asyncio
-    async def test_server_mode_creates_bind_server(self):
-        """Verify _start_server_mode creates BindServer with correct params."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+    async def test_start_server_creates_bind_server(self, tmp_path):
+        """Verify start_server creates BindServer with correct params."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        manager = VirtualPrinterManager()
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = ""
-        manager._model = "3DPrinter-X1-Carbon"
+        inst = VirtualPrinterInstance(
+            vp_id=99,
+            name="Bambuddy",
+            mode="immediate",
+            model="3DPrinter-X1-Carbon",
+            access_code="12345678",
+            serial_suffix="391800099",
+            bind_ip="192.168.1.50",
+            base_dir=tmp_path,
+        )
 
         with (
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
             patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls,
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                inst._cert_service,
                 "generate_certificates",
                 return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
             ),
         ):
-            await manager._start_server_mode()
+            await inst.start_server()
 
             mock_bind_cls.assert_called_once_with(
-                serial=manager.printer_serial,
+                serial=inst.serial,
                 model="3DPrinter-X1-Carbon",
                 name="Bambuddy",
+                bind_address="192.168.1.50",
             )

+ 92 - 0
backend/tests/unit/test_archive_file_path_guard.py

@@ -0,0 +1,92 @@
+"""Tests for archive file_path guard against empty paths and directories (#475).
+
+When a 3mf download fails (e.g. BambuStudio-initiated prints), the fallback
+archive is created with file_path="". Previously, `settings.base_dir / ""`
+resolved to the base directory itself, which passed `exists()` but caused
+`[Errno 21] Is a directory` when opened as a ZipFile.
+
+The fix replaces `.exists()` with `.is_file()` across all archive endpoints,
+and adds an `archive.file_path` truthiness check for photo capture.
+"""
+
+from pathlib import Path
+
+import pytest
+
+
+class TestIsFileGuard:
+    """Verify that is_file() correctly rejects directories and empty paths."""
+
+    def test_empty_path_resolves_to_parent(self, tmp_path: Path):
+        """Path('') / '' resolves to the parent directory (which exists but is not a file)."""
+        base_dir = tmp_path / "data"
+        base_dir.mkdir()
+
+        file_path = base_dir / ""
+        # exists() returns True (the directory exists) — this was the old broken check
+        assert file_path.exists()
+        # is_file() returns False (it's a directory, not a file)
+        assert not file_path.is_file()
+
+    def test_real_file_passes_is_file(self, tmp_path: Path):
+        """A real 3mf file passes is_file()."""
+        fake_3mf = tmp_path / "archive" / "test.3mf"
+        fake_3mf.parent.mkdir(parents=True)
+        fake_3mf.write_bytes(b"PK\x03\x04")  # ZIP magic bytes
+
+        assert fake_3mf.is_file()
+
+    def test_nonexistent_file_fails_is_file(self, tmp_path: Path):
+        """A nonexistent path fails is_file()."""
+        missing = tmp_path / "archive" / "missing.3mf"
+        assert not missing.is_file()
+
+    def test_directory_fails_is_file(self, tmp_path: Path):
+        """A directory path fails is_file()."""
+        dir_path = tmp_path / "archive"
+        dir_path.mkdir()
+        assert not dir_path.is_file()
+
+
+class TestFallbackArchiveFilePath:
+    """Verify that a fallback archive (file_path='') is handled safely."""
+
+    def test_base_dir_slash_empty_string_is_base_dir(self, tmp_path: Path):
+        """Joining base_dir with empty string produces base_dir (a directory)."""
+        base_dir = tmp_path / "data"
+        base_dir.mkdir()
+
+        # Simulate: file_path = settings.base_dir / archive.file_path
+        # where archive.file_path = ""
+        file_path = base_dir / ""
+
+        # The resolved path IS the directory itself
+        assert file_path.resolve() == base_dir.resolve()
+        # exists() says True (this caused the old bug)
+        assert file_path.exists()
+        # is_file() says False (this is the fix)
+        assert not file_path.is_file()
+
+    def test_archive_file_path_empty_string_is_falsy(self):
+        """Empty string file_path is falsy (used for photo capture guard)."""
+        file_path = ""
+        assert not file_path
+
+    def test_archive_file_path_real_is_truthy(self):
+        """Real file_path is truthy."""
+        file_path = "archive/2026/02/test.3mf"
+        assert file_path
+
+
+class TestPhotoPathDerivation:
+    """Verify that photo directory derivation is safe with empty file_path."""
+
+    def test_empty_file_path_parent_is_dot(self):
+        """Path('').parent is '.' — would resolve to base_dir instead of archive dir."""
+        parent = Path("").parent
+        assert str(parent) == "."
+
+    def test_real_file_path_parent_is_archive_dir(self):
+        """Real file_path parent gives the correct archive directory."""
+        parent = Path("archive/2026/02/test.3mf").parent
+        assert str(parent) == "archive/2026/02"

+ 2 - 0
backend/tests/unit/test_archive_filtering.py

@@ -754,6 +754,8 @@ class TestAttachTimelapseBackgroundConversion:
         mock_create_task.assert_called_once()
         # Verify task name includes archive ID
         assert "timelapse-convert-1" in mock_create_task.call_args[1]["name"]
+        # Close the unawaited coroutine to prevent GC warning
+        mock_create_task.call_args[0][0].close()
 
 
 class TestDeleteTimelapse:

+ 184 - 0
backend/tests/unit/test_bulk_spool_create.py

@@ -0,0 +1,184 @@
+"""Unit tests for bulk spool creation.
+
+Tests:
+- SpoolBulkCreate schema validation (quantity bounds)
+- Bulk create endpoint creates the requested number of spools
+- Bulk create with quantity=1 (single spool)
+- Bulk create returns spools with k_profiles loaded
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate
+
+# ── Schema Validation ──────────────────────────────────────────────────────
+
+
+class TestSpoolBulkCreateSchema:
+    """Tests for the SpoolBulkCreate Pydantic model."""
+
+    def test_default_quantity_is_1(self):
+        data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"))
+        assert data.quantity == 1
+
+    def test_quantity_within_range(self):
+        data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=50)
+        assert data.quantity == 50
+
+    def test_quantity_max_100(self):
+        data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=100)
+        assert data.quantity == 100
+
+    def test_quantity_zero_rejected(self):
+        with pytest.raises(ValidationError, match="greater than or equal to 1"):
+            SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=0)
+
+    def test_quantity_negative_rejected(self):
+        with pytest.raises(ValidationError, match="greater than or equal to 1"):
+            SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=-1)
+
+    def test_quantity_over_100_rejected(self):
+        with pytest.raises(ValidationError, match="less than or equal to 100"):
+            SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=101)
+
+    def test_spool_fields_preserved(self):
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(
+                material="PETG",
+                brand="Polymaker",
+                subtype="Basic",
+                color_name="Red",
+                rgba="FF0000FF",
+                label_weight=750,
+                note="Test batch",
+            ),
+            quantity=5,
+        )
+        assert data.spool.material == "PETG"
+        assert data.spool.brand == "Polymaker"
+        assert data.spool.label_weight == 750
+        assert data.spool.note == "Test batch"
+        assert data.quantity == 5
+
+    def test_spool_without_slicer_filament_is_stock(self):
+        """A spool without slicer_filament is a 'stock' spool (computed, not stored)."""
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PLA", label_weight=1000),
+            quantity=3,
+        )
+        assert data.spool.slicer_filament is None
+
+    def test_spool_with_slicer_filament_is_configured(self):
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PLA", slicer_filament="GFL99"),
+            quantity=2,
+        )
+        assert data.spool.slicer_filament == "GFL99"
+
+    def test_material_required(self):
+        with pytest.raises(ValidationError):
+            SpoolBulkCreate(spool=SpoolCreate(material=""), quantity=1)
+
+
+# ── Endpoint Logic ─────────────────────────────────────────────────────────
+
+
+def _make_mock_spool(spool_id):
+    """Create a mock Spool ORM object."""
+    spool = MagicMock()
+    spool.id = spool_id
+    spool.material = "PLA"
+    spool.label_weight = 1000
+    spool.k_profiles = []
+    return spool
+
+
+class TestBulkCreateEndpoint:
+    """Tests for the bulk_create_spools endpoint logic."""
+
+    @pytest.mark.asyncio
+    async def test_creates_requested_number_of_spools(self):
+        """Verify N spools are created and added to the session."""
+        from backend.app.api.routes.inventory import bulk_create_spools
+
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PLA", brand="Test", label_weight=1000),
+            quantity=3,
+        )
+
+        db = AsyncMock()
+        added_objects = []
+        db.add = lambda obj: added_objects.append(obj)
+
+        # Mock the re-fetch query
+        mock_result = MagicMock()
+        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]
+        mock_result.scalars.return_value.all.return_value = mock_spools
+        db.execute = AsyncMock(return_value=mock_result)
+
+        result = await bulk_create_spools(data=data, db=db, _=None)
+
+        assert len(result) == 3
+        assert len(added_objects) == 3
+        db.commit.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_single_quantity_creates_one_spool(self):
+        """Bulk create with quantity=1 should create exactly one spool."""
+        from backend.app.api.routes.inventory import bulk_create_spools
+
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PETG"),
+            quantity=1,
+        )
+
+        db = AsyncMock()
+        added_objects = []
+        db.add = lambda obj: added_objects.append(obj)
+
+        mock_result = MagicMock()
+        mock_spools = [_make_mock_spool(1)]
+        mock_result.scalars.return_value.all.return_value = mock_spools
+        db.execute = AsyncMock(return_value=mock_result)
+
+        result = await bulk_create_spools(data=data, db=db, _=None)
+
+        assert len(result) == 1
+        assert len(added_objects) == 1
+
+    @pytest.mark.asyncio
+    async def test_all_spools_have_same_fields(self):
+        """All created spools should have identical field values."""
+        from backend.app.api.routes.inventory import bulk_create_spools
+
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(
+                material="ABS",
+                brand="Bambu Lab",
+                color_name="Black",
+                rgba="000000FF",
+                label_weight=750,
+            ),
+            quantity=3,
+        )
+
+        db = AsyncMock()
+        added_objects = []
+        db.add = lambda obj: added_objects.append(obj)
+
+        mock_result = MagicMock()
+        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]
+        mock_result.scalars.return_value.all.return_value = mock_spools
+        db.execute = AsyncMock(return_value=mock_result)
+
+        await bulk_create_spools(data=data, db=db, _=None)
+
+        # All added Spool objects should have the same material/brand/color
+        for spool_obj in added_objects:
+            assert spool_obj.material == "ABS"
+            assert spool_obj.brand == "Bambu Lab"
+            assert spool_obj.color_name == "Black"
+            assert spool_obj.label_weight == 750

+ 776 - 0
backend/tests/unit/test_cost_tracking.py

@@ -0,0 +1,776 @@
+"""Unit tests for cost tracking in usage_tracker.py.
+
+Tests cost calculation scenarios:
+- Spool-specific cost_per_kg
+- Default fallback cost from settings
+- Spools without cost (None)
+- Completed prints
+- Failed/partial prints
+- Cost aggregation to archives
+"""
+
+import os
+import tempfile
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.usage_tracker import (
+    PrintSession,
+    _active_sessions,
+    _track_from_3mf,
+    on_print_complete,
+)
+
+
+def _make_spool(spool_id=1, label_weight=1000, weight_used=0, cost_per_kg=None):
+    """Create a mock Spool object with cost fields."""
+    spool = MagicMock()
+    spool.id = spool_id
+    spool.label_weight = label_weight
+    spool.weight_used = weight_used
+    spool.cost_per_kg = cost_per_kg
+    spool.last_used = None
+    spool.material = "PLA"
+    return spool
+
+
+def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
+    """Create a mock SpoolAssignment object."""
+    assignment = MagicMock()
+    assignment.spool_id = spool_id
+    assignment.printer_id = printer_id
+    assignment.ams_id = ams_id
+    assignment.tray_id = tray_id
+    return assignment
+
+
+def _make_archive(archive_id=1, file_path=None):
+    """Create a mock PrintArchive object with a temp file, and register cleanup."""
+    if file_path is None:
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", prefix="test_print_") as tmp:
+            file_path = tmp.name
+        # Register cleanup for this file after the test
+        import pytest
+
+        frame = None
+        try:
+            raise Exception
+        except Exception:
+            import sys
+
+            frame = sys._getframe(1)
+        request = frame.f_locals.get("request")
+        if request is not None:
+
+            def cleanup():
+                try:
+                    os.remove(file_path)
+                except Exception:
+                    pass
+
+            request.addfinalizer(cleanup)
+    archive = MagicMock()
+    archive.id = archive_id
+    archive.file_path = file_path
+    return archive
+
+
+@pytest.fixture(autouse=True)
+def cleanup_temp_archives():
+    yield
+    # Cleanup any temp .3mf files created by _make_archive
+    import glob
+
+    for f in glob.glob("test_print_*.3mf"):
+        try:
+            os.remove(f)
+        except Exception:
+            pass
+
+
+@pytest.fixture(autouse=True)
+def cleanup_test_print_gcode():
+    yield
+    import os
+
+    path = "archives/test/test_print.gcode.3mf"
+    if os.path.exists(path):
+        try:
+            os.remove(path)
+        except Exception:
+            pass
+
+
+@pytest.fixture
+def archive_factory_temp():
+    import tempfile
+
+    def _factory(*args, **kwargs):
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", prefix="test_print_", dir="archives/test") as tmp:
+            kwargs["file_path"] = tmp.name
+        return kwargs["file_path"]
+
+    yield _factory
+    # Cleanup
+    import glob
+    import os
+
+    for f in glob.glob("archives/test/test_print_*.3mf"):
+        try:
+            os.remove(f)
+        except Exception:
+            pass
+
+
+def _mock_db_sequential(responses):
+    """Create mock db that returns responses in order."""
+    db = AsyncMock()
+    call_count = [0]
+
+    async def mock_execute(*args, **kwargs):
+        idx = call_count[0]
+        call_count[0] += 1
+        result = MagicMock()
+        if idx < len(responses):
+            result.scalar_one_or_none.return_value = responses[idx]
+        else:
+            result.scalar_one_or_none.return_value = None
+        return result
+
+    db.execute = mock_execute
+    return db
+
+
+class TestCostCalculation:
+    """Tests for cost calculation in usage tracking."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_cost_with_spool_specific_cost_per_kg(self):
+        """Cost is calculated using spool-specific cost_per_kg when available."""
+        # Spool with cost_per_kg = 25.00 USD/kg
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # 20g used from 3MF
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),  # default cost
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 20.0
+        # Cost = 20g / 1000 * 25.0 = 0.50
+        assert results[0]["cost"] == 0.50
+
+    @pytest.mark.asyncio
+    async def test_cost_with_default_fallback(self):
+        """Cost uses default_filament_cost from settings when spool cost is None."""
+        # Spool without cost_per_kg
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # 30g used from 3MF
+        filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),  # default: 15.0/kg
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 30.0
+        # Cost = 30g / 1000 * 15.0 = 0.45
+        assert results[0]["cost"] == 0.45
+
+    @pytest.mark.asyncio
+    async def test_cost_zero_when_default_cost_is_zero(self):
+        """Cost is None when both spool cost and default cost are 0."""
+        # Spool without cost_per_kg
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="0.0"),  # no default cost
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        assert results[0]["cost"] is None
+
+    @pytest.mark.asyncio
+    async def test_cost_for_failed_print_uses_actual_usage(self):
+        """Failed print at 50% progress calculates cost from actual usage."""
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        # Failed at 50% progress
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=50,
+            layer_num=25,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # 40g total, but only 50% used
+        filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value=None,  # No layer data, use linear scaling
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "failed", "last_progress": 50.0},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        # 50% of 40g = 20g
+        assert results[0]["weight_used"] == 20.0
+        # Cost = 20g / 1000 * 20.0 = 0.40
+        assert results[0]["cost"] == 0.40
+
+    @pytest.mark.asyncio
+    async def test_cost_with_ams_fallback_tracking(self):
+        """AMS fallback tracking also calculates cost correctly."""
+        spool = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=30.0)
+        assignment = _make_assignment(spool_id=2)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            tray_now=0,
+            last_loaded_tray=-1,
+        )
+
+        # db returns assignment then spool (no archive, AMS fallback path)
+        db = _mock_db_sequential([assignment, spool])
+
+        with patch("backend.app.api.routes.settings.get_setting", return_value="15.0"):
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=None,  # No archive = AMS fallback
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 2
+        # 10% of 1000g = 100g
+        assert results[0]["weight_used"] == 100.0
+        # Cost = 100g / 1000 * 30.0 = 3.00
+        assert results[0]["cost"] == 3.0
+
+    @pytest.mark.asyncio
+    async def test_multi_filament_cost_aggregation(self):
+        """Multiple spools in one print have their costs tracked separately."""
+        spool1 = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
+        spool2 = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=25.0)
+        assignment1 = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
+        assignment2 = _make_assignment(spool_id=2, ams_id=0, tray_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80, (0, 1): 90},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}, {"id": 1, "remain": 80}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # Mock slot-to-tray mapping: slot 1 -> tray 0, slot 2 -> tray 1
+        ams_mapping = [0, 1]
+
+        # db returns: archive, assignment1, spool1, assignment2, spool2
+        # ams_mapping is provided, so no queue item lookup is performed
+        db = _mock_db_sequential([archive, assignment1, spool1, assignment2, spool2])
+
+        # Two filaments used
+        filament_usage = [
+            {"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"},
+            {"slot_id": 2, "used_g": 25.0, "type": "PLA", "color": "#00FF00"},
+        ]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+                ams_mapping=ams_mapping,
+            )
+
+        assert len(results) == 2
+
+        # First spool: 15g at 20/kg = 0.30
+        spool1_result = next(r for r in results if r["spool_id"] == 1)
+        assert spool1_result["weight_used"] == 15.0
+        assert spool1_result["cost"] == 0.30
+
+        # Second spool: 25g at 25/kg = 0.625, rounded to 0.62
+        spool2_result = next(r for r in results if r["spool_id"] == 2)
+        assert spool2_result["weight_used"] == 25.0
+        assert spool2_result["cost"] == 0.62
+
+
+class TestCostAggregation:
+    """Tests for cost aggregation to PrintArchive."""
+
+    @pytest.mark.asyncio
+    async def test_costs_summed_in_results(self):
+        """Multiple spool costs are correctly summed from result dicts."""
+        results = [
+            {"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
+            {"spool_id": 2, "weight_used": 30.0, "cost": 0.75},
+        ]
+
+        total_cost = sum(r.get("cost", 0) or 0 for r in results)
+        assert total_cost == 1.25
+
+    @pytest.mark.asyncio
+    async def test_null_costs_handled_in_aggregation(self):
+        """None costs don't break aggregation."""
+        results = [
+            {"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
+            {"spool_id": 2, "weight_used": 30.0, "cost": None},  # No cost
+            {"spool_id": 3, "weight_used": 10.0, "cost": 0.25},
+        ]
+
+        total_cost = sum(r.get("cost", 0) or 0 for r in results)
+        assert total_cost == 0.75  # Only spools 1 and 3
+
+    @pytest.mark.asyncio
+    async def test_archive_cost_not_overwritten_with_zero(self):
+        """archive.cost is preserved when no spool usage has cost data."""
+        # Spool without cost_per_kg, default_filament_cost also 0 → cost=None per usage
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+        archive.cost = 5.00  # Pre-existing cost from catalog
+        archive.print_name = "TestPrint"
+        archive.printer_id = 1
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="TestPrint",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # Build mock db that returns proper scalars for the aggregation queries
+        responses = []
+        # 1. select(PrintArchive) → archive
+        responses.append(("scalar_one_or_none", archive))
+        # 2. select(PrintQueueItem) → None
+        responses.append(("scalar_one_or_none", None))
+        # 3. select(SpoolAssignment) → assignment
+        responses.append(("scalar_one_or_none", assignment))
+        # 4. select(Spool) → spool
+        responses.append(("scalar_one_or_none", spool))
+        # 5. cost aggregation: select archive to update cost
+        responses.append(("scalar_one_or_none", archive))
+
+        db = AsyncMock()
+        call_count = [0]
+
+        async def mock_execute(*args, **kwargs):
+            idx = call_count[0]
+            call_count[0] += 1
+            result = MagicMock()
+            if idx < len(responses):
+                method, value = responses[idx]
+                if method == "scalar":
+                    result.scalar.return_value = value
+                    result.scalar_one_or_none.return_value = value
+                else:
+                    result.scalar_one_or_none.return_value = value
+                    result.scalar.return_value = value
+            else:
+                result.scalar_one_or_none.return_value = None
+                result.scalar.return_value = None
+            return result
+
+        db.execute = mock_execute
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="0.0"),  # no default cost
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        # Usage tracked but cost is None (no cost_per_kg, no default)
+        assert len(results) == 1
+        assert results[0]["cost"] is None
+
+        # Archive cost should NOT have been overwritten — still 5.00
+        assert archive.cost == 5.00
+
+    @pytest.mark.asyncio
+    async def test_archive_cost_set_when_spool_has_cost(self):
+        """archive.cost is set from spool usage when cost data exists."""
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+        archive.cost = None  # No pre-existing cost
+        archive.print_name = "TestPrint"
+        archive.printer_id = 1
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="TestPrint",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # 20g at 25/kg = 0.50
+        expected_cost = 0.50
+
+        responses = []
+        responses.append(("scalar_one_or_none", archive))
+        responses.append(("scalar_one_or_none", None))  # queue item
+        responses.append(("scalar_one_or_none", assignment))
+        responses.append(("scalar_one_or_none", spool))
+        # cost aggregation: select archive to update cost
+        responses.append(("scalar_one_or_none", archive))
+
+        db = AsyncMock()
+        call_count = [0]
+
+        async def mock_execute(*args, **kwargs):
+            idx = call_count[0]
+            call_count[0] += 1
+            result = MagicMock()
+            if idx < len(responses):
+                method, value = responses[idx]
+                result.scalar.return_value = value
+                result.scalar_one_or_none.return_value = value
+            else:
+                result.scalar_one_or_none.return_value = None
+                result.scalar.return_value = None
+            return result
+
+        db.execute = mock_execute
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        assert results[0]["cost"] == expected_cost
+        # Archive cost should have been updated
+        assert archive.cost == expected_cost
+
+    @pytest.mark.asyncio
+    async def test_cost_with_archive_id(self):
+        """Test cost aggregation using archive_id (3MF path)."""
+        spool_new = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
+        assignment_new = _make_assignment(spool_id=1)
+        archive_new = _make_archive(archive_id=20)
+        filament_usage_new = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        db = _mock_db_sequential([archive_new, None, assignment_new, spool_new])
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage_new),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results_new = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=20,
+            )
+
+        assert len(results_new) == 1
+        assert results_new[0]["spool_id"] == 1
+        assert results_new[0]["cost"] == 0.50  # 20g / 1000 * 25.0
+
+    @pytest.mark.asyncio
+    async def test_cost_with_print_name_ams_fallback(self):
+        """Test cost aggregation using print_name (AMS fallback, legacy path)."""
+        spool_old = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=15.0)
+        assignment_old = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        legacy_print_name = "LegacyPrint"
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name=legacy_print_name,
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        db = _mock_db_sequential([assignment_old, spool_old])
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=None),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results_old = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed", "subtask_name": legacy_print_name, "filename": legacy_print_name},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=None,
+            )
+
+        assert len(results_old) == 1
+        assert results_old[0]["spool_id"] == 2
+        assert results_old[0]["cost"] == 1.5  # 100g / 1000 * 15.0

+ 76 - 0
backend/tests/unit/test_permissions.py

@@ -0,0 +1,76 @@
+"""Tests for the permission system definitions and consistency."""
+
+from backend.app.core.permissions import (
+    ALL_PERMISSIONS,
+    DEFAULT_GROUPS,
+    PERMISSION_CATEGORIES,
+    Permission,
+)
+
+
+class TestPermissionEnum:
+    """Test the Permission enum values."""
+
+    def test_clear_plate_permission_exists(self):
+        """printers:clear_plate permission should exist in the enum."""
+        assert hasattr(Permission, "PRINTERS_CLEAR_PLATE")
+        assert Permission.PRINTERS_CLEAR_PLATE == "printers:clear_plate"
+
+    def test_clear_plate_in_all_permissions(self):
+        """printers:clear_plate should be in ALL_PERMISSIONS list."""
+        assert "printers:clear_plate" in ALL_PERMISSIONS
+
+    def test_clear_plate_in_printers_category(self):
+        """printers:clear_plate should be in the Printers permission category."""
+        printers_perms = PERMISSION_CATEGORIES["Printers"]
+        assert Permission.PRINTERS_CLEAR_PLATE in printers_perms
+
+    def test_clear_plate_separate_from_control(self):
+        """clear_plate and control should be distinct permissions."""
+        assert Permission.PRINTERS_CLEAR_PLATE != Permission.PRINTERS_CONTROL
+        assert Permission.PRINTERS_CLEAR_PLATE.value != Permission.PRINTERS_CONTROL.value
+
+
+class TestDefaultGroups:
+    """Test the default group definitions."""
+
+    def test_operators_have_clear_plate(self):
+        """Operators group should include printers:clear_plate."""
+        operators = DEFAULT_GROUPS["Operators"]
+        assert "printers:clear_plate" in operators["permissions"]
+
+    def test_operators_have_control_and_clear_plate(self):
+        """Operators group should have both printers:control and printers:clear_plate."""
+        operators = DEFAULT_GROUPS["Operators"]
+        assert "printers:control" in operators["permissions"]
+        assert "printers:clear_plate" in operators["permissions"]
+
+    def test_administrators_have_all_permissions(self):
+        """Administrators should have all permissions including clear_plate."""
+        admins = DEFAULT_GROUPS["Administrators"]
+        assert "printers:clear_plate" in admins["permissions"]
+
+    def test_viewers_do_not_have_clear_plate(self):
+        """Viewers group (read-only) should not include printers:clear_plate."""
+        viewers = DEFAULT_GROUPS["Viewers"]
+        assert "printers:clear_plate" not in viewers["permissions"]
+
+
+class TestPermissionCategoriesCompleteness:
+    """Test that all enum permissions appear in exactly one category."""
+
+    def test_all_permissions_categorized(self):
+        """Every Permission enum member should appear in a category."""
+        categorized = set()
+        for perms in PERMISSION_CATEGORIES.values():
+            categorized.update(perms)
+        for perm in Permission:
+            assert perm in categorized, f"{perm} not in any category"
+
+    def test_no_duplicate_categorization(self):
+        """No permission should appear in multiple categories."""
+        seen = {}
+        for cat_name, perms in PERMISSION_CATEGORIES.items():
+            for perm in perms:
+                assert perm not in seen, f"{perm} in both '{seen[perm]}' and '{cat_name}'"
+                seen[perm] = cat_name

+ 363 - 11
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -474,12 +474,17 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         assert result[0]["is_external"] is True
 
 
-def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
+def _make_3mf_zip(
+    project_settings: dict | None = None,
+    slice_info_xml: str | None = None,
+) -> zipfile.ZipFile:
     """Create an in-memory ZipFile mimicking a 3MF with project_settings.config."""
     buf = io.BytesIO()
     with zipfile.ZipFile(buf, "w") as zf:
         if project_settings is not None:
             zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+        if slice_info_xml is not None:
+            zf.writestr("Metadata/slice_info.config", slice_info_xml)
     buf.seek(0)
     return zipfile.ZipFile(buf, "r")
 
@@ -487,8 +492,55 @@ def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
 class TestExtractNozzleMappingFrom3mf:
     """Test the extract_nozzle_mapping_from_3mf utility."""
 
-    def test_dual_nozzle_mapping(self):
-        """Should return slot->extruder mapping for dual-nozzle files."""
+    def test_group_id_priority_over_filament_nozzle_map(self):
+        """group_id from slice_info should override filament_nozzle_map from project_settings.
+
+        Real-world scenario: "Auto For Flush" mode sets filament_nozzle_map all to 0
+        (user preference) but the actual assignment in slice_info has different group_ids.
+        """
+        # filament_nozzle_map says all on slicer ext 0 → MQTT ext 1 (LEFT)
+        # But slice_info group_id says slot 6 → group 0 (LEFT), slot 12 → group 1 (RIGHT)
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="6" type="PLA" color="#56B7E6" used_g="1.84" group_id="0"/>
+            <filament id="12" type="PLA" color="#B39B84" used_g="1.76" group_id="1"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0"] * 12,
+                "physical_extruder_map": ["1", "0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)
+        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
+        assert result == {6: 1, 12: 0}
+        zf.close()
+
+    def test_fallback_to_filament_nozzle_map_without_group_id(self):
+        """Should fall back to filament_nozzle_map when slice_info has no group_id."""
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="PLA" color="#FF0000" used_g="5.0"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1", "0"],
+                "physical_extruder_map": ["0", "1"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 0, 2: 1, 3: 0}
+        zf.close()
+
+    def test_fallback_to_filament_nozzle_map_without_slice_info(self):
+        """Should fall back to filament_nozzle_map when no slice_info.config exists."""
         zf = _make_3mf_zip(
             {
                 "filament_nozzle_map": ["0", "1", "0"],
@@ -500,7 +552,7 @@ class TestExtractNozzleMappingFrom3mf:
         zf.close()
 
     def test_single_nozzle_returns_none(self):
-        """All slots on same extruder should return None (single-nozzle)."""
+        """Single physical_extruder_map entry should return None (single-nozzle)."""
         zf = _make_3mf_zip(
             {
                 "filament_nozzle_map": ["0", "0", "0"],
@@ -519,7 +571,7 @@ class TestExtractNozzleMappingFrom3mf:
         zf.close()
 
     def test_missing_fields_returns_none(self):
-        """Missing filament_nozzle_map or physical_extruder_map should return None."""
+        """Missing physical_extruder_map should return None."""
         zf = _make_3mf_zip({"some_other_key": "value"})
         result = extract_nozzle_mapping_from_3mf(zf)
         assert result is None
@@ -562,8 +614,8 @@ class TestNozzleAwareMapping:
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [0, 4]
 
-    def test_nozzle_fallback_when_no_match(self, scheduler):
-        """Should fall back to unfiltered list when nozzle-filtered list is empty."""
+    def test_nozzle_hard_filter_no_fallback(self, scheduler):
+        """Hard filter: no fallback to wrong nozzle when target nozzle has no trays."""
         required = [
             {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
         ]
@@ -571,9 +623,9 @@ class TestNozzleAwareMapping:
             # Only a tray on the left nozzle, none on right
             {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
         ]
-        # No trays on extruder 0, so fallback to unfiltered -> should still match
+        # No trays on extruder 0 — hard filter returns -1, no cross-nozzle fallback
         result = scheduler._match_filaments_to_slots(required, loaded)
-        assert result == [4]
+        assert result == [-1]
 
     def test_no_nozzle_id_skips_filtering(self, scheduler):
         """When nozzle_id is None, no nozzle filtering should be applied."""
@@ -620,7 +672,7 @@ class TestNozzleAwareMapping:
         assert result[0]["extruder_id"] is None
 
     def test_external_spool_extruder_id(self, scheduler):
-        """External spool should have extruder_id=0 when ams_extruder_map exists."""
+        """External spool 254 (Ext-L) should have extruder_id=1 (LEFT) when ams_extruder_map exists."""
 
         class MockStatus:
             raw_data = {
@@ -630,7 +682,8 @@ class TestNozzleAwareMapping:
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
-        assert result[0]["extruder_id"] == 0
+        # Default vt_tray id=254 → Ext-L → LEFT nozzle (extruder 1)
+        assert result[0]["extruder_id"] == 1
         assert result[0]["is_external"] is True
 
     def test_external_spool_no_extruder_map(self, scheduler):
@@ -655,3 +708,302 @@ class TestNozzleAwareMapping:
         ]
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [0, 4]
+
+
+# ============================================================================
+# MODEL-SPECIFIC TESTS: Real data from actual printers
+# ============================================================================
+
+
+def _h2d_raw_data():
+    """H2D real data fixture (from live API response 2026-02-18).
+
+    Configuration:
+        LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
+        RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
+        External: 254 (Ext-L, LEFT), 255 (Ext-R, RIGHT, empty)
+
+    ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
+    """
+    return {
+        "ams": [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0, "tray_type": "PETG", "tray_color": "FFFFFFFF", "tray_info_idx": "GFG02"},
+                    {"id": 1, "tray_type": "PLA", "tray_color": "C8C8C8FF", "tray_info_idx": "GFA06"},
+                    {"id": 2, "tray_type": "PETG", "tray_color": "875718FF", "tray_info_idx": "GFG02"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFA00"},
+                ],
+            },
+            {
+                "id": 1,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                    {"id": 1, "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG02"},
+                    {"id": 2, "tray_type": "PLA", "tray_color": "5F6367FF", "tray_info_idx": "GFA06"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "B39B84FF", "tray_info_idx": "GFA02"},
+                ],
+            },
+            {
+                "id": 128,
+                "tray": [{"id": 0}],  # AMS-HT, empty
+            },
+            {
+                "id": 2,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA-S", "tray_color": "FFFFFFFF", "tray_info_idx": "P8aa1726"},
+                    {"id": 1, "tray_type": "PLA", "tray_color": "56B7E6FF", "tray_info_idx": "PFUS9924"},
+                    {"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "tray_info_idx": "GFG02"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "PFUS9ac9"},
+                ],
+            },
+        ],
+        "vt_tray": [
+            {"id": 254, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "P4d64437"},
+            {"id": 255, "tray_type": "", "tray_color": "00000000"},  # empty
+        ],
+        "ams_extruder_map": {"0": 1, "1": 0, "2": 1, "128": 0},
+    }
+
+
+def _x1c_raw_data():
+    """X1C real data fixture (from live API response 2026-02-18).
+
+    Configuration:
+        Single nozzle (extruder 0): AMS 0 (4-slot, all empty), AMS 1 (4-slot, 3 loaded)
+        External: 254 (single, empty)
+
+    ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
+    """
+    return {
+        "ams": [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0},  # empty
+                    {"id": 1},  # empty
+                    {"id": 2},  # empty
+                    {"id": 3},  # empty
+                ],
+            },
+            {
+                "id": 1,
+                "tray": [
+                    {"id": 0},  # empty
+                    {"id": 1, "tray_type": "PLA", "tray_color": "EBCFA6FF", "tray_info_idx": "PFUS22b2"},
+                    {"id": 2, "tray_type": "PLA", "tray_color": "FCECD6FF", "tray_info_idx": "P4d64437"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "0066FFFF", "tray_info_idx": "P4d64437"},
+                ],
+            },
+        ],
+        "vt_tray": [
+            {"id": 254, "tray_type": "", "tray_color": "00000000"},  # empty
+        ],
+        "ams_extruder_map": {"0": 0, "1": 0},
+    }
+
+
+class TestH2DModel:
+    """H2D-specific tests with real printer data (dual nozzle, AMS-HT)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_h2d(self, scheduler):
+        """H2D: correct extruder_id, global_tray_id, AMS-HT handling."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+
+        # Should have 13 loaded filaments (4 + 4 + 0 + 4 + 1 external)
+        assert len(result) == 13
+
+        # AMS 0 trays → extruder 1 (LEFT)
+        ams0 = [f for f in result if f["ams_id"] == 0]
+        assert len(ams0) == 4
+        assert all(f["extruder_id"] == 1 for f in ams0)
+        assert [f["global_tray_id"] for f in ams0] == [0, 1, 2, 3]
+
+        # AMS 1 trays → extruder 0 (RIGHT)
+        ams1 = [f for f in result if f["ams_id"] == 1]
+        assert len(ams1) == 4
+        assert all(f["extruder_id"] == 0 for f in ams1)
+        assert [f["global_tray_id"] for f in ams1] == [4, 5, 6, 7]
+
+        # AMS-HT 128 → empty, should not appear
+        ams_ht = [f for f in result if f["ams_id"] == 128]
+        assert len(ams_ht) == 0
+
+        # AMS 2 trays → extruder 1 (LEFT)
+        ams2 = [f for f in result if f["ams_id"] == 2]
+        assert len(ams2) == 4
+        assert all(f["extruder_id"] == 1 for f in ams2)
+        assert [f["global_tray_id"] for f in ams2] == [8, 9, 10, 11]
+
+    def test_external_spool_extruder_h2d(self, scheduler):
+        """H2D: Ext-L (254) = LEFT (extruder 1), Ext-R (255) = RIGHT (extruder 0)."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        ext = [f for f in result if f["is_external"]]
+        assert len(ext) == 1  # Only 254 has filament
+        assert ext[0]["global_tray_id"] == 254
+        # Ext-L (254) should be LEFT nozzle (extruder 1)
+        assert ext[0]["extruder_id"] == 1
+
+    def test_match_left_nozzle_only(self, scheduler):
+        """H2D: left-nozzle requirement only matches left-nozzle AMS."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1},  # LEFT
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Black PLA on LEFT: AMS 0 T4 (global 3)
+        assert result == [3]
+
+    def test_match_right_nozzle_only(self, scheduler):
+        """H2D: right-nozzle requirement only matches right-nozzle AMS."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0},  # RIGHT
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # White PLA on RIGHT: AMS 1 T1 (global 4)
+        assert result == [4]
+
+    def test_reject_cross_nozzle(self, scheduler):
+        """H2D: hard filter rejects cross-nozzle assignment."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        # PLA-S only exists on AMS 2 T1 (LEFT), require on RIGHT
+        required = [
+            {"slot_id": 1, "type": "PLA-S", "color": "#FFFFFF", "nozzle_id": 0},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [-1]  # No fallback to wrong nozzle
+
+    def test_dual_nozzle_multi_filament(self, scheduler):
+        """H2D: multi-filament print maps to correct nozzles."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PETG", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": "GFG02"},
+            {"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0, "tray_info_idx": "GFA00"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # PETG white on LEFT: AMS 0 T1 (global 0)
+        # PLA white on RIGHT: AMS 1 T1 (global 4)
+        assert result == [0, 4]
+
+    def test_external_spool_matches_on_correct_nozzle(self, scheduler):
+        """H2D: external spool on left nozzle matches left-nozzle requirement."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1, "tray_info_idx": "P4d64437"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [254]  # External spool on left nozzle
+
+
+class TestX1CModel:
+    """X1C-specific tests with real printer data (single nozzle, 2x regular AMS)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_x1c(self, scheduler):
+        """X1C: all filaments on extruder 0, correct global_tray_id."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+
+        # Only 3 loaded (AMS 1 trays 1-3)
+        assert len(result) == 3
+        # All on extruder 0
+        assert all(f["extruder_id"] == 0 for f in result)
+        # Correct global tray IDs
+        assert [f["global_tray_id"] for f in result] == [5, 6, 7]
+
+    def test_single_nozzle_no_filtering(self, scheduler):
+        """X1C: single-nozzle 3MF has no nozzle_id, all trays available."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#0066FF"},  # No nozzle_id
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Blue PLA → AMS 1 T4 (global 7)
+        assert result == [7]
+
+    def test_tray_info_idx_matching_x1c(self, scheduler):
+        """X1C: tray_info_idx matching works across AMS units."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#EBCFA6", "tray_info_idx": "PFUS22b2"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Unique tray_info_idx → AMS 1 T2 (global 5)
+        assert result == [5]
+
+    def test_non_unique_tray_info_idx_color_match_x1c(self, scheduler):
+        """X1C: non-unique tray_info_idx falls back to color matching."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        # P4d64437 appears in AMS 1 T3 and T4
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FCECD6", "tray_info_idx": "P4d64437"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Should pick AMS 1 T3 (global 6, color FCECD6) over T4 (0066FF)
+        assert result == [6]
+
+    def test_multi_filament_x1c(self, scheduler):
+        """X1C: multi-filament print matches freely across AMS units."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#EBCFA6"},
+            {"slot_id": 2, "type": "PLA", "color": "#0066FF"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [5, 7]

+ 215 - 0
backend/tests/unit/test_scheduler_filament_override.py

@@ -0,0 +1,215 @@
+"""Tests for the filament override feature in the print scheduler."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+class TestCountOverrideColorMatches:
+    """Test the _count_override_color_matches method."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_status_returns_zero(self, mock_pm, scheduler):
+        """When printer_manager.get_status() returns None, should return 0."""
+        mock_pm.get_status.return_value = None
+
+        result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
+        assert result == 0
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_exact_match(self, mock_pm, scheduler):
+        """Override with matching type+color on printer returns 1."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
+        assert result == 1
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_match(self, mock_pm, scheduler):
+        """Override with type+color not on printer returns 0."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        result = scheduler._count_override_color_matches(1, [{"type": "PETG", "color": "#00FF00"}])
+        assert result == 0
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_multiple_overrides_partial_match(self, mock_pm, scheduler):
+        """2 overrides, only 1 matching = returns 1."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        overrides = [
+            {"type": "PLA", "color": "#FF0000"},  # Matches
+            {"type": "PETG", "color": "#00FF00"},  # Does not match
+        ]
+        result = scheduler._count_override_color_matches(1, overrides)
+        assert result == 1
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_color_normalization(self, mock_pm, scheduler):
+        """Override color '#FF0000' matches printer tray_color 'FF0000FF' (with alpha)."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        # Override uses #-prefixed color; printer uses 8-char RGBA without hash
+        result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
+        assert result == 1
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_external_spool_match(self, mock_pm, scheduler):
+        """Override matches filament in vt_tray."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [],
+                "vt_tray": [{"tray_type": "TPU", "tray_color": "0000FFFF"}],
+            }
+        )
+
+        result = scheduler._count_override_color_matches(1, [{"type": "TPU", "color": "#0000FF"}])
+        assert result == 1
+
+
+class TestFilamentOverrideInMatching:
+    """Test that when overrides are applied to filament requirements, the matching uses overridden values."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _apply_overrides(self, filament_reqs, overrides):
+        """Simulate override application as done in _compute_ams_mapping_for_printer."""
+        override_map = {o["slot_id"]: o for o in overrides}
+        for req in filament_reqs:
+            if req["slot_id"] in override_map:
+                override = override_map[req["slot_id"]]
+                req["type"] = override["type"]
+                req["color"] = override["color"]
+                req["tray_info_idx"] = ""  # Clear for override
+        return filament_reqs
+
+    def test_override_changes_color_match(self, scheduler):
+        """Original req has color A, loaded has color B. Override to color B gives exact match."""
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
+        ]
+
+        # Without override: type-only match (colors differ)
+        result_without = scheduler._match_filaments_to_slots(filament_reqs, loaded)
+        assert result_without == [0]  # Matches by type only
+
+        # Now apply override changing color to match loaded
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
+        self._apply_overrides(filament_reqs_overridden, overrides)
+
+        result_with = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
+        assert result_with == [0]  # Exact color match now
+        # Verify the override actually changed the color in the requirement
+        assert filament_reqs_overridden[0]["color"] == "#FF0000"
+
+    def test_override_clears_tray_info_idx(self, scheduler):
+        """When tray_info_idx is cleared, matching falls to color-based instead of tray_info_idx-based."""
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 1, "tray_info_idx": "GFB00"},
+        ]
+
+        # Without override: tray_info_idx "GFA00" matches tray 0 (red)
+        filament_reqs_original = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA00"}]
+        result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)
+        assert result_original == [0]  # Matched by tray_info_idx
+
+        # With override: tray_info_idx is cleared, color changed to green -> matches tray 1
+        filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA00"}]
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#00FF00"}]
+        self._apply_overrides(filament_reqs_overridden, overrides)
+
+        assert filament_reqs_overridden[0]["tray_info_idx"] == ""  # Cleared
+        result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
+        assert result_overridden == [1]  # Now matches tray 1 by color
+
+    def test_override_type_change(self, scheduler):
+        """Override changes type from PLA to PETG, loaded has PETG -> matches."""
+        loaded = [
+            {"type": "PETG", "color": "#FF0000", "global_tray_id": 0},
+        ]
+
+        # Without override: PLA requirement, PETG loaded -> no match
+        filament_reqs_original = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)
+        assert result_original == [-1]  # Type mismatch
+
+        # With override: type changed to PETG -> matches
+        filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        overrides = [{"slot_id": 1, "type": "PETG", "color": "#FF0000"}]
+        self._apply_overrides(filament_reqs_overridden, overrides)
+
+        result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
+        assert result_overridden == [0]  # Exact match now
+
+    def test_partial_override(self, scheduler):
+        """2 slots, only slot 1 overridden. Slot 1 uses override, slot 2 uses original."""
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
+            {"type": "PETG", "color": "#00FF00", "global_tray_id": 1},
+        ]
+
+        filament_reqs = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
+            {"slot_id": 2, "type": "PETG", "color": "#00FF00", "tray_info_idx": "GFG02"},
+        ]
+
+        # Override only slot 1: change color to red
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        self._apply_overrides(filament_reqs, overrides)
+
+        # Slot 1: overridden to PLA/#FF0000, tray_info_idx cleared -> matches tray 0 by exact color
+        assert filament_reqs[0]["color"] == "#FF0000"
+        assert filament_reqs[0]["tray_info_idx"] == ""
+
+        # Slot 2: NOT overridden, retains original tray_info_idx
+        assert filament_reqs[1]["color"] == "#00FF00"
+        assert filament_reqs[1]["tray_info_idx"] == "GFG02"
+
+        result = scheduler._match_filaments_to_slots(filament_reqs, loaded)
+        assert result == [0, 1]  # Slot 1 -> tray 0 (red PLA), slot 2 -> tray 1 (green PETG)
+
+    def test_nozzle_filtering_with_override(self, scheduler):
+        """Override to a type only available on the wrong nozzle returns -1."""
+        loaded = [
+            # PETG on RIGHT nozzle (extruder 0) only
+            {"type": "PETG", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0},
+            # PLA on LEFT nozzle (extruder 1) only
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "extruder_id": 1},
+        ]
+
+        # Override to PETG on LEFT nozzle — but PETG is only on RIGHT
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 1}]
+        overrides = [{"slot_id": 1, "type": "PETG", "color": "#FF0000"}]
+        self._apply_overrides(filament_reqs, overrides)
+
+        result = scheduler._match_filaments_to_slots(filament_reqs, loaded)
+        # Nozzle filter limits to extruder 1 (LEFT) which only has PLA.
+        # Override changed type to PETG, so no type match on LEFT nozzle -> -1
+        assert result == [-1]

+ 101 - 0
backend/tests/unit/test_support_helpers.py

@@ -228,6 +228,107 @@ class TestFormatBytes:
         assert _format_bytes(0) == "0 B"
 
 
+class TestSanitizeLogContent:
+    """Tests for _sanitize_log_content() redaction."""
+
+    def test_ipv4_addresses_redacted(self):
+        """IPv4 addresses in log lines are replaced with [IP]."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "2024-01-15 Connected to printer at 192.168.1.100 on port 8883"
+        result = _sanitize_log_content(content)
+        assert "192.168.1.100" not in result
+        assert "[IP]" in result
+        assert "on port 8883" in result
+
+    def test_multiple_ipv4_addresses_redacted(self):
+        """Multiple different IPs in the same line are all redacted."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Proxy 10.0.0.1 -> 192.168.1.50"
+        result = _sanitize_log_content(content)
+        assert result == "Proxy [IP] -> [IP]"
+
+    def test_firmware_versions_with_leading_zeros_preserved(self):
+        """Firmware versions like 01.09.01.00 have leading zeros and should NOT be redacted."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Firmware version: 01.09.01.00"
+        result = _sanitize_log_content(content)
+        assert "01.09.01.00" in result
+
+    def test_firmware_version_mixed_with_ip(self):
+        """Firmware versions preserved while real IPs are redacted in the same line."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Printer at 192.168.1.5 running firmware 01.07.02.00"
+        result = _sanitize_log_content(content)
+        assert "192.168.1.5" not in result
+        assert "01.07.02.00" in result
+        assert "[IP] running firmware 01.07.02.00" in result
+
+    def test_printer_ip_from_sensitive_strings(self):
+        """Printer IPs in sensitive_strings are replaced before regex pass."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Connecting to 192.168.1.100"
+        result = _sanitize_log_content(content, sensitive_strings={"192.168.1.100": "[IP]"})
+        assert result == "Connecting to [IP]"
+
+    def test_edge_case_zero_ip(self):
+        """0.0.0.0 is a valid IP and should be redacted."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Binding to 0.0.0.0"
+        result = _sanitize_log_content(content)
+        assert result == "Binding to [IP]"
+
+    def test_edge_case_broadcast_ip(self):
+        """255.255.255.255 is a valid IP and should be redacted."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Broadcast to 255.255.255.255"
+        result = _sanitize_log_content(content)
+        assert result == "Broadcast to [IP]"
+
+    def test_invalid_octet_not_redacted(self):
+        """Octets >255 are not valid IPs and should not be redacted."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Value 999.999.999.999"
+        result = _sanitize_log_content(content)
+        assert "999.999.999.999" in result
+
+    def test_existing_serial_redaction_still_works(self):
+        """Serial number redaction still functions alongside IP redaction."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Printer 01SABCDEF1234 at 10.0.0.5"
+        result = _sanitize_log_content(content)
+        assert "[SERIAL]" in result
+        assert "[IP]" in result
+        assert "01SABCDEF1234" not in result
+        assert "10.0.0.5" not in result
+
+    def test_existing_email_redaction_still_works(self):
+        """Email redaction still functions alongside IP redaction."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "User user@example.com from 172.16.0.1"
+        result = _sanitize_log_content(content)
+        assert "[EMAIL]" in result
+        assert "[IP]" in result
+
+    def test_existing_path_redaction_still_works(self):
+        """Path redaction still functions alongside IP redaction."""
+        from backend.app.api.routes.support import _sanitize_log_content
+
+        content = "Config at /home/john/config.yaml from 192.168.0.1"
+        result = _sanitize_log_content(content)
+        assert "/home/[user]/" in result
+        assert "[IP]" in result
+
+
 class TestCollectSupportInfo:
     """Tests for _collect_support_info() new diagnostic sections."""
 

+ 733 - 0
backend/tests/unit/test_usage_tracker.py

@@ -14,6 +14,8 @@ import pytest
 from backend.app.services.usage_tracker import (
     PrintSession,
     _active_sessions,
+    _decode_mqtt_mapping,
+    _match_slots_by_color,
     _track_from_3mf,
     on_print_complete,
     on_print_start,
@@ -29,6 +31,8 @@ def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray
     spool.tag_uid = tag_uid
     spool.tray_uuid = tray_uuid
     spool.last_used = None
+    spool.cost_per_kg = None
+    spool.material = "PLA"
     return spool
 
 
@@ -84,6 +88,8 @@ def _mock_db_sequential(responses):
             result.scalar_one_or_none.return_value = responses[idx]
         else:
             result.scalar_one_or_none.return_value = None
+        # For cost aggregation queries that use .scalar() instead of .scalar_one_or_none()
+        result.scalar.return_value = None
         return result
 
     db.execute = mock_execute
@@ -165,6 +171,15 @@ class TestOnPrintComplete:
         yield
         _active_sessions.clear()
 
+    @pytest.fixture(autouse=True)
+    def _mock_get_setting(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            yield
+
     @pytest.mark.asyncio
     async def test_bl_spool_uses_3mf(self):
         """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
@@ -839,6 +854,724 @@ class TestTrackFrom3mf:
         assert results[0]["tray_id"] == 1
 
 
+class TestTrayChangeSplit:
+    """Tests for mid-print tray switch weight splitting in _track_from_3mf()."""
+
+    @pytest.mark.asyncio
+    async def test_tray_switch_splits_weight_with_gcode(self):
+        """Two-tray runout: weight split using per-layer gcode data."""
+        spool_a = _make_spool(spool_id=10, label_weight=1000)
+        spool_b = _make_spool(spool_id=20, label_weight=1000)
+        assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=1)
+        assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=0)
+        archive = _make_archive(archive_id=100)
+
+        # db: archive, queue_item(None), then for each segment: assignment, spool
+        db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
+
+        # Tray change log: started on tray 1, switched to tray 0 at layer 60
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=100,
+            tray_now=0,
+            last_loaded_tray=0,
+            total_layers=100,
+            tray_change_log=[(1, 0), (0, 60)],
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value={30: {0: 3000.0}, 60: {0: 6000.0}, 100: {0: 10000.0}},
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
+                side_effect=lambda data, layer: {0: {0: 0.0, 60: 6000.0, 100: 10000.0}.get(layer, 0.0)},
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
+                return_value={1: {"density": 1.24, "diameter": 1.75}},
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.mm_to_grams",
+                side_effect=lambda mm, d, dens: round(mm * 0.003, 1),  # Simple conversion
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=100,
+                status="completed",
+                print_name="Runout Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        # Two results: one per tray segment
+        assert len(results) == 2
+        # First segment: tray 1 (AMS0-T1), layers 0→60
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 1
+        assert results[0]["spool_id"] == 10
+        assert results[0]["weight_used"] == 18.0  # 6000mm * 0.003
+        # Second segment: tray 0 (AMS0-T0), layers 60→end = 30.0 - 18.0 = 12.0
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 0
+        assert results[1]["spool_id"] == 20
+        assert results[1]["weight_used"] == 12.0
+        # Both trays handled
+        assert (0, 1) in handled_trays
+        assert (0, 0) in handled_trays
+
+    @pytest.mark.asyncio
+    async def test_tray_switch_linear_fallback(self):
+        """Two-tray runout without per-layer gcode: linear split by layer ratio."""
+        spool_a = _make_spool(spool_id=10, label_weight=1000)
+        spool_b = _make_spool(spool_id=20, label_weight=1000)
+        assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=2)
+        assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
+        archive = _make_archive(archive_id=101)
+
+        db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
+
+        # Tray 2 from layer 0, switched to tray 1 at layer 40 (of 100 total)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=100,
+            tray_now=1,
+            last_loaded_tray=1,
+            total_layers=100,
+            tray_change_log=[(2, 0), (1, 40)],
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value=None,  # No per-layer gcode available
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=101,
+                status="completed",
+                print_name="Linear Fallback",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 2
+        # Linear split: tray 2 for 40/100 layers = 20g
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 2
+        assert results[0]["weight_used"] == 20.0
+        # Last segment gets remainder: 50 - 20 = 30g
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 1
+        assert results[1]["weight_used"] == 30.0
+
+    @pytest.mark.asyncio
+    async def test_no_tray_change_uses_normal_path(self):
+        """Single-entry tray_change_log falls through to normal tray_now_at_start logic."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
+        archive = _make_archive(archive_id=102)
+
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # Only one entry = no switch, should use normal path
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=100,
+            tray_now=2,
+            last_loaded_tray=2,
+            total_layers=100,
+            tray_change_log=[(2, 0)],
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=102,
+                status="completed",
+                print_name="No Switch",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                tray_now_at_start=2,
+            )
+
+        # Normal path: single result, full weight
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 15.0
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 2
+
+    @pytest.mark.asyncio
+    async def test_empty_tray_change_log_uses_normal_path(self):
+        """Empty tray_change_log (e.g. server restart) falls through to existing logic."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
+        archive = _make_archive(archive_id=103)
+
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # Empty log (server restarted mid-print)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=100,
+            tray_now=0,
+            last_loaded_tray=0,
+            total_layers=100,
+            tray_change_log=[],
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=103,
+                status="completed",
+                print_name="Restart Recovery",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                tray_now_at_start=0,
+            )
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 10.0
+
+    @pytest.mark.asyncio
+    async def test_tray_switch_segment_no_spool(self):
+        """Segment with no spool assignment is skipped; other segments still tracked."""
+        spool_b = _make_spool(spool_id=20, label_weight=1000)
+        assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=3)
+        archive = _make_archive(archive_id=104)
+
+        # db: archive, queue_item(None), 1st segment: no assignment, 2nd segment: assignment, spool
+        db = _mock_db_sequential([archive, None, None, assign_b, spool_b])
+
+        # Tray 5 (no spool) from layer 0, switched to tray 3 at layer 50
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=100,
+            tray_now=3,
+            last_loaded_tray=3,
+            total_layers=100,
+            tray_change_log=[(5, 0), (3, 50)],
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value=None,  # No per-layer data
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=104,
+                status="completed",
+                print_name="Missing Spool",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        # Only the second segment (tray 3) tracked; first segment (tray 5) skipped
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 3
+        assert results[0]["spool_id"] == 20
+
+    @pytest.mark.asyncio
+    async def test_tray_switch_three_segments(self):
+        """Three-segment switch (rare): A→B→C split by linear fallback."""
+        spool_a = _make_spool(spool_id=1, label_weight=1000)
+        spool_b = _make_spool(spool_id=2, label_weight=1000)
+        spool_c = _make_spool(spool_id=3, label_weight=1000)
+        assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
+        assign_b = _make_assignment(spool_id=2, ams_id=0, tray_id=1)
+        assign_c = _make_assignment(spool_id=3, ams_id=0, tray_id=2)
+        archive = _make_archive(archive_id=105)
+
+        db = _mock_db_sequential(
+            [
+                archive,
+                None,
+                assign_a,
+                spool_a,
+                assign_b,
+                spool_b,
+                assign_c,
+                spool_c,
+            ]
+        )
+
+        # 3 segments: tray 0 (0-30), tray 1 (30-70), tray 2 (70-end)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=100,
+            tray_now=2,
+            last_loaded_tray=2,
+            total_layers=100,
+            tray_change_log=[(0, 0), (1, 30), (2, 70)],
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value=None,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=105,
+                status="completed",
+                print_name="Triple Switch",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 3
+        # Tray 0: 30/100 * 100g = 30g
+        assert results[0]["weight_used"] == 30.0
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 0
+        # Tray 1: 40/100 * 100g = 40g
+        assert results[1]["weight_used"] == 40.0
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 1
+        # Tray 2: remainder = 100 - 30 - 40 = 30g
+        assert results[2]["weight_used"] == 30.0
+        assert results[2]["ams_id"] == 0
+        assert results[2]["tray_id"] == 2
+
+
+class TestDecodeMqttMapping:
+    """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
+
+    def test_none_input(self):
+        assert _decode_mqtt_mapping(None) is None
+
+    def test_empty_list(self):
+        assert _decode_mqtt_mapping([]) is None
+
+    def test_all_unmapped(self):
+        """All 65535 values → None (no valid mappings)."""
+        assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
+
+    def test_single_ams_slots(self):
+        """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
+        assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
+
+    def test_multi_ams_slots(self):
+        """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
+        assert _decode_mqtt_mapping([256, 257]) == [4, 5]
+
+    def test_ams_ht_slot(self):
+        """AMS-HT (hw_id=128): snow 32768 → global 128."""
+        assert _decode_mqtt_mapping([32768]) == [128]
+
+    def test_external_spool(self):
+        """External spool: ams_hw_id=254, slot=0 → global 254."""
+        # snow = 254 * 256 + 0 = 65024
+        assert _decode_mqtt_mapping([65024]) == [254]
+
+    def test_mixed_with_unmapped(self):
+        """Mix of valid and unmapped (65535) values."""
+        result = _decode_mqtt_mapping([1, 65535, 0])
+        assert result == [1, -1, 0]
+
+    def test_h2c_real_mapping(self):
+        """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
+        mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
+        result = _decode_mqtt_mapping(mapping)
+        assert result == [1, 0, -1, -1, -1, -1, 128]
+
+    def test_non_int_values_treated_as_unmapped(self):
+        """Non-integer values in the mapping are treated as unmapped."""
+        assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
+
+
+class TestMatchSlotsByColor:
+    """Tests for _match_slots_by_color() — color-based filament slot to AMS tray matching."""
+
+    def _ams(self, trays):
+        """Build AMS data from list of (ams_id, tray_id, color_hex, tray_type) tuples."""
+        units: dict[int, list] = {}
+        for ams_id, tray_id, color, tray_type in trays:
+            units.setdefault(ams_id, []).append({"id": tray_id, "tray_color": color, "tray_type": tray_type})
+        return [{"id": aid, "tray": t} for aid, t in units.items()]
+
+    def _usage(self, slots):
+        """Build filament_usage from list of (slot_id, color_hex) tuples."""
+        return [{"slot_id": sid, "used_g": 10.0, "type": "PLA", "color": color} for sid, color in slots]
+
+    def test_none_inputs(self):
+        assert _match_slots_by_color(None, None) is None
+        assert _match_slots_by_color([], None) is None
+        assert _match_slots_by_color(None, {"ams": []}) is None
+
+    def test_empty_ams(self):
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": []}) is None
+
+    def test_single_slot_single_tray(self):
+        """One 3MF slot matches one AMS tray by color."""
+        ams = self._ams([(0, 0, "FF0000FF", "PLA")])
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0]
+
+    def test_a1_mini_three_colors(self):
+        """A1 Mini: 3 slots match 3 distinct AMS trays."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),  # Red
+                (0, 1, "00FF00FF", "PLA"),  # Green
+                (0, 2, "0000FFFF", "PLA"),  # Blue
+            ]
+        )
+        usage = self._usage([(1, "#FF0000"), (2, "#00FF00"), (3, "#0000FF")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0, 1, 2]
+
+    def test_dual_ams_p2s_like(self):
+        """P2S with dual AMS: slots from second AMS unit."""
+        ams = self._ams(
+            [
+                (0, 0, "AAAAAAFF", "PLA"),
+                (0, 1, "BBBBBBFF", "PLA"),
+                (1, 0, "CC0000FF", "PETG"),  # global_id=4
+                (1, 1, "00CC00FF", "PETG"),  # global_id=5
+            ]
+        )
+        usage = self._usage([(1, "#CC0000"), (2, "#00CC00")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [4, 5]
+
+    def test_ams_ht_global_id(self):
+        """AMS-HT (ams_id >= 128) uses raw ams_id as global tray ID."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),
+                (128, 0, "0000FFFF", "PLA"),  # AMS-HT → global_id=128
+            ]
+        )
+        usage = self._usage([(1, "#FF0000"), (2, "#0000FF")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0, 128]
+
+    def test_ambiguous_same_color_returns_none(self):
+        """Two trays with the same color → ambiguous → None."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),
+                (0, 1, "FF0000FF", "PLA"),  # Same red
+            ]
+        )
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_no_matching_color_returns_none(self):
+        """3MF slot color not found in any AMS tray → None."""
+        ams = self._ams([(0, 0, "00FF00FF", "PLA")])
+        usage = self._usage([(1, "#FF0000")])  # Red, but AMS has green
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_color_normalization_strips_alpha(self):
+        """AMS colors (RRGGBBAA) and 3MF colors (#RRGGBB) match after normalization."""
+        ams = self._ams([(0, 0, "AABBCC80", "PLA")])  # 8-char with alpha
+        usage = self._usage([(1, "#AABBCC")])  # 6-char with #
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0]
+
+    def test_case_insensitive(self):
+        """Color matching is case-insensitive."""
+        ams = self._ams([(0, 0, "aaBBccFF", "PLA")])
+        usage = self._usage([(1, "#AAbbCC")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0]
+
+    def test_empty_tray_color_skipped(self):
+        """Trays with empty color are skipped (not matched)."""
+        ams = self._ams(
+            [
+                (0, 0, "", "PLA"),
+                (0, 1, "FF0000FF", "PLA"),
+            ]
+        )
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [1]
+
+    def test_empty_tray_type_skipped(self):
+        """Trays with empty tray_type are skipped (unloaded slot)."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", ""),  # Empty slot
+                (0, 1, "FF0000FF", "PLA"),  # Loaded slot
+            ]
+        )
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [1]
+
+    def test_short_slot_color_returns_none(self):
+        """3MF slot with color < 6 chars → can't match → None."""
+        ams = self._ams([(0, 0, "FF0000FF", "PLA")])
+        usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFF"}]
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_slot_id_zero_skipped(self):
+        """Slots with slot_id=0 are skipped."""
+        ams = self._ams([(0, 0, "FF0000FF", "PLA")])
+        usage = [{"slot_id": 0, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_ams_data_as_list(self):
+        """Handles ams_raw as a plain list (some printer models)."""
+        ams_list = [{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}]
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, ams_list) == [0]
+
+    def test_same_color_two_trays_disambiguated_by_usage(self):
+        """Two trays same color, two slots same color → unique assignment via used_trays tracking."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),
+                (0, 1, "FF0000FF", "PLA"),
+            ]
+        )
+        # Two slots both wanting red — first gets tray 0, second gets tray 1? No.
+        # When first slot takes the only available, second has 1 left → should work
+        usage = self._usage([(1, "#FF0000"), (2, "#FF0000")])
+        # First slot: candidates=[0,1], available=[0,1], len!=1 → None
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_dict_wrapper_with_ams_key(self):
+        """Standard dict format with 'ams' key."""
+        ams_data = {"ams": [{"id": 0, "tray": [{"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"}]}]}
+        usage = self._usage([(1, "#00FF00")])
+        assert _match_slots_by_color(usage, ams_data) == [0]
+
+
+class TestMqttMappingIntegration:
+    """Integration tests: MQTT mapping field used in _track_from_3mf."""
+
+    @pytest.mark.asyncio
+    async def test_h2c_multi_filament_uses_mqtt_mapping(self):
+        """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
+        # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
+        spool_white = _make_spool(spool_id=1, label_weight=1000)
+        spool_black = _make_spool(spool_id=2, label_weight=1000)
+        spool_red = _make_spool(spool_id=3, label_weight=1000)
+        assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
+        assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
+        archive = _make_archive(archive_id=12)
+
+        # db: archive, then 3 pairs of (assignment, spool)
+        # No queue lookup because MQTT mapping is found first
+        db = _mock_db_sequential(
+            [
+                archive,
+                assign_white,
+                spool_white,
+                assign_black,
+                spool_black,
+                assign_red,
+                spool_red,
+            ]
+        )
+
+        # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
+        filament_usage = [
+            {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
+            {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
+            {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
+        ]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=12,
+                status="completed",
+                print_name="Cube + Cube + Cube",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 3
+
+        # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
+        assert results[0]["spool_id"] == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 1
+        assert results[0]["weight_used"] == 21.2
+
+        # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
+        assert results[1]["spool_id"] == 2
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 0
+        assert results[1]["weight_used"] == 24.2
+
+        # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
+        assert results[2]["spool_id"] == 3
+        assert results[2]["ams_id"] == 128
+        assert results[2]["tray_id"] == 0
+        assert results[2]["weight_used"] == 18.5
+
+    @pytest.mark.asyncio
+    async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
+        """ams_mapping from print command is used even when MQTT mapping exists."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
+        db = _mock_db_sequential([archive, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [0, 65535]},  # MQTT says slot 0 → AMS0-T0
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                ams_mapping=[2],  # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 2  # From print_cmd mapping, not MQTT
+
+
 class TestNotificationVariables:
     """Tests for filament_details formatting in notifications."""
 

+ 1 - 0
docker-compose.yml

@@ -24,6 +24,7 @@ services:
     #ports:
     #  - "${PORT:-8000}:8000"
     #  - "3000:3000"                  # Virtual printer bind/detect
+    #  - "3002:3002"                  # Virtual printer bind/detect
     #  - "8883:8883"                  # Virtual printer MQTT
     #  - "9990:9990"                  # Virtual printer FTP control
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data

+ 20 - 7
docker-publish.sh

@@ -10,7 +10,7 @@
 #   ./docker-publish.sh 0.1.9b --ghcr-only    # Only push to GHCR
 #   ./docker-publish.sh 0.1.9b --dockerhub-only # Only push to Docker Hub
 #
-# Note: All versions are also tagged as 'latest'
+# Note: Stable versions are also tagged as 'latest'. Beta versions (ending in 'b') are not.
 #
 # Prerequisites:
 #   1. Log in to ghcr.io:
@@ -143,13 +143,22 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
     docker run --privileged --rm tonistiigi/binfmt --install all
 fi
 
+# Only tag as 'latest' for stable releases (not beta versions ending in 'b')
+TAG_LATEST=true
+if [[ "$VERSION" =~ b[0-9]*$ ]]; then
+    TAG_LATEST=false
+    echo -e "${YELLOW}Beta version detected — skipping 'latest' tag${NC}"
+fi
+
 # Build tags for all target registries
 TAGS=""
 if [ "$PUSH_GHCR" = true ]; then
-    TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:latest"
+    TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION}"
+    [ "$TAG_LATEST" = true ] && TAGS="$TAGS -t ${GHCR_IMAGE}:latest"
 fi
 if [ "$PUSH_DOCKERHUB" = true ]; then
-    TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION} -t ${DOCKERHUB_IMAGE}:latest"
+    TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION}"
+    [ "$TAG_LATEST" = true ] && TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:latest"
 fi
 
 echo -e "${BLUE}[3/4] Building and pushing...${NC}"
@@ -209,15 +218,19 @@ if [ "$PARALLEL" = true ]; then
 
     if [ "$PUSH_GHCR" = true ]; then
         echo -e "${BLUE}  Creating GHCR manifest...${NC}"
+        GHCR_MANIFEST_TAGS="-t ${GHCR_IMAGE}:${VERSION}"
+        [ "$TAG_LATEST" = true ] && GHCR_MANIFEST_TAGS="$GHCR_MANIFEST_TAGS -t ${GHCR_IMAGE}:latest"
         docker buildx imagetools create \
-            -t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:latest" \
+            $GHCR_MANIFEST_TAGS \
             "${GHCR_IMAGE}:${VERSION}-amd64" \
             "${GHCR_IMAGE}:${VERSION}-arm64"
     fi
     if [ "$PUSH_DOCKERHUB" = true ]; then
         echo -e "${BLUE}  Creating Docker Hub manifest...${NC}"
+        DH_MANIFEST_TAGS="-t ${DOCKERHUB_IMAGE}:${VERSION}"
+        [ "$TAG_LATEST" = true ] && DH_MANIFEST_TAGS="$DH_MANIFEST_TAGS -t ${DOCKERHUB_IMAGE}:latest"
         docker buildx imagetools create \
-            -t "${DOCKERHUB_IMAGE}:${VERSION}" -t "${DOCKERHUB_IMAGE}:latest" \
+            $DH_MANIFEST_TAGS \
             "${DOCKERHUB_IMAGE}:${VERSION}-amd64" \
             "${DOCKERHUB_IMAGE}:${VERSION}-arm64"
     fi
@@ -249,12 +262,12 @@ echo -e "${GREEN}================================================${NC}"
 if [ "$PUSH_GHCR" = true ]; then
     echo "  GHCR:"
     echo "    - ${GHCR_IMAGE}:${VERSION}"
-    echo "    - ${GHCR_IMAGE}:latest"
+    [ "$TAG_LATEST" = true ] && echo "    - ${GHCR_IMAGE}:latest"
 fi
 if [ "$PUSH_DOCKERHUB" = true ]; then
     echo "  Docker Hub:"
     echo "    - ${DOCKERHUB_IMAGE}:${VERSION}"
-    echo "    - ${DOCKERHUB_IMAGE}:latest"
+    [ "$TAG_LATEST" = true ] && echo "    - ${DOCKERHUB_IMAGE}:latest"
 fi
 echo ""
 echo -e "${BLUE}Supported platforms:${NC}"

+ 25 - 0
docs/ams_slot_printer_matrix.txt

@@ -0,0 +1,25 @@
+  ┌─────────────────┬─────────────────┬────────────────┬────────────────────────────────────┬─────────────────────┐
+  │      Model      │ _is_dual_nozzle │ ams_exist_bits │             Branch hit             │      Behavior       │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ X1C/X1E (1 AMS) │ False           │ "1"            │ New elif → num_ams=1 → passthrough │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ X1C (2 AMS)     │ False           │ "3"            │ New elif → disambiguation          │ Fixed (same as P2S) │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ P1S/P1P (1 AMS) │ False           │ "1"            │ New elif → num_ams=1 → passthrough │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ A1/A1 Mini      │ False           │ "1" or missing │ New elif → num_ams≤1 → passthrough │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ P2S (1 AMS)     │ False           │ "1"            │ New elif → num_ams=1 → passthrough │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ P2S (2 AMS)     │ False           │ "3"            │ New elif → disambiguation          │ Fixed               │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ H2D/H2D Pro     │ True            │ any            │ Branch 1 (H2D logic)               │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ H2C             │ True            │ any            │ Branch 1 (H2D logic)               │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ tray_now=255    │ any             │ any            │ Branch 3 (passthrough)             │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ tray_now=254    │ any             │ any            │ Branch 3 (passthrough)             │ Unchanged           │
+  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
+  │ tray_now 4-15   │ any (False)     │ any            │ Branch 3 (passthrough)             │ Unchanged           │
+  └─────────────────┴─────────────────┴────────────────┴────────────────────────────────────┴─────────────────────┘

BIN
docs/screenshots/settings-virtual-printer.png


+ 126 - 236
frontend/package-lock.json

@@ -1569,350 +1569,325 @@
       "license": "MIT"
     },
     "node_modules/@rollup/rollup-android-arm-eabi": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
-      "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+      "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
       "cpu": [
         "arm"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ]
     },
     "node_modules/@rollup/rollup-android-arm64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
-      "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+      "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ]
     },
     "node_modules/@rollup/rollup-darwin-arm64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
-      "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+      "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
       ]
     },
     "node_modules/@rollup/rollup-darwin-x64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
-      "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+      "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
       ]
     },
     "node_modules/@rollup/rollup-freebsd-arm64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
-      "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+      "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
       ]
     },
     "node_modules/@rollup/rollup-freebsd-x64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
-      "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+      "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
       ]
     },
     "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
-      "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+      "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
       "cpu": [
         "arm"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-arm-musleabihf": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
-      "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+      "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
       "cpu": [
         "arm"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-arm64-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
-      "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+      "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-arm64-musl": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
-      "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+      "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-loong64-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
-      "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+      "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
       "cpu": [
         "loong64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-loong64-musl": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
-      "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+      "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
       "cpu": [
         "loong64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-ppc64-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
-      "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+      "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
       "cpu": [
         "ppc64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-ppc64-musl": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
-      "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+      "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
       "cpu": [
         "ppc64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-riscv64-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
-      "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+      "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
       "cpu": [
         "riscv64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-riscv64-musl": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
-      "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+      "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
       "cpu": [
         "riscv64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-s390x-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
-      "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+      "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
       "cpu": [
         "s390x"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-x64-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
-      "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+      "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-linux-x64-musl": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
-      "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+      "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ]
     },
     "node_modules/@rollup/rollup-openbsd-x64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
-      "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+      "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "openbsd"
       ]
     },
     "node_modules/@rollup/rollup-openharmony-arm64": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
-      "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+      "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "openharmony"
       ]
     },
     "node_modules/@rollup/rollup-win32-arm64-msvc": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
-      "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+      "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
       "cpu": [
         "arm64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ]
     },
     "node_modules/@rollup/rollup-win32-ia32-msvc": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
-      "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+      "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
       "cpu": [
         "ia32"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ]
     },
     "node_modules/@rollup/rollup-win32-x64-gnu": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
-      "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+      "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ]
     },
     "node_modules/@rollup/rollup-win32-x64-msvc": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
-      "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+      "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
       "cpu": [
         "x64"
       ],
       "dev": true,
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
@@ -3295,32 +3270,6 @@
         "typescript": ">=4.8.4 <6.0.0"
       }
     },
-    "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
       "version": "7.7.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3590,7 +3539,6 @@
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
       "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "fast-deep-equal": "^3.1.1",
         "fast-json-stable-stringify": "^2.0.0",
@@ -3718,11 +3666,13 @@
       }
     },
     "node_modules/balanced-match": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
-      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
+      "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
       "dev": true,
-      "license": "MIT"
+      "engines": {
+        "node": "20 || >=22"
+      }
     },
     "node_modules/baseline-browser-mapping": {
       "version": "2.9.19",
@@ -3735,14 +3685,15 @@
       }
     },
     "node_modules/brace-expansion": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
-      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
+      "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "balanced-match": "^1.0.0",
-        "concat-map": "0.0.1"
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "20 || >=22"
       }
     },
     "node_modules/browserslist": {
@@ -3998,13 +3949,6 @@
         "node": ">= 0.8"
       }
     },
-    "node_modules/concat-map": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/convert-source-map": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4739,8 +4683,7 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
       "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-      "dev": true,
-      "license": "MIT"
+      "dev": true
     },
     "node_modules/fast-levenshtein": {
       "version": "2.0.6",
@@ -5007,32 +4950,6 @@
         "node": ">=10.13.0"
       }
     },
-    "node_modules/glob/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/glob/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/globals": {
       "version": "16.5.0",
       "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
@@ -5573,8 +5490,7 @@
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
       "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-      "dev": true,
-      "license": "MIT"
+      "dev": true
     },
     "node_modules/json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
@@ -6119,16 +6035,18 @@
       }
     },
     "node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "version": "10.2.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+      "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
       "dev": true,
-      "license": "ISC",
       "dependencies": {
-        "brace-expansion": "^1.1.7"
+        "brace-expansion": "^5.0.2"
       },
       "engines": {
-        "node": "*"
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
       }
     },
     "node_modules/minipass": {
@@ -7046,11 +6964,10 @@
       "license": "MIT"
     },
     "node_modules/rollup": {
-      "version": "4.57.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
-      "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+      "version": "4.59.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+      "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "@types/estree": "1.0.8"
       },
@@ -7062,31 +6979,31 @@
         "npm": ">=8.0.0"
       },
       "optionalDependencies": {
-        "@rollup/rollup-android-arm-eabi": "4.57.1",
-        "@rollup/rollup-android-arm64": "4.57.1",
-        "@rollup/rollup-darwin-arm64": "4.57.1",
-        "@rollup/rollup-darwin-x64": "4.57.1",
-        "@rollup/rollup-freebsd-arm64": "4.57.1",
-        "@rollup/rollup-freebsd-x64": "4.57.1",
-        "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
-        "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
-        "@rollup/rollup-linux-arm64-gnu": "4.57.1",
-        "@rollup/rollup-linux-arm64-musl": "4.57.1",
-        "@rollup/rollup-linux-loong64-gnu": "4.57.1",
-        "@rollup/rollup-linux-loong64-musl": "4.57.1",
-        "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
-        "@rollup/rollup-linux-ppc64-musl": "4.57.1",
-        "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
-        "@rollup/rollup-linux-riscv64-musl": "4.57.1",
-        "@rollup/rollup-linux-s390x-gnu": "4.57.1",
-        "@rollup/rollup-linux-x64-gnu": "4.57.1",
-        "@rollup/rollup-linux-x64-musl": "4.57.1",
-        "@rollup/rollup-openbsd-x64": "4.57.1",
-        "@rollup/rollup-openharmony-arm64": "4.57.1",
-        "@rollup/rollup-win32-arm64-msvc": "4.57.1",
-        "@rollup/rollup-win32-ia32-msvc": "4.57.1",
-        "@rollup/rollup-win32-x64-gnu": "4.57.1",
-        "@rollup/rollup-win32-x64-msvc": "4.57.1",
+        "@rollup/rollup-android-arm-eabi": "4.59.0",
+        "@rollup/rollup-android-arm64": "4.59.0",
+        "@rollup/rollup-darwin-arm64": "4.59.0",
+        "@rollup/rollup-darwin-x64": "4.59.0",
+        "@rollup/rollup-freebsd-arm64": "4.59.0",
+        "@rollup/rollup-freebsd-x64": "4.59.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+        "@rollup/rollup-linux-arm64-musl": "4.59.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+        "@rollup/rollup-linux-loong64-musl": "4.59.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+        "@rollup/rollup-linux-x64-gnu": "4.59.0",
+        "@rollup/rollup-linux-x64-musl": "4.59.0",
+        "@rollup/rollup-openbsd-x64": "4.59.0",
+        "@rollup/rollup-openharmony-arm64": "4.59.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+        "@rollup/rollup-win32-x64-gnu": "4.59.0",
+        "@rollup/rollup-win32-x64-msvc": "4.59.0",
         "fsevents": "~2.3.2"
       }
     },
@@ -7462,32 +7379,6 @@
         "node": ">=18"
       }
     },
-    "node_modules/test-exclude/node_modules/brace-expansion": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
-      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/test-exclude/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/three": {
       "version": "0.181.2",
       "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
@@ -7752,7 +7643,6 @@
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
       "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
       "dev": true,
-      "license": "BSD-2-Clause",
       "dependencies": {
         "punycode": "^2.1.0"
       }

+ 3 - 0
frontend/package.json

@@ -41,6 +41,9 @@
     "recharts": "^3.5.1",
     "three": "^0.181.2"
   },
+  "overrides": {
+    "minimatch": "^10.2.1"
+  },
   "devDependencies": {
     "@eslint/js": "^9.39.1",
     "@tailwindcss/postcss": "^4.1.17",

BIN
frontend/public/img/printers/h2dpro.png


+ 16 - 0
frontend/src/App.tsx

@@ -14,6 +14,7 @@ import { FileManagerPage } from './pages/FileManagerPage';
 import { CameraPage } from './pages/CameraPage';
 import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
+import { GroupEditPage } from './pages/GroupEditPage';
 import InventoryPage from './pages/InventoryPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
@@ -22,6 +23,11 @@ import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
+import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
+import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
+import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
+import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
+import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 
 const queryClient = new QueryClient({
   defaultOptions: {
@@ -113,6 +119,14 @@ function App() {
                 {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
                 <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
 
+                {/* SpoolBuddy kiosk UI */}
+                <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
+                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+                  <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
+                  <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
+                  <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+                </Route>
+
                 {/* Main app with WebSocket for real-time updates */}
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />
@@ -126,6 +140,8 @@ function App() {
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
+                  <Route path="groups/new" element={<AdminRoute><GroupEditPage /></AdminRoute>} />
+                  <Route path="groups/:id/edit" element={<AdminRoute><GroupEditPage /></AdminRoute>} />
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />

Some files were not shown because too many files changed in this diff