Forráskód Böngészése

Merge branch '0.2.0b' into feature/addIpTablesOnDockerInstal

MartinNYHC 6 hónapja
szülő
commit
ee1a0125ee
100 módosított fájl, 10133 hozzáadás és 781 törlés
  1. 2 0
      .gitignore
  2. 139 0
      BETA_TEST_PLAN.md
  3. 63 0
      CHANGELOG.md
  4. 1 0
      CONTRIBUTING.md
  5. 1 1
      DOCKERHUB.md
  6. 15 4
      README.md
  7. 20 5
      backend/app/api/routes/archives.py
  8. 19 0
      backend/app/api/routes/camera.py
  9. 109 3
      backend/app/api/routes/cloud.py
  10. 1063 0
      backend/app/api/routes/inventory.py
  11. 3 0
      backend/app/api/routes/kprofiles.py
  12. 7 0
      backend/app/api/routes/library.py
  13. 98 15
      backend/app/api/routes/maintenance.py
  14. 2 0
      backend/app/api/routes/notifications.py
  15. 129 0
      backend/app/api/routes/print_log.py
  16. 247 56
      backend/app/api/routes/printers.py
  17. 11 2
      backend/app/api/routes/settings.py
  18. 47 3
      backend/app/api/routes/spoolman.py
  19. 5 2
      backend/app/api/routes/support.py
  20. 340 0
      backend/app/api/routes/system.py
  21. 13 0
      backend/app/api/routes/updates.py
  22. 317 0
      backend/app/core/bambu_colors.py
  23. 828 0
      backend/app/core/catalog_defaults.py
  24. 1 1
      backend/app/core/config.py
  25. 152 0
      backend/app/core/database.py
  26. 18 0
      backend/app/core/permissions.py
  27. 513 18
      backend/app/main.py
  28. 12 0
      backend/app/models/__init__.py
  29. 20 0
      backend/app/models/color_catalog.py
  30. 2 1
      backend/app/models/external_link.py
  31. 1 0
      backend/app/models/maintenance.py
  32. 3 0
      backend/app/models/notification.py
  33. 6 0
      backend/app/models/notification_template.py
  34. 31 0
      backend/app/models/print_log.py
  35. 44 0
      backend/app/models/spool.py
  36. 35 0
      backend/app/models/spool_assignment.py
  37. 18 0
      backend/app/models/spool_catalog.py
  38. 31 0
      backend/app/models/spool_k_profile.py
  39. 21 0
      backend/app/models/spool_usage_history.py
  40. 1 0
      backend/app/schemas/cloud.py
  41. 3 0
      backend/app/schemas/external_link.py
  42. 6 0
      backend/app/schemas/notification.py
  43. 41 2
      backend/app/schemas/notification_template.py
  44. 25 0
      backend/app/schemas/print_log.py
  45. 1 1
      backend/app/schemas/printer.py
  46. 6 0
      backend/app/schemas/settings.py
  47. 109 0
      backend/app/schemas/spool.py
  48. 17 0
      backend/app/schemas/spool_usage.py
  49. 46 10
      backend/app/services/archive.py
  50. 1 1
      backend/app/services/bambu_ftp.py
  51. 221 116
      backend/app/services/bambu_mqtt.py
  52. 36 15
      backend/app/services/external_camera.py
  53. 85 37
      backend/app/services/firmware_check.py
  54. 7 2
      backend/app/services/mqtt_relay.py
  55. 8 2
      backend/app/services/mqtt_smart_plug.py
  56. 79 19
      backend/app/services/notification_service.py
  57. 52 0
      backend/app/services/print_log.py
  58. 54 45
      backend/app/services/print_scheduler.py
  59. 67 37
      backend/app/services/printer_manager.py
  60. 310 0
      backend/app/services/spool_tag_matcher.py
  61. 108 55
      backend/app/services/spoolman.py
  62. 9 8
      backend/app/services/spoolman_tracking.py
  63. 420 0
      backend/app/services/usage_tracker.py
  64. 63 0
      backend/app/utils/printer_models.py
  65. 56 0
      backend/app/utils/threemf_tools.py
  66. 1 0
      backend/tests/conftest.py
  67. 43 0
      backend/tests/integration/test_camera_api.py
  68. 6 6
      backend/tests/unit/services/test_bambu_ftp.py
  69. 130 0
      backend/tests/unit/services/test_notification_service.py
  70. 40 12
      backend/tests/unit/services/test_printer_manager.py
  71. 66 0
      backend/tests/unit/services/test_spoolman_service.py
  72. 2 2
      backend/tests/unit/services/test_spoolman_tracking.py
  73. 401 0
      backend/tests/unit/services/test_usage_tracker.py
  74. 1 1
      backend/tests/unit/test_code_quality.py
  75. 211 0
      backend/tests/unit/test_phantom_print_hardening.py
  76. 104 0
      backend/tests/unit/test_print_log.py
  77. 190 2
      backend/tests/unit/test_scheduler_ams_mapping.py
  78. 186 0
      backend/tests/unit/test_scheduler_clear_plate.py
  79. 47 0
      backend/tests/unit/test_support_helpers.py
  80. 242 0
      backend/tests/unit/test_sync_ams_weights.py
  81. 726 0
      backend/tests/unit/test_usage_tracker.py
  82. 193 0
      docker-publish-beta.sh
  83. 2 0
      frontend/src/App.tsx
  84. 1 0
      frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx
  85. 134 0
      frontend/src/__tests__/components/AssignSpoolModal.test.tsx
  86. 77 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  87. 77 141
      frontend/src/__tests__/components/LinkSpoolModal.test.tsx
  88. 32 0
      frontend/src/__tests__/components/NotificationProviderCard.test.tsx
  89. 1 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  90. 177 0
      frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx
  91. 186 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  92. 58 120
      frontend/src/__tests__/components/SpoolmanSettings.test.tsx
  93. 182 3
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  94. 1 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  95. 4 3
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  96. 1 1
      frontend/src/__tests__/pages/StatsPage.test.tsx
  97. 43 0
      frontend/src/__tests__/utils/currency.test.ts
  98. 316 16
      frontend/src/api/client.ts
  99. 12 12
      frontend/src/components/AMSHistoryModal.tsx
  100. 22 0
      frontend/src/components/AddExternalLinkModal.tsx

+ 2 - 0
.gitignore

@@ -28,6 +28,8 @@ npm-debug.log*
 # Database
 *.db
 *.db-journal
+*.db-wal
+*.db-shm
 
 # Archive files (user data)
 archive/

+ 139 - 0
BETA_TEST_PLAN.md

@@ -0,0 +1,139 @@
+# Beta Test Plan — Spool Inventory & Related Features
+
+## Prerequisites
+
+- At least one printer connected with AMS
+- At least one Bambu Lab spool (RFID) loaded in AMS
+- At least one spool without RFID tag
+- At least one empty AMS slot (or slot with non-BL filament)
+- A 3MF file ready to print (small/fast test print recommended)
+
+---
+
+## 1. Filament Tracking Mode Switching
+
+**Location:** Settings > Filament
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 1.1 | Default mode | Open Settings > Filament | "Built-in Inventory" card is selected (green border), info panel shows RFID/usage/catalog bullet points |
+| 1.2 | Switch to Spoolman | Click "Spoolman" card | Spoolman config appears (URL input, Sync Mode, connection status). Built-in info panel disappears |
+| 1.3 | Switch back | Click "Built-in Inventory" card | Spoolman config disappears, built-in info panel reappears |
+| 1.4 | Persistence | Switch mode, reload page | Selected mode persists after reload |
+| 1.5 | Spoolman disabled state | Select Spoolman without URL, check Connect button | Connect button should be disabled when URL is empty |
+
+---
+
+## 2. Spool Management
+
+**Location:** Inventory page (sidebar)
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 2.1 | Add spool | Click "+ Add Spool", fill material/brand/color/weight, save | Spool appears in table with correct info |
+| 2.2 | Edit spool | Click a spool row, change fields, save | Changes reflected in table |
+| 2.3 | Remaining weight | Edit spool > Additional section > adjust remaining weight | Remaining weight = label_weight - weight_used, slider/input updates correctly |
+| 2.4 | Spool catalog | Edit spool > Additional > Empty Spool Weight dropdown | Pre-defined spool weights shown, selecting one updates the field |
+| 2.5 | Color picker | Edit spool > Color section | Recent colors, brand palettes, and hex input all work |
+| 2.6 | PA profile tab | Edit spool > PA Profile tab | Matching K-profiles shown grouped by printer/nozzle (requires calibration data) |
+| 2.7 | Archive spool | Archive a spool from context menu or edit | Spool moves to "Archived" tab |
+| 2.8 | Delete spool | Delete a spool | Spool removed from all views |
+| 2.9 | Summary cards | Check top of inventory page | Total Inventory, Total Consumed, By Material, In Printer, Low Stock cards show correct values |
+| 2.10 | Filters | Try Active/Archived/All tabs, material/brand dropdowns, search | Filtering works correctly |
+| 2.11 | View modes | Toggle between Table and Card view | Both views show correct spool data |
+
+---
+
+## 3. AMS Slot Assignment
+
+**Location:** Printers page, AMS hover cards
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 3.1 | Assign spool | Hover over an empty/non-BL AMS slot > click "Assign Spool" | Modal opens showing only manual (non-BL) spools |
+| 3.2 | BL spools filtered | Open assign modal | Bambu Lab spools (with RFID tags) are NOT in the list |
+| 3.3 | Assigned spools filtered | Assign spool A to slot 1, then open assign modal for slot 2 | Spool A is NOT in the list (already assigned) |
+| 3.4 | Current slot spool visible | Open assign for slot that already has a spool | The currently assigned spool IS still shown (for reassignment) |
+| 3.5 | Confirm assignment | Select spool, click "Assign Spool" | Slot shows the assigned spool info on hover |
+| 3.6 | Unassign spool | Hover over assigned slot > click "Unassign" | Spool removed from slot, slot shows default info |
+| 3.7 | BL slot — no buttons | Hover over a slot with a Bambu Lab spool (RFID) | No "Assign Spool" or "Unassign" buttons shown |
+| 3.8 | Empty slot display | Check an empty AMS slot | Shows type from AMS data or "Empty" (localized) |
+
+---
+
+## 4. Auto-Unlink on BL Spool Insertion
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 4.1 | BL spool replaces manual | Assign a manual spool to a slot, then physically insert a BL spool into that slot | Assignment automatically removed, slot now shows BL spool info |
+| 4.2 | Log message | Check backend logs after 4.1 | Log shows "Auto-unlink: spool X AMSY-TZ — Bambu Lab spool detected" |
+
+---
+
+## 5. Spool Tag Linking
+
+**Location:** Printers page, AMS hover cards for BL spools
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 5.1 | Link modal | Hover over BL spool slot > click "Link to Spool" (if available) | Modal opens showing only untagged spools |
+| 5.2 | Search filter | Type in search box | Spools filtered by material/brand/color |
+| 5.3 | Link spool | Click a spool in the list | Success toast, modal closes, spool now linked to tag |
+| 5.4 | Tagged spools hidden | After linking spool A, open link modal again | Spool A is no longer in the list |
+
+---
+
+## 6. Usage Tracking — BL Spools (AMS Remain%)
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 6.1 | Session capture | Start a print with a BL spool | Backend log shows "Captured start remain% for printer X (N trays)" |
+| 6.2 | Completed print | Let print finish | Spool's weight_used increases by (delta% * label_weight). Check inventory page remaining weight |
+| 6.3 | Failed/aborted print | Start and cancel a print mid-way | Usage still tracked based on remain% delta at time of stop |
+| 6.4 | No double-tracking | Complete a print with BL spool | Only AMS delta tracking applies (no 3MF fallback for this spool) |
+
+---
+
+## 7. Usage Tracking — Non-BL Spools (3MF Estimates)
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 7.1 | Assign and print | Assign a manual spool to AMS slot, start a print using that slot | Backend log shows "3MF fallback available" at print start |
+| 7.2 | Completed print | Let print finish | Spool's weight_used increases by the 3MF estimated used_g. Check inventory page |
+| 7.3 | Failed print scaling | Start a print, cancel at ~50% | Usage = 3MF estimate * (progress/100). E.g., 100g estimate at 50% = ~50g tracked |
+| 7.4 | Multi-slot print | Print using multiple filament slots (some BL, some manual) | BL spools tracked via remain% delta, manual spools via 3MF. No double-counting |
+| 7.5 | Slot mapping | Use a spool in AMS slot 5 (second AMS unit) | Correctly maps to AMS 1, Tray 0. Check usage history shows correct ams_id/tray_id |
+
+---
+
+## 8. Edge Cases
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 8.1 | No AMS data | Print from external spool (no AMS) | No crash, usage tracking gracefully skipped |
+| 8.2 | No archive | Print without 3MF archive available | AMS delta path still works for BL spools, 3MF path skipped gracefully |
+| 8.3 | Spool refilled | If remain% goes UP between start and end (spool swapped/refilled) | Negative delta skipped, no negative usage recorded |
+| 8.4 | Rapid print start/stop | Start and immediately cancel a print | No errors, minimal or zero usage tracked |
+| 8.5 | Concurrent printers | Print on two printers simultaneously | Each printer tracked independently, no cross-contamination |
+
+---
+
+## 9. UI/UX Checks
+
+| # | Test | Steps | Expected |
+|---|------|-------|----------|
+| 9.1 | Mobile layout | Open on mobile or narrow browser | Inventory page, assign modal, and settings all responsive |
+| 9.2 | Dark/light mode | Toggle theme | All inventory UI elements properly themed |
+| 9.3 | Language switching | Switch to German and Japanese | All inventory strings translated (no hardcoded English) |
+| 9.4 | Keyboard nav | Use keyboard shortcuts on printers page | AMS slot interactions accessible |
+
+---
+
+## Reporting Issues
+
+When reporting a bug, please include:
+- Test case number (e.g., "Beta 9.1 failed")
+- Browser + version
+- Screenshot or screen recording
+- Backend logs (if relevant — check Docker logs)
+- Steps to reproduce if different from above

+ 63 - 0
CHANGELOG.md

@@ -2,6 +2,68 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.2.0b] - Not released
+
+### New Features
+- **Bed Cooled Notification** ([#378](https://github.com/maziggy/bambuddy/issues/378)) — New notification event that fires when the print bed cools below a configurable threshold (default 35°C) after a print completes. Useful for knowing when it's safe to remove parts. A background task polls the bed temperature every 15 seconds after print completion and sends a notification when it drops below the threshold. Automatically cancels if a new print starts or the printer disconnects. The threshold is configurable in Settings → Notifications. Includes a customizable notification template with printer name, bed temperature, and threshold variables.
+- **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
+- **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
+- **Spool Inventory — Unified 3MF-Based Usage Tracking** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament `used_g` data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue `ams_mapping` for queue-initiated prints and the printer's `tray_now` state for single-filament non-queue prints, ensuring the correct physical spool is always tracked.
+- **Notification Templates — Filament Usage Variables** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — `print_complete`, `print_failed`, and `print_stopped` notification events now expose `{filament_grams}` (total grams, scaled by progress for partial prints), `{filament_details}` (per-filament breakdown with AMS slot info, e.g. "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g"), and `{progress}` (completion percentage for failed/stopped prints). The `{filament_details}` variable includes the AMS unit and tray position for each filament used, with "Ext" shown for external spool holders. Falls back to type-only format (e.g. "PLA: 10.0g") when usage tracking data is unavailable. Webhook payloads include `filament_used`, `filament_details`, and `progress` fields. Per-slot filament data is stored in archive `extra_data` for downstream use.
+- **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).
+- **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
+- **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).
+- **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
+- **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
+- **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
+- **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
+
+### Fixed
+- **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.
+- **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.
+- **Stale Inventory Assignments Persist After Switching to Spoolman Mode** — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing "Assign Spool" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all `SpoolAssignment` records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.
+- **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
+- **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
+- **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
+- **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.
+- **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.
+- **RFID Spool Data Erased by Periodic AMS Updates** — Periodic MQTT push-all responses cleared `tag_uid` and `tray_uuid` fields because they were included in the "always update" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by empty `tray_type`). This fixes the AMS "eye" icon disappearing for RFID spools after startup.
+- **AMS Slot Configuration Overwrites RFID Spool State** — Configuring an AMS slot for an RFID-detected Bambu Lab spool sent `ams_set_filament_setting`, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's "eye" icon to change to a "pen" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection.
+- **K-Profile Selection Corrupts Existing Profiles on X1C/P1S** — The `extrusion_cali_sel` command included a `setting_id` field that BambuStudio never sends, causing firmware to mislink calibration data. The `extrusion_cali_set` command was sent unconditionally, overwriting existing profile metadata. Now `setting_id` is removed from selection commands, and `extrusion_cali_set` is only sent when no existing profile is selected (`cali_idx < 0`).
+- **AMS Slot Configure — Black Filament Color Not Pre-Populated** — When re-opening the Configure AMS Slot modal for a slot with black filament, the color field was empty despite the preset and K-profile being correctly pre-selected. The color pre-population logic excluded hex `000000` (black) as a guard against empty slots, but empty slots already skip color data entirely. Removed the unnecessary check so black is now pre-populated like any other color.
+- **Archive List View Not Labeling Failed Prints** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — The archive grid view displayed a red "Failed" / "Cancelled" badge on failed and aborted prints, but the list view had no equivalent indicator. Now shows an inline status badge next to the print name in list view.
+- **Reprint Fails with SD Card Error for Archives Without 3MF File** ([#376](https://github.com/maziggy/bambuddy/issues/376)) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing "SD card error." The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.
+- **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.
+- **All Spool Fill Levels Drop to Zero When Printers Power Off** — When a printer powers off, the AMS sensor can report `remain=0` for all trays while `tray_type` is still populated. The weight sync treated 0% remain as "100% consumed," computing `weight_used = label_weight` (e.g., 1000g). The "only increase" guard passed because `label_weight > current_used + 1`, marking every assigned spool as fully consumed. The AMS weight sync now skips `remain=0` entirely — a physically empty spool is tracked by the usage tracker during the print, not by a transient AMS sensor reading.
+- **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
+- **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
+- **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
+- **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
+
+### Improved
+- **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.
+- **Reduce Log Noise from MQTT Diagnostics** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.
+- **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.
+- **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
+- **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
+- **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).
+- **Printer Card Cover Image Not Updating Between Prints** — The cover image on the printer card only refreshed on page reload. The `<img>` URL was always the same (`/printers/{id}/cover`) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.
+- **Telegram Bold Title Broken by Underscores in Message** ([#332](https://github.com/maziggy/bambuddy/issues/332)) — Telegram notifications showed literal `*Title*` asterisks instead of bold text when the message body contained underscores (e.g. job name `A1_plate_8`, error code `0300_0001`). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with `\_` so Markdown rendering stays enabled.
+- **Queued Jobs Incorrectly Archived After Duplicate Execution Detection** ([#341](https://github.com/maziggy/bambuddy/issues/341)) — When the same file was added to the print queue multiple times, only the first job executed. All subsequent jobs were automatically skipped with "already printed X hours ago" because they shared the same archive reference, and a safety check incorrectly treated them as phantom reprints. The same issue also affected single queue items created from recently completed archives. Removed the overly broad 4-hour duplicate detection check — the crash recovery scenario it guarded against is already handled by the queue item status lifecycle.
+
+### New Features
+- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
+- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
+
+### Improved
+- **Skip Objects: Confirmation Dialog** ([#346](https://github.com/maziggy/bambuddy/issues/346)) — Added a warning confirmation modal before skipping an object during a print. Shows the object name and warns the action is irreversible. Prevents accidentally skipping the wrong object. Translated in all 4 locales (en, de, ja, it).
+- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
+- **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
+- **Inventory — Confirmation Modals for Delete & Archive** — The inventory page now uses the app's styled confirmation modal for both delete and archive actions. Previously, delete used the browser's native `confirm()` dialog and archive had no confirmation at all. Delete shows a danger-styled modal, archive shows a warning-styled modal. Translated in all 5 locales (en, de, fr, it, ja).
+- **Default Color Catalog Expanded to 638 Colors Across 20 Brands** — The built-in filament color catalog has been expanded from 258 entries (6 brands) to 638 entries (20 brands). Added Overture, Sunlu, Creality, Elegoo, Jayo, Inland, Eryone, ColorFabb, Fillamentum, FormFutura, Fiberlogy, MatterHackers, Protopasta, 3DXTECH, and Sakata3D. eSUN expanded from 10 generic placeholder entries to 79 measured colors across 10 material lines (PLA+, Pro PLA+, PLA, PLA Silk, PLA Metal, PLA-ST, PETG, PETG-HS, ABS, ABS+). All hex codes sourced from FilamentColors.xyz measured swatches.
+- **Settings — Built-in Inventory Feature Note** — Added a note in Settings > Filament > Built-in Inventory that third-party spools can be assigned to inventory spools for tracking.
+- **Catalog Settings Cards Taller** — Spool Catalog and Color Catalog settings panels increased from 400px to 600px max height for better browsability with the expanded default catalogs.
+
 ## [0.1.9] - 2026-02-10
 
 ### New Features
@@ -37,6 +99,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
 - **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
 - **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
+- **Timelapse Not Attached — Baseline Race Condition** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Follow-up to the snapshot-diff timelapse fix: the baseline of existing MP4 files was captured at print completion time inside a background task, but fast-encoding printers could finish writing the timelapse before the baseline was taken, causing the new file to appear in the baseline and never be detected as "new." Moved baseline capture to print start time, when the timelapse file cannot possibly exist yet. Falls back to completion-time baseline if the app was restarted mid-print.
 - **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
 - **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.

+ 1 - 0
CONTRIBUTING.md

@@ -166,6 +166,7 @@ Translations live in `frontend/src/i18n/locales/`:
 |------|----------|
 | `en.ts` | English (primary) |
 | `de.ts` | German |
+| `fr.ts` | French |
 | `ja.ts` | Japanese |
 
 ### Adding New Strings

+ 1 - 1
DOCKERHUB.md

@@ -92,7 +92,7 @@ docker compose pull && docker compose up -d
 
 | Series | Models | Status |
 |---|---|---|
-| H2 | H2D | Tested |
+| H2 | H2C, H2D, H2D Pro, H2S | Tested |
 | X1 | X1 Carbon, X1E | Tested |
 | P1 | P1P, P1S | Compatible |
 | P2 | P2S | Compatible |

+ 15 - 4
README.md

@@ -72,10 +72,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
-- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
+- **Print Log** — Chronological table view of all print activity with columns for date/time, print name, printer, user, status, duration, and filament. Filterable by search, printer, user, status, and date range. Pagination with configurable page size. Clear button removes log entries without affecting archives.
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
@@ -88,7 +89,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
-- AMS slot configuration (custom presets, K profiles, color picker)
+- AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
+- Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history
 - Print success rates & trends
 - Filament usage tracking
@@ -103,6 +105,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
+- Clear plate confirmation between queued prints
 - Smart plug integration (Tasmota, Home Assistant, MQTT)
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
@@ -139,12 +142,20 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Email, Pushover, ntfy
 - Custom webhooks
 - Quiet hours & daily digest
-- Customizable message templates
+- Customizable message templates with per-filament usage details
 - Print finish photo URL in notifications
+- Filament usage and progress in failed/cancelled print notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
+- Bed cooled alerts (configurable threshold)
 - Queue events (waiting, skipped, failed)
 
+### 🧵 Spool Inventory
+- 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
+- Spool catalog, color catalog, PA profile matching, and low-stock alerts
+
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - MQTT publishing for Home Assistant, Node-RED, etc.
@@ -194,7 +205,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 </tr>
 </table>
 
-**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE/JA/IT) • Auto updates • Database backup/restore • System info dashboard
 
 ---
 

+ 20 - 5
backend/app/api/routes/archives.py

@@ -21,6 +21,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -133,13 +134,15 @@ async def list_archives(
         offset=offset,
     )
 
-    # Get set of hashes that have duplicates (efficient single query)
-    duplicate_hashes = await service.get_duplicate_hashes()
+    # Get sets of hashes and names that have duplicates (efficient single queries)
+    duplicate_hashes, duplicate_names = await service.get_duplicate_hashes_and_names()
 
-    # Mark archives that have duplicates
+    # Mark archives that have duplicates (by hash or by print name)
     result = []
     for a in archives:
-        has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
+        has_hash_dup = a.content_hash in duplicate_hashes if a.content_hash else False
+        has_name_dup = a.print_name and a.print_name.lower() in duplicate_names
+        has_duplicate = has_hash_dup or has_name_dup
         result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
     return result
 
@@ -2669,6 +2672,12 @@ async def get_filament_requirements(
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
+            # Enrich with nozzle mapping for dual-nozzle printers
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 
@@ -2731,8 +2740,14 @@ async def reprint_archive(
         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.",
+        )
     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")
 
     # Upload file to printer via FTP

+ 19 - 0
backend/app/api/routes/camera.py

@@ -547,6 +547,25 @@ async def camera_snapshot(
 
     printer = await get_printer_or_404(printer_id, db)
 
+    # Check for external camera first
+    if printer.external_camera_enabled and printer.external_camera_url:
+        from backend.app.services.external_camera import capture_frame
+
+        frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
+        if not frame_data:
+            raise HTTPException(
+                status_code=503,
+                detail="Failed to capture frame from external camera.",
+            )
+        return Response(
+            content=frame_data,
+            media_type="image/jpeg",
+            headers={
+                "Cache-Control": "no-cache, no-store, must-revalidate",
+                "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
+            },
+        )
+
     # Create temporary file for the snapshot
     with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
         temp_path = Path(f.name)

+ 109 - 3
backend/app/api/routes/cloud.py

@@ -246,11 +246,12 @@ async def get_slicer_settings(
 
         for api_key, our_type in type_mapping.items():
             type_data = data.get(api_key, {})
-            # Combine public and private presets, private (user's own) first
-            all_settings = type_data.get("private", []) + type_data.get("public", [])
+            private_settings = type_data.get("private", [])
+            public_settings = type_data.get("public", [])
 
             parsed = []
-            for s in all_settings:
+            # Private (custom) presets first
+            for s in private_settings:
                 parsed.append(
                     SlicerSetting(
                         setting_id=s.get("setting_id", s.get("id", "")),
@@ -259,6 +260,20 @@ async def get_slicer_settings(
                         version=s.get("version"),
                         user_id=s.get("user_id"),
                         updated_time=s.get("updated_time"),
+                        is_custom=True,
+                    )
+                )
+            # Public (default) presets
+            for s in public_settings:
+                parsed.append(
+                    SlicerSetting(
+                        setting_id=s.get("setting_id", s.get("id", "")),
+                        name=s.get("name", "Unknown"),
+                        type=our_type,
+                        version=s.get("version"),
+                        user_id=s.get("user_id"),
+                        updated_time=s.get("updated_time"),
+                        is_custom=False,
                     )
                 )
             setattr(result, our_type, parsed)
@@ -302,6 +317,22 @@ async def get_setting_detail(
         raise HTTPException(status_code=500, detail=str(e))
 
 
+@router.get("/filaments", response_model=list[SlicerSetting])
+async def get_filament_presets(
+    version: str = "02.04.00.70",
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
+    """
+    Get just filament presets (convenience endpoint).
+
+    Returns all filament presets with custom presets first.
+    Uses the same cache as get_slicer_settings.
+    """
+    settings = await get_slicer_settings(version=version, db=db)
+    return settings.filament
+
+
 # Cache for filament preset info (setting_id -> {name, k})
 _filament_cache: dict[str, dict] = {}
 _filament_cache_time: float = 0
@@ -844,6 +875,81 @@ def _load_fields(preset_type: str) -> dict:
     return data
 
 
+@router.get("/builtin-filaments")
+async def get_builtin_filaments(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
+    """
+    Get built-in filament names as a fallback source.
+
+    Returns the static _BUILTIN_FILAMENT_NAMES table as a list of
+    {filament_id, name} objects.  Used by the frontend when cloud
+    and local profiles are unavailable.
+    """
+    return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
+
+
+# Cache for filament_id → name mapping (resolved from cloud preset details)
+_filament_id_name_cache: dict[str, str] = {}
+_filament_id_name_cache_time: float = 0
+
+
+@router.get("/filament-id-map")
+async def get_filament_id_map(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
+    """
+    Get filament_id → name mapping for user cloud presets.
+
+    K-profiles store a filament_id (e.g., "P4d64437") which is different from
+    the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
+    fetches details for all custom presets and returns the mapping.
+    Cached for 5 minutes.
+    """
+    import time
+
+    global _filament_id_name_cache, _filament_id_name_cache_time
+
+    if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
+        return _filament_id_name_cache
+
+    token, _ = await get_stored_token(db)
+    if not token:
+        return _filament_id_name_cache or {}
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+    if not cloud.is_authenticated:
+        return _filament_id_name_cache or {}
+
+    try:
+        data = await cloud.get_slicer_settings()
+        custom_presets = data.get("filament", {}).get("private", [])
+
+        result: dict[str, str] = {}
+        for preset in custom_presets:
+            setting_id = preset.get("setting_id", "")
+            if not setting_id:
+                continue
+            try:
+                detail = await cloud.get_setting_detail(setting_id)
+                fid = detail.get("filament_id", "")
+                name = detail.get("name", "")
+                if fid and name:
+                    # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
+                    clean_name = name.split(" @")[0].strip() if " @" in name else name
+                    result[fid] = clean_name
+            except Exception:
+                pass
+
+        _filament_id_name_cache = result
+        _filament_id_name_cache_time = time.time()
+        return result
+    except Exception:
+        return _filament_id_name_cache or {}
+
+
 @router.get("/fields/{preset_type}")
 async def get_preset_fields(
     preset_type: Literal["filament", "print", "process", "printer"],

+ 1063 - 0
backend/app/api/routes/inventory.py

@@ -0,0 +1,1063 @@
+import json
+import logging
+
+import httpx
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.spool import Spool
+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.user import User
+from backend.app.schemas.spool import (
+    SpoolAssignmentCreate,
+    SpoolAssignmentResponse,
+    SpoolCreate,
+    SpoolKProfileBase,
+    SpoolKProfileResponse,
+    SpoolResponse,
+    SpoolUpdate,
+)
+from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/inventory", tags=["inventory"])
+
+# Material temperature defaults (nozzle min/max)
+MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
+    "PLA": (190, 230),
+    "PETG": (220, 260),
+    "ABS": (240, 270),
+    "ASA": (240, 270),
+    "TPU": (200, 240),
+    "PA": (260, 290),
+    "PC": (250, 280),
+    "PVA": (190, 210),
+    "PLA-CF": (210, 240),
+    "PETG-CF": (240, 270),
+    "PA-CF": (270, 300),
+}
+
+# FilamentColors.xyz API
+FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
+
+
+# ── Spool Catalog Schemas ──────────────────────────────────────────────────
+
+
+class CatalogEntryResponse(BaseModel):
+    id: int
+    name: str
+    weight: int
+    is_default: bool
+
+    class Config:
+        from_attributes = True
+
+
+class CatalogEntryCreate(BaseModel):
+    name: str
+    weight: int
+
+
+class CatalogEntryUpdate(BaseModel):
+    name: str
+    weight: int
+
+
+# ── Color Catalog Schemas ──────────────────────────────────────────────────
+
+
+class ColorEntryResponse(BaseModel):
+    id: int
+    manufacturer: str
+    color_name: str
+    hex_color: str
+    material: str | None
+    is_default: bool
+
+    class Config:
+        from_attributes = True
+
+
+class ColorEntryCreate(BaseModel):
+    manufacturer: str
+    color_name: str
+    hex_color: str
+    material: str | None = None
+
+
+class ColorEntryUpdate(BaseModel):
+    manufacturer: str
+    color_name: str
+    hex_color: str
+    material: str | None = None
+
+
+class ColorLookupResult(BaseModel):
+    found: bool
+    hex_color: str | None = None
+    material: str | None = None
+
+
+# ── Spool Catalog CRUD ─────────────────────────────────────────────────────
+
+
+@router.get("/catalog", response_model=list[CatalogEntryResponse])
+async def get_spool_catalog(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get all spool catalog entries."""
+    result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
+    return list(result.scalars().all())
+
+
+@router.post("/catalog", response_model=CatalogEntryResponse)
+async def add_catalog_entry(
+    entry: CatalogEntryCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Add a new spool catalog entry."""
+    row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+@router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
+async def update_catalog_entry(
+    entry_id: int,
+    entry: CatalogEntryUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update a spool catalog entry."""
+    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Entry not found")
+    row.name = entry.name
+    row.weight = entry.weight
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+@router.delete("/catalog/{entry_id}")
+async def delete_catalog_entry(
+    entry_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete a spool catalog entry."""
+    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Entry not found")
+    await db.delete(row)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+@router.post("/catalog/reset")
+async def reset_spool_catalog(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Reset spool catalog to defaults."""
+    await db.execute(select(SpoolCatalogEntry))  # ensure table loaded
+    # Delete all
+    result = await db.execute(select(SpoolCatalogEntry))
+    for row in result.scalars().all():
+        await db.delete(row)
+    # Re-seed defaults
+    for name, weight in DEFAULT_SPOOL_CATALOG:
+        db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
+    await db.commit()
+    return {"status": "reset"}
+
+
+# ── Color Catalog CRUD ─────────────────────────────────────────────────────
+
+
+@router.get("/colors", response_model=list[ColorEntryResponse])
+async def get_color_catalog(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get all color catalog entries."""
+    result = await db.execute(
+        select(ColorCatalogEntry).order_by(
+            ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
+        )
+    )
+    return list(result.scalars().all())
+
+
+@router.post("/colors", response_model=ColorEntryResponse)
+async def add_color_entry(
+    entry: ColorEntryCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Add a new color catalog entry."""
+    row = ColorCatalogEntry(
+        manufacturer=entry.manufacturer,
+        color_name=entry.color_name,
+        hex_color=entry.hex_color,
+        material=entry.material,
+        is_default=False,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+@router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
+async def update_color_entry(
+    entry_id: int,
+    entry: ColorEntryUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update a color catalog entry."""
+    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Entry not found")
+    row.manufacturer = entry.manufacturer
+    row.color_name = entry.color_name
+    row.hex_color = entry.hex_color
+    row.material = entry.material
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+@router.delete("/colors/{entry_id}")
+async def delete_color_entry(
+    entry_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete a color catalog entry."""
+    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Entry not found")
+    await db.delete(row)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+@router.post("/colors/reset")
+async def reset_color_catalog(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Reset color catalog to defaults."""
+    result = await db.execute(select(ColorCatalogEntry))
+    for row in result.scalars().all():
+        await db.delete(row)
+    for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
+        db.add(
+            ColorCatalogEntry(
+                manufacturer=manufacturer,
+                color_name=color_name,
+                hex_color=hex_color,
+                material=material,
+                is_default=True,
+            )
+        )
+    await db.commit()
+    return {"status": "reset"}
+
+
+@router.get("/colors/lookup", response_model=ColorLookupResult)
+async def lookup_color(
+    manufacturer: str,
+    color_name: str,
+    material: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Look up a color by manufacturer and color name."""
+    query = select(ColorCatalogEntry).where(
+        ColorCatalogEntry.manufacturer == manufacturer,
+        ColorCatalogEntry.color_name == color_name,
+    )
+    if material:
+        query = query.where(ColorCatalogEntry.material == material)
+    query = query.limit(1)
+    result = await db.execute(query)
+    row = result.scalar_one_or_none()
+    if row:
+        return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
+    return ColorLookupResult(found=False)
+
+
+@router.get("/colors/search", response_model=list[ColorEntryResponse])
+async def search_colors(
+    manufacturer: str | None = None,
+    material: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Search colors by manufacturer and/or material."""
+    query = select(ColorCatalogEntry)
+    if manufacturer:
+        query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
+    if material:
+        query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
+    query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
+    result = await db.execute(query)
+    return list(result.scalars().all())
+
+
+@router.post("/colors/sync")
+async def sync_from_filamentcolors(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Sync colors from FilamentColors.xyz API with progress streaming."""
+
+    async def generate():
+        from backend.app.core.database import async_session
+
+        added = 0
+        skipped = 0
+        total_fetched = 0
+        total_available = 0
+
+        try:
+            async with httpx.AsyncClient(timeout=120.0) as client:
+                page = 1
+                while True:
+                    response = await client.get(
+                        f"{FILAMENT_COLORS_API}/swatch/",
+                        params={"page": page},
+                    )
+                    response.raise_for_status()
+                    data = response.json()
+                    total_available = data.get("count", total_available)
+                    results = data.get("results", [])
+                    if not results:
+                        break
+
+                    async with async_session() as db:
+                        for swatch in results:
+                            total_fetched += 1
+                            manufacturer_data = swatch.get("manufacturer")
+                            manufacturer_name = (
+                                manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
+                            )
+                            filament_type_data = swatch.get("filament_type")
+                            mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
+                            color_name_val = swatch.get("color_name", "")
+                            hex_color_val = swatch.get("hex_color", "")
+
+                            if not manufacturer_name or not color_name_val or not hex_color_val:
+                                skipped += 1
+                                continue
+
+                            if not hex_color_val.startswith("#"):
+                                hex_color_val = f"#{hex_color_val}"
+
+                            # Check if entry already exists
+                            existing = await db.execute(
+                                select(ColorCatalogEntry)
+                                .where(
+                                    ColorCatalogEntry.manufacturer == manufacturer_name,
+                                    ColorCatalogEntry.color_name == color_name_val,
+                                    ColorCatalogEntry.material == mat,
+                                )
+                                .limit(1)
+                            )
+                            if existing.scalar_one_or_none():
+                                skipped += 1
+                            else:
+                                db.add(
+                                    ColorCatalogEntry(
+                                        manufacturer=manufacturer_name,
+                                        color_name=color_name_val,
+                                        hex_color=hex_color_val.upper(),
+                                        material=mat,
+                                        is_default=False,
+                                    )
+                                )
+                                added += 1
+
+                        await db.commit()
+
+                    progress = {
+                        "type": "progress",
+                        "added": added,
+                        "skipped": skipped,
+                        "total_fetched": total_fetched,
+                        "total_available": total_available,
+                    }
+                    yield f"data: {json.dumps(progress)}\n\n"
+
+                    if not data.get("next") or total_fetched >= total_available:
+                        break
+                    page += 1
+
+            result = {
+                "type": "complete",
+                "added": added,
+                "skipped": skipped,
+                "total_fetched": total_fetched,
+                "total_available": total_available,
+            }
+            yield f"data: {json.dumps(result)}\n\n"
+
+        except httpx.HTTPError as e:
+            logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
+            yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
+        except Exception as e:
+            logger.error("Error syncing from FilamentColors.xyz: %s", e)
+            yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
+
+    return StreamingResponse(generate(), media_type="text/event-stream")
+
+
+# ── Spool CRUD ───────────────────────────────────────────────────────────────
+
+
+@router.get("/spools", response_model=list[SpoolResponse])
+async def list_spools(
+    include_archived: bool = False,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List all spools, excluding archived by default."""
+    query = select(Spool).options(selectinload(Spool.k_profiles))
+    if not include_archived:
+        query = query.where(Spool.archived_at.is_(None))
+    query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
+    result = await db.execute(query)
+    return list(result.scalars().all())
+
+
+@router.get("/spools/{spool_id}", response_model=SpoolResponse)
+async def get_spool(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get a single spool with k_profiles."""
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+    return spool
+
+
+@router.post("/spools", response_model=SpoolResponse)
+async def create_spool(
+    spool_data: SpoolCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Create a new spool."""
+    spool = Spool(**spool_data.model_dump())
+    db.add(spool)
+    await db.commit()
+    await db.refresh(spool)
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    return result.scalar_one()
+
+
+@router.patch("/spools/{spool_id}", response_model=SpoolResponse)
+async def update_spool(
+    spool_id: int,
+    spool_data: SpoolUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update a spool."""
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+
+    for field, value in spool_data.model_dump(exclude_unset=True).items():
+        setattr(spool, field, value)
+
+    await db.commit()
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    return result.scalar_one()
+
+
+@router.delete("/spools/{spool_id}")
+async def delete_spool(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Hard delete a spool."""
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+
+    await db.delete(spool)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+@router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
+async def archive_spool(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Soft-delete a spool by setting archived_at."""
+    from datetime import datetime, timezone
+
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+
+    spool.archived_at = datetime.now(timezone.utc)
+    await db.commit()
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    return result.scalar_one()
+
+
+@router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
+async def restore_spool(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Restore an archived spool."""
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+
+    spool.archived_at = None
+    await db.commit()
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    return result.scalar_one()
+
+
+# ── K-Profiles ───────────────────────────────────────────────────────────────
+
+
+@router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
+async def list_k_profiles(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List K-profiles for a spool."""
+    result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
+    return list(result.scalars().all())
+
+
+@router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
+async def replace_k_profiles(
+    spool_id: int,
+    profiles: list[SpoolKProfileBase],
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Replace all K-profiles for a spool (batch save)."""
+    # Verify spool exists
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Spool not found")
+
+    # Delete existing
+    existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
+    for old in existing.scalars().all():
+        await db.delete(old)
+
+    # Create new
+    new_profiles = []
+    for p in profiles:
+        kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
+        db.add(kp)
+        new_profiles.append(kp)
+
+    await db.commit()
+    for kp in new_profiles:
+        await db.refresh(kp)
+    return new_profiles
+
+
+# ── Spool Assignments ────────────────────────────────────────────────────────
+
+
+@router.get("/assignments", response_model=list[SpoolAssignmentResponse])
+async def list_assignments(
+    printer_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List spool assignments, optionally filtered by printer."""
+    query = select(SpoolAssignment).options(
+        selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+        selectinload(SpoolAssignment.printer),
+    )
+    if printer_id is not None:
+        query = query.where(SpoolAssignment.printer_id == printer_id)
+    result = await db.execute(query)
+    return list(result.scalars().all())
+
+
+@router.post("/assignments", response_model=SpoolAssignmentResponse)
+async def assign_spool(
+    data: SpoolAssignmentCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Assign a spool to an AMS slot and auto-configure via MQTT."""
+    from backend.app.services.printer_manager import printer_manager
+
+    # 1. Validate spool exists and is not archived
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+    if spool.archived_at:
+        raise HTTPException(400, "Cannot assign an archived spool")
+
+    # 2. Get current AMS tray state for fingerprint
+    fingerprint_color = None
+    fingerprint_type = None
+    state = printer_manager.get_status(data.printer_id)
+    if state and state.raw_data:
+        if data.ams_id == 255:
+            # External slot: look up tray from vt_tray by global ID
+            vt_tray = state.raw_data.get("vt_tray") or []
+            ext_id = data.tray_id + 254  # 0→254, 1→255
+            for vt in vt_tray:
+                if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
+                    fingerprint_color = vt.get("tray_color", "")
+                    fingerprint_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 []
+            )
+            tray = _find_tray_in_ams_data(
+                ams_list,
+                data.ams_id,
+                data.tray_id,
+            )
+            if tray:
+                fingerprint_color = tray.get("tray_color", "")
+                fingerprint_type = tray.get("tray_type", "")
+
+    # 3. Upsert assignment (replace if same printer+ams+tray)
+    existing = await db.execute(
+        select(SpoolAssignment).where(
+            SpoolAssignment.printer_id == data.printer_id,
+            SpoolAssignment.ams_id == data.ams_id,
+            SpoolAssignment.tray_id == data.tray_id,
+        )
+    )
+    old = existing.scalar_one_or_none()
+    if old:
+        await db.delete(old)
+        await db.flush()
+
+    assignment = SpoolAssignment(
+        spool_id=data.spool_id,
+        printer_id=data.printer_id,
+        ams_id=data.ams_id,
+        tray_id=data.tray_id,
+        fingerprint_color=fingerprint_color,
+        fingerprint_type=fingerprint_type,
+    )
+    db.add(assignment)
+    await db.commit()
+    await db.refresh(assignment)
+
+    # 4. Auto-configure AMS slot via MQTT
+    configured = False
+    try:
+        client = printer_manager.get_client(data.printer_id)
+        if client:
+            # Build filament setting from spool data
+            tray_type = spool.material
+            tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
+            tray_color = spool.rgba or "FFFFFFFF"
+            tray_info_idx = spool.slicer_filament or ""
+            setting_id = ""
+
+            # 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:
+                temp_min = spool.nozzle_temp_min
+            if spool.nozzle_temp_max is not None:
+                temp_max = spool.nozzle_temp_max
+
+            # a. Set filament setting
+            client.ams_set_filament_setting(
+                ams_id=data.ams_id,
+                tray_id=data.tray_id,
+                tray_info_idx=tray_info_idx,
+                tray_type=tray_type,
+                tray_sub_brands=tray_sub_brands,
+                tray_color=tray_color,
+                nozzle_temp_min=temp_min,
+                nozzle_temp_max=temp_max,
+                setting_id=setting_id,
+            )
+
+            # b. Look up K-profile for this spool + printer + nozzle + extruder
+            nozzle_diameter = "0.4"
+            if state and state.nozzles:
+                nd = state.nozzles[0].nozzle_diameter
+                if nd:
+                    nozzle_diameter = nd
+
+            # Determine slot's extruder from ams_extruder_map
+            slot_extruder = None
+            if state and state.ams_extruder_map:
+                if data.ams_id == 255:
+                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                    slot_extruder = 1 - data.tray_id  # 0→1, 1→0
+                else:
+                    slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
+
+            matching_kp = None
+            for kp in spool.k_profiles:
+                if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
+                    if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
+                        continue
+                    matching_kp = kp
+                    break
+
+            if matching_kp and matching_kp.cali_idx is not None:
+                client.extrusion_cali_sel(
+                    ams_id=data.ams_id,
+                    tray_id=data.tray_id,
+                    cali_idx=matching_kp.cali_idx,
+                    filament_id=tray_info_idx,
+                    nozzle_diameter=nozzle_diameter,
+                )
+
+            configured = True
+            logger.info(
+                "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
+                data.ams_id,
+                data.tray_id,
+                spool.id,
+                data.printer_id,
+            )
+    except Exception as e:
+        logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+
+    # Return assignment with spool data
+    result = await db.execute(
+        select(SpoolAssignment)
+        .options(
+            selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+            selectinload(SpoolAssignment.printer),
+        )
+        .where(SpoolAssignment.id == assignment.id)
+    )
+    resp = result.scalar_one()
+    response = SpoolAssignmentResponse.model_validate(resp)
+    response.configured = configured
+    return response
+
+
+@router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
+async def unassign_spool(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Unassign a spool from an AMS slot."""
+    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:
+        raise HTTPException(404, "Assignment not found")
+
+    await db.delete(assignment)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+# ── Tag Linking ───────────────────────────────────────────────────────────────
+
+
+class LinkTagRequest(BaseModel):
+    tag_uid: str | None = None
+    tray_uuid: str | None = None
+    tag_type: str | None = None
+    data_origin: str | None = "nfc_link"
+
+
+@router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
+async def link_tag_to_spool(
+    spool_id: int,
+    data: LinkTagRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Link an RFID tag_uid/tray_uuid to an existing spool."""
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+    if spool.archived_at:
+        raise HTTPException(400, "Cannot link tag to archived spool")
+
+    # Check for conflicts: tag already linked to another active spool
+    if data.tag_uid:
+        conflict = await db.execute(
+            select(Spool).where(
+                Spool.tag_uid == data.tag_uid,
+                Spool.id != spool_id,
+                Spool.archived_at.is_(None),
+            )
+        )
+        if conflict.scalar_one_or_none():
+            raise HTTPException(409, "Tag UID already linked to another active spool")
+        # Auto-clear from archived spools (tag recycling)
+        archived_with_tag = await db.execute(
+            select(Spool).where(
+                Spool.tag_uid == data.tag_uid,
+                Spool.id != spool_id,
+                Spool.archived_at.is_not(None),
+            )
+        )
+        for old_spool in archived_with_tag.scalars().all():
+            old_spool.tag_uid = None
+
+    if data.tray_uuid:
+        conflict = await db.execute(
+            select(Spool).where(
+                Spool.tray_uuid == data.tray_uuid,
+                Spool.id != spool_id,
+                Spool.archived_at.is_(None),
+            )
+        )
+        if conflict.scalar_one_or_none():
+            raise HTTPException(409, "Tray UUID already linked to another active spool")
+        archived_with_uuid = await db.execute(
+            select(Spool).where(
+                Spool.tray_uuid == data.tray_uuid,
+                Spool.id != spool_id,
+                Spool.archived_at.is_not(None),
+            )
+        )
+        for old_spool in archived_with_uuid.scalars().all():
+            old_spool.tray_uuid = None
+
+    if data.tag_uid is not None:
+        spool.tag_uid = data.tag_uid
+    if data.tray_uuid is not None:
+        spool.tray_uuid = data.tray_uuid
+    if data.tag_type is not None:
+        spool.tag_type = data.tag_type
+    if data.data_origin is not None:
+        spool.data_origin = data.data_origin
+
+    await db.commit()
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    return result.scalar_one()
+
+
+# ── Usage History ─────────────────────────────────────────────────────────────
+
+
+@router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
+async def get_spool_usage_history(
+    spool_id: int,
+    limit: int = 50,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get usage history for a specific spool."""
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    # Verify spool exists
+    spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    if not spool_result.scalar_one_or_none():
+        raise HTTPException(404, "Spool not found")
+
+    result = await db.execute(
+        select(SpoolUsageHistory)
+        .where(SpoolUsageHistory.spool_id == spool_id)
+        .order_by(SpoolUsageHistory.created_at.desc())
+        .limit(limit)
+    )
+    return list(result.scalars().all())
+
+
+@router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
+async def get_all_usage_history(
+    limit: int = 100,
+    printer_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get global usage history, optionally filtered by printer."""
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
+    if printer_id is not None:
+        query = query.where(SpoolUsageHistory.printer_id == printer_id)
+    result = await db.execute(query)
+    return list(result.scalars().all())
+
+
+@router.delete("/spools/{spool_id}/usage")
+async def clear_spool_usage_history(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Clear usage history for a spool."""
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
+    for row in result.scalars().all():
+        await db.delete(row)
+    await db.commit()
+    return {"status": "cleared"}
+
+
+# ── AMS Weight Sync ──────────────────────────────────────────────────────────
+
+
+@router.post("/sync-ams-weights")
+async def sync_weights_from_ams(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Force-sync spool weight_used from live AMS remain% data.
+
+    Overwrites the database weight_used for every assigned spool using the
+    current AMS remain% from connected printers.  This is a manual recovery
+    tool — it bypasses the normal "only increase" guard.
+    """
+    from backend.app.services.printer_manager import printer_manager
+
+    result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+    assignments = list(result.scalars().all())
+    logger.info("AMS weight sync: found %d assignments", len(assignments))
+
+    synced = 0
+    skipped = 0
+
+    for assignment in assignments:
+        spool = assignment.spool
+        if not spool:
+            logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
+            skipped += 1
+            continue
+
+        state = printer_manager.get_status(assignment.printer_id)
+        if not state or not state.raw_data:
+            logger.info(
+                "AMS weight sync: printer %d not connected, skipping spool %d",
+                assignment.printer_id,
+                spool.id,
+            )
+            skipped += 1
+            continue
+
+        ams_raw = state.raw_data.get("ams", [])
+        if isinstance(ams_raw, dict):
+            ams_raw = ams_raw.get("ams", [])
+        tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
+        if not tray:
+            logger.info(
+                "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
+                spool.id,
+                assignment.printer_id,
+                assignment.ams_id,
+                assignment.tray_id,
+            )
+            skipped += 1
+            continue
+
+        remain_raw = tray.get("remain")
+        if remain_raw is None:
+            logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
+            skipped += 1
+            continue
+
+        try:
+            remain_val = int(remain_raw)
+        except (TypeError, ValueError):
+            skipped += 1
+            continue
+
+        if remain_val < 0 or remain_val > 100:
+            logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
+            skipped += 1
+            continue
+
+        lw = spool.label_weight or 1000
+        new_used = round(lw * (100 - remain_val) / 100.0, 1)
+        old_used = spool.weight_used or 0
+
+        if round(old_used, 1) != new_used:
+            logger.info(
+                "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
+                spool.id,
+                old_used,
+                new_used,
+                remain_val,
+            )
+            spool.weight_used = new_used
+            synced += 1
+        else:
+            skipped += 1
+
+    await db.commit()
+    return {"synced": synced, "skipped": skipped}
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+
+def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
+    """Find a specific tray in the AMS data structure."""
+    if not ams_data:
+        return None
+    for ams_unit in ams_data:
+        if int(ams_unit.get("id", -1)) != ams_id:
+            continue
+        for tray in ams_unit.get("tray", []):
+            if int(tray.get("id", -1)) == tray_id:
+                return tray
+    return None

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

@@ -278,6 +278,9 @@ async def delete_kprofile(
     if not success:
         raise HTTPException(500, "Failed to send K-profile delete command")
 
+    # Wait for printer to process the delete before frontend refetches
+    await asyncio.sleep(0.5)
+
     return {"success": True, "message": "K-profile deleted successfully"}
 
 

+ 7 - 0
backend/app/api/routes/library.py

@@ -56,6 +56,7 @@ from backend.app.schemas.library import (
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -1711,6 +1712,12 @@ async def get_library_file_filament_requirements(
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
+            # Enrich with nozzle mapping for dual-nozzle printers
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 

+ 98 - 15
backend/app/api/routes/maintenance.py

@@ -26,6 +26,7 @@ from backend.app.schemas.maintenance import (
     PrinterMaintenanceUpdate,
 )
 from backend.app.services.notification_service import notification_service
+from backend.app.utils.printer_models import get_rod_type
 
 logger = logging.getLogger(__name__)
 
@@ -33,12 +34,33 @@ router = APIRouter(prefix="/maintenance", tags=["maintenance"])
 
 # Default maintenance types
 DEFAULT_MAINTENANCE_TYPES = [
+    # Carbon rod models only (X1/P1/P2S)
+    {
+        "name": "Lubricate Carbon Rods",
+        "description": "Apply lubricant to carbon rods for smooth motion",
+        "default_interval_hours": 50.0,
+        "icon": "Droplet",
+    },
+    {
+        "name": "Clean Carbon Rods",
+        "description": "Wipe carbon rods with a dry cloth",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    # Linear rail models only (A1/H2)
     {
         "name": "Lubricate Linear Rails",
-        "description": "Apply lubricant to linear rails and rods for smooth motion",
+        "description": "Apply lubricant to linear rails for smooth motion",
         "default_interval_hours": 50.0,
         "icon": "Droplet",
     },
+    {
+        "name": "Clean Linear Rails",
+        "description": "Wipe linear rails with a dry cloth to remove dust and debris",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    # Universal (all models)
     {
         "name": "Clean Nozzle/Hotend",
         "description": "Clean nozzle exterior and perform cold pull if needed",
@@ -51,12 +73,6 @@ DEFAULT_MAINTENANCE_TYPES = [
         "default_interval_hours": 200.0,
         "icon": "Ruler",
     },
-    {
-        "name": "Clean Carbon Rods",
-        "description": "Wipe carbon rods with a dry cloth",
-        "default_interval_hours": 100.0,
-        "icon": "Sparkles",
-    },
     {
         "name": "Clean Build Plate",
         "description": "Deep clean build plate with IPA or soap",
@@ -71,6 +87,30 @@ DEFAULT_MAINTENANCE_TYPES = [
     },
 ]
 
+# System types that only apply to printers with a specific rod/rail type.
+# "carbon" = X1/P1/P2S series (carbon rods), "linear_rail" = A1/H2 series.
+# Types not listed here apply to all printers.
+_ROD_TYPE_REQUIREMENTS: dict[str, str] = {
+    "Lubricate Carbon Rods": "carbon",
+    "Clean Carbon Rods": "carbon",
+    "Lubricate Linear Rails": "linear_rail",
+    "Clean Linear Rails": "linear_rail",
+}
+
+
+def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
+    """Check if a system maintenance type should apply to a given printer model."""
+    rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
+    if rod_requirement is None:
+        return True  # Not model-specific, applies to all
+
+    rod_type = get_rod_type(printer_model)
+    if rod_type is None:
+        # Unknown model — default to carbon rods (legacy behavior)
+        return rod_requirement == "carbon"
+
+    return rod_type == rod_requirement
+
 
 async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
     """Calculate total active hours for a printer from runtime counter plus offset.
@@ -94,13 +134,27 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
 
 
 async def ensure_default_types(db: AsyncSession) -> None:
-    """Ensure default maintenance types exist."""
-    result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True)))
+    """Ensure default maintenance types exist, remove stale/duplicate ones."""
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)
+    )
     existing = result.scalars().all()
-    existing_names = {t.name for t in existing}
 
+    default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
+
+    # Remove stale system types no longer in defaults (e.g. renamed types)
+    # and deduplicate: if concurrent requests created the same type twice,
+    # keep only the first (lowest id) and delete the rest.
+    seen_names: set[str] = set()
+    for t in existing:
+        if t.name not in default_names or t.name in seen_names:
+            await db.delete(t)
+        else:
+            seen_names.add(t.name)
+
+    # Create any missing default types
     for type_def in DEFAULT_MAINTENANCE_TYPES:
-        if type_def["name"] not in existing_names:
+        if type_def["name"] not in seen_names:
             new_type = MaintenanceType(
                 name=type_def["name"],
                 description=type_def["description"],
@@ -123,7 +177,11 @@ async def get_maintenance_types(
 ):
     """Get all maintenance types."""
     await ensure_default_types(db)
-    result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
+    result = await db.execute(
+        select(MaintenanceType)
+        .where(MaintenanceType.is_deleted.is_(False))
+        .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
+    )
     return result.scalars().all()
 
 
@@ -176,20 +234,40 @@ async def delete_maintenance_type(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
-    """Delete a custom maintenance type."""
+    """Delete a maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
     maint_type = result.scalar_one_or_none()
     if not maint_type:
         raise HTTPException(status_code=404, detail="Maintenance type not found")
 
     if maint_type.is_system:
-        raise HTTPException(status_code=400, detail="Cannot delete system maintenance type")
+        maint_type.is_deleted = True
+        await db.commit()
+        return {"status": "deleted"}
 
     await db.delete(maint_type)
     await db.commit()
     return {"status": "deleted"}
 
 
+@router.post("/types/restore-defaults")
+async def restore_default_maintenance_types(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
+):
+    """Restore deleted default maintenance types."""
+    await ensure_default_types(db)
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True))
+    )
+    deleted_types = result.scalars().all()
+    for maint_type in deleted_types:
+        maint_type.is_deleted = False
+
+    await db.commit()
+    return {"restored": len(deleted_types)}
+
+
 # ============== Printer Maintenance ==============
 
 
@@ -210,7 +288,7 @@ async def _get_printer_maintenance_internal(
     total_hours = await get_printer_total_hours(db, printer_id)
 
     # Get all maintenance types
-    result = await db.execute(select(MaintenanceType))
+    result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False)))
     all_types = result.scalars().all()
 
     # Get printer's maintenance items
@@ -228,6 +306,11 @@ async def _get_printer_maintenance_internal(
     now = datetime.utcnow()
 
     for maint_type in all_types:
+        # Skip system types that don't apply to this printer model
+        # (e.g., "Clean Carbon Rods" for H2D which has steel rods)
+        if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):
+            continue
+
         item = existing_items.get(maint_type.id)
         default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
 

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -56,6 +56,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
+        # Bed cooled
+        "on_bed_cooled": provider.on_bed_cooled,
         # Print queue events
         "on_queue_job_added": provider.on_queue_job_added,
         "on_queue_job_assigned": provider.on_queue_job_assigned,

+ 129 - 0
backend/app/api/routes/print_log.py

@@ -0,0 +1,129 @@
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.user import User
+from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/print-log", tags=["print-log"])
+
+
+@router.get("/", response_model=PrintLogResponse)
+async def get_print_log(
+    search: str | None = None,
+    printer_id: int | None = None,
+    created_by_username: str | None = None,
+    status: str | None = None,
+    date_from: datetime | None = None,
+    date_to: datetime | None = None,
+    limit: int = Query(default=50, ge=1, le=500),
+    offset: int = Query(default=0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Get the print log."""
+    query = select(PrintLogEntry)
+    count_query = select(func.count(PrintLogEntry.id))
+
+    if printer_id is not None:
+        query = query.where(PrintLogEntry.printer_id == printer_id)
+        count_query = count_query.where(PrintLogEntry.printer_id == printer_id)
+    if created_by_username:
+        query = query.where(PrintLogEntry.created_by_username == created_by_username)
+        count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)
+    if status:
+        query = query.where(PrintLogEntry.status == status)
+        count_query = count_query.where(PrintLogEntry.status == status)
+    if search:
+        query = query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
+        count_query = count_query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
+    if date_from:
+        query = query.where(PrintLogEntry.created_at >= date_from)
+        count_query = count_query.where(PrintLogEntry.created_at >= date_from)
+    if date_to:
+        query = query.where(PrintLogEntry.created_at <= date_to)
+        count_query = count_query.where(PrintLogEntry.created_at <= date_to)
+
+    # Get total count
+    total_result = await db.execute(count_query)
+    total = total_result.scalar() or 0
+
+    # Get paginated results
+    query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
+    result = await db.execute(query)
+    entries = result.scalars().all()
+
+    return PrintLogResponse(
+        items=[
+            PrintLogEntrySchema(
+                id=e.id,
+                print_name=e.print_name,
+                printer_name=e.printer_name,
+                printer_id=e.printer_id,
+                status=e.status,
+                started_at=e.started_at,
+                completed_at=e.completed_at,
+                duration_seconds=e.duration_seconds,
+                filament_type=e.filament_type,
+                filament_color=e.filament_color,
+                filament_used_grams=e.filament_used_grams,
+                thumbnail_path=e.thumbnail_path,
+                created_by_username=e.created_by_username,
+                created_at=e.created_at,
+            )
+            for e in entries
+        ],
+        total=total,
+    )
+
+
+@router.get("/{entry_id}/thumbnail")
+async def get_print_log_thumbnail(
+    entry_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the thumbnail for a print log entry.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
+    entry = await db.get(PrintLogEntry, entry_id)
+    if not entry or not entry.thumbnail_path:
+        raise HTTPException(404, "Thumbnail not found")
+
+    thumb_path = settings.base_dir / entry.thumbnail_path
+    if not thumb_path.exists():
+        raise HTTPException(404, "Thumbnail file not found")
+
+    return FileResponse(
+        path=thumb_path,
+        media_type="image/png",
+        headers={"Cache-Control": "public, max-age=86400"},
+    )
+
+
+@router.delete("/")
+async def clear_print_log(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
+):
+    """Clear the print log.
+
+    Only deletes log entries. Archives and queue items are never touched.
+    """
+    result = await db.execute(delete(PrintLogEntry))
+    deleted = result.rowcount
+    await db.commit()
+
+    logger.info("Print log cleared: %d entries deleted", deleted)
+    return {"deleted": deleted}

+ 247 - 56
backend/app/api/routes/printers.py

@@ -237,7 +237,7 @@ async def get_printer_status(
 
     # Parse AMS data from raw_data
     ams_units = []
-    vt_tray = None
+    vt_tray = []
     ams_exists = False
     raw_data = state.raw_data or {}
 
@@ -319,38 +319,41 @@ async def get_printer_status(
                 )
             )
 
-    # Virtual tray (external spool holder) - comes from vt_tray in raw_data
+    # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
     if "vt_tray" in raw_data:
-        vt_data = raw_data["vt_tray"]
-        # Filter out empty/invalid tag values for vt_tray
-        vt_tag_uid = vt_data.get("tag_uid", "")
-        if vt_tag_uid in ("", "0000000000000000"):
-            vt_tag_uid = None
-        vt_tray_uuid = vt_data.get("tray_uuid", "")
-        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-            vt_tray_uuid = None
-
-        # Get K value: first try tray's k field, then lookup from K-profiles
-        vt_k_value = vt_data.get("k")
-        vt_cali_idx = vt_data.get("cali_idx")
-        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-            vt_k_value = kprofile_map[vt_cali_idx]
-
-        vt_tray = AMSTray(
-            id=254,  # Virtual tray ID
-            tray_color=vt_data.get("tray_color"),
-            tray_type=vt_data.get("tray_type"),
-            tray_sub_brands=vt_data.get("tray_sub_brands"),
-            tray_id_name=vt_data.get("tray_id_name"),
-            tray_info_idx=vt_data.get("tray_info_idx"),
-            remain=vt_data.get("remain", 0),
-            k=vt_k_value,
-            cali_idx=vt_cali_idx,
-            tag_uid=vt_tag_uid,
-            tray_uuid=vt_tray_uuid,
-            nozzle_temp_min=vt_data.get("nozzle_temp_min"),
-            nozzle_temp_max=vt_data.get("nozzle_temp_max"),
-        )
+        for vt_data in raw_data["vt_tray"]:
+            # Filter out empty/invalid tag values for vt_tray
+            vt_tag_uid = vt_data.get("tag_uid", "")
+            if vt_tag_uid in ("", "0000000000000000"):
+                vt_tag_uid = None
+            vt_tray_uuid = vt_data.get("tray_uuid", "")
+            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+                vt_tray_uuid = None
+
+            # Get K value: first try tray's k field, then lookup from K-profiles
+            vt_k_value = vt_data.get("k")
+            vt_cali_idx = vt_data.get("cali_idx")
+            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+                vt_k_value = kprofile_map[vt_cali_idx]
+
+            tray_id = int(vt_data.get("id", 254))
+            vt_tray.append(
+                AMSTray(
+                    id=tray_id,
+                    tray_color=vt_data.get("tray_color"),
+                    tray_type=vt_data.get("tray_type"),
+                    tray_sub_brands=vt_data.get("tray_sub_brands"),
+                    tray_id_name=vt_data.get("tray_id_name"),
+                    tray_info_idx=vt_data.get("tray_info_idx"),
+                    remain=vt_data.get("remain", 0),
+                    k=vt_k_value,
+                    cali_idx=vt_cali_idx,
+                    tag_uid=vt_tag_uid,
+                    tray_uuid=vt_tray_uuid,
+                    nozzle_temp_min=vt_data.get("nozzle_temp_min"),
+                    nozzle_temp_max=vt_data.get("nozzle_temp_max"),
+                )
+            )
 
     # Convert nozzle info to response format
     nozzles = [
@@ -1637,40 +1640,72 @@ async def configure_ams_slot(
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
 
-    # Send the filament setting command (type, color, temp)
-    success = client.ams_set_filament_setting(
-        ams_id=ams_id,
-        tray_id=tray_id,
-        tray_info_idx=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,
-    )
+    # 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
 
-    if not success:
-        raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+        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", ""),
+            )
 
-    # Send the calibration/K-profile commands
-    # Use the K profile's filament_id if provided, otherwise use tray_info_idx
+    # 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,
+        )
+
+        if not success:
+            raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+
     # Method 1: Select existing calibration profile by cali_idx
-    # IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
-    # Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
+    # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
+    # and including it causes the firmware to mislink the profile on X1C/P1S.
     client.extrusion_cali_sel(
         ams_id=ams_id,
         tray_id=tray_id,
         cali_idx=cali_idx,
         filament_id=filament_id_for_kprofile,
         nozzle_diameter=nozzle_diameter,
-        setting_id=kprofile_setting_id if kprofile_setting_id else None,
     )
 
-    # Method 2: Also directly set the K value if provided (for better compatibility)
-    if k_value > 0:
+    # Method 2: Only send extrusion_cali_set when NO existing profile was selected
+    # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
+    # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
+    # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
+    # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
+    # hardcoded extruder_id=0 and nozzle_id=HS00.
+    if k_value > 0 and cali_idx < 0:
         # Calculate global tray ID for extrusion_cali_set
         if ams_id <= 3:
             global_tray_id = ams_id * 4 + tray_id
@@ -1682,11 +1717,12 @@ async def configure_ams_slot(
         client.extrusion_cali_set(
             tray_id=global_tray_id,
             k_value=k_value,
-            n_coef=0.0,
             nozzle_diameter=nozzle_diameter,
-            bed_temp=60,
             nozzle_temp=nozzle_temp_max,
-            max_volumetric_speed=20.0,
+            filament_id=filament_id_for_kprofile,
+            setting_id=kprofile_setting_id or "",
+            name=tray_sub_brands or "",
+            cali_idx=cali_idx,
         )
 
     # Request fresh status push from printer so frontend gets updated data via WebSocket
@@ -1821,6 +1857,37 @@ async def stop_print(
     return {"success": True, "message": "Print stop command sent"}
 
 
+@router.post("/{printer_id}/clear-plate")
+async def clear_plate(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Acknowledge that the build plate has been cleared after a finished/failed print.
+
+    Sets a plate-cleared flag so the scheduler can start the next queued print.
+    No MQTT command is sent to the printer — the scheduler's start_print command
+    will override the FINISH/FAILED state when it sends the next job.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    if not printer_manager.is_connected(printer_id):
+        raise HTTPException(400, "Printer not connected")
+
+    state = printer_manager.get_status(printer_id)
+    if not state or state.state not in ("FINISH", "FAILED"):
+        raise HTTPException(
+            400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
+        )
+
+    printer_manager.set_plate_cleared(printer_id)
+
+    return {"success": True, "message": "Plate cleared, next print will start shortly"}
+
+
 @router.post("/{printer_id}/print/pause")
 async def pause_print(
     printer_id: int,
@@ -2078,9 +2145,133 @@ async def refresh_ams_slot(
     if not success:
         raise HTTPException(400, message)
 
+    # Apply PA profile after delay (RFID re-read takes a few seconds)
+    asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))
+
     return {"success": True, "message": message}
 
 
+async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
+    """Apply PA profile after RFID re-read completes.
+
+    Waits for the printer to finish processing the RFID data, then selects
+    the K-profile via extrusion_cali_sel.  Does NOT re-send ams_set_filament_setting
+    because that would overwrite the RFID-provided filament data.
+    """
+    await asyncio.sleep(5)
+    try:
+        from backend.app.api.routes.inventory import _find_tray_in_ams_data
+        from backend.app.core.database import async_session
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_assignment import SpoolAssignment as SA
+        from backend.app.services.spool_tag_matcher import is_bambu_tag
+
+        client = printer_manager.get_client(printer_id)
+        if not client:
+            return
+
+        state = printer_manager.get_status(printer_id)
+        if not state or not state.raw_data:
+            return
+
+        # Find current tray data (should have RFID data by now)
+        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 []
+        )
+        tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
+        if not tray or not tray.get("tray_type"):
+            logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
+            return
+
+        tag_uid = tray.get("tag_uid", "")
+        tray_uuid = tray.get("tray_uuid", "")
+        tray_info_idx = tray.get("tray_info_idx", "")
+        if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
+            return
+
+        async with async_session() as db:
+            from sqlalchemy import select as sa_select
+            from sqlalchemy.orm import selectinload
+
+            result = await db.execute(
+                sa_select(SA)
+                .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
+                .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
+            )
+            assignment = result.scalar_one_or_none()
+            if not assignment or not assignment.spool or not assignment.spool.k_profiles:
+                return
+
+            spool = assignment.spool
+            nozzle_diameter = "0.4"
+            if state.nozzles:
+                nd = state.nozzles[0].nozzle_diameter
+                if nd:
+                    nozzle_diameter = nd
+
+            # Determine slot's extruder from ams_extruder_map
+            slot_extruder = None
+            if state.ams_extruder_map:
+                if ams_id == 255:
+                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                    slot_extruder = 1 - slot_id  # 0→1, 1→0
+                else:
+                    slot_extruder = state.ams_extruder_map.get(str(ams_id))
+
+            matching_kp = None
+            for kp in spool.k_profiles:
+                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
+                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
+                        continue
+                    matching_kp = kp
+                    break
+
+            if not matching_kp or matching_kp.cali_idx is None:
+                return
+
+            # The filament_id in extrusion_cali_sel must match the filament preset
+            # under which the K-profile was calibrated. Use spool.slicer_filament
+            # (the preset assigned in inventory), falling back to tray's RFID value.
+            kp_filament_id = spool.slicer_filament or tray_info_idx
+
+            logger.info(
+                "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
+                ams_id,
+                slot_id,
+                matching_kp.cali_idx,
+                kp_filament_id,
+            )
+
+            # 1. Select K-profile
+            # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
+            # "this is a manual config" which destroys the RFID-detected spool state
+            # (changes eye icon to pen icon in slicer).
+            client.extrusion_cali_sel(
+                ams_id=ams_id,
+                tray_id=slot_id,
+                cali_idx=matching_kp.cali_idx,
+                filament_id=kp_filament_id,
+                nozzle_diameter=nozzle_diameter,
+            )
+
+            # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
+            # selected the correct profile by cali_idx. Sending extrusion_cali_set with
+            # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
+            # nozzle_id, name), corrupting it.
+
+            logger.info(
+                "Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d",
+                matching_kp.cali_idx,
+                matching_kp.k_value or 0,
+                printer_id,
+                ams_id,
+                slot_id,
+            )
+    except Exception as e:
+        logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
+
+
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
     printer_id: int,

+ 11 - 2
backend/app/api/routes/settings.py

@@ -6,7 +6,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, UploadFile
 from fastapi.responses import JSONResponse, StreamingResponse
-from sqlalchemy import select
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -255,7 +255,16 @@ async def update_spoolman_settings(
 ):
     """Update Spoolman integration settings."""
     if "spoolman_enabled" in settings:
-        await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
+        old_val = await get_setting(db, "spoolman_enabled") or "false"
+        new_val = settings["spoolman_enabled"]
+        await set_setting(db, "spoolman_enabled", new_val)
+
+        # Switching to Spoolman: clear built-in inventory slot assignments
+        if old_val.lower() != "true" and new_val.lower() == "true":
+            from backend.app.models.spool_assignment import SpoolAssignment
+
+            result = await db.execute(delete(SpoolAssignment))
+            logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount)
     if "spoolman_url" in settings:
         await set_setting(db, "spoolman_url", settings["spoolman_url"])
     if "spoolman_sync_mode" in settings:

+ 47 - 3
backend/app/api/routes/spoolman.py

@@ -6,12 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException
 from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 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.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
@@ -230,6 +232,22 @@ async def sync_printer_ams(
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
         )
 
+    # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
+    inv_weights: dict[tuple[int, int], float] = {}
+    try:
+        assign_result = await db.execute(
+            select(SpoolAssignment)
+            .options(selectinload(SpoolAssignment.spool))
+            .where(SpoolAssignment.printer_id == printer_id)
+        )
+        for assignment in assign_result.scalars().all():
+            spool = assignment.spool
+            if spool and spool.label_weight > 0:
+                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
+    except Exception as e:
+        logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
+
     for ams_unit in ams_units:
         if not isinstance(ams_unit, dict):
             continue
@@ -270,11 +288,13 @@ async def sync_printer_ams(
                 current_tray_uuids.add(spool_tag.upper())
 
             try:
+                inv_remaining = inv_weights.get((ams_id, tray.tray_id))
                 sync_result = await client.sync_ams_tray(
                     tray,
                     printer.name,
                     disable_weight_sync=disable_weight_sync,
                     cached_spools=cached_spools,
+                    inventory_remaining=inv_remaining,
                 )
                 if sync_result:
                     synced += 1
@@ -345,6 +365,8 @@ async def sync_all_printers(
     all_errors = []
     # Track tray UUIDs per printer (for clearing removed spools)
     printer_tray_uuids: dict[str, set[str]] = {}
+    # Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)
+    printer_synced_ids: dict[str, set[int]] = {}
 
     # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
     # This eliminates redundant API calls across all printers
@@ -359,6 +381,19 @@ async def sync_all_printers(
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
         )
 
+    # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
+    # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
+    inventory_weights: dict[tuple[int, int, int], float] = {}
+    try:
+        assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+        for assignment in assign_result.scalars().all():
+            spool = assignment.spool
+            if spool and spool.label_weight > 0:
+                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
+    except Exception as e:
+        logger.debug("Could not load inventory assignments for weight fallback: %s", e)
+
     for printer in printers:
         state = printer_manager.get_status(printer.id)
         if not state or not state.raw_data:
@@ -368,8 +403,9 @@ async def sync_all_printers(
         if not ams_data:
             continue
 
-        # Initialize tray UUID set for this printer
+        # Initialize tracking sets for this printer
         printer_tray_uuids[printer.name] = set()
+        printer_synced_ids[printer.name] = set()
 
         # Handle different AMS data structures
         # Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -432,16 +468,21 @@ async def sync_all_printers(
                     printer_tray_uuids[printer.name].add(spool_tag.upper())
 
                 try:
+                    # Look up inventory weight as fallback when AMS data is invalid
+                    inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
                     sync_result = await client.sync_ams_tray(
                         tray,
                         printer.name,
                         disable_weight_sync=disable_weight_sync,
                         cached_spools=cached_spools,
+                        inventory_remaining=inv_remaining,
                     )
                     if sync_result:
                         total_synced += 1
-                        # Add newly created spool to cache
+                        # Track synced spool ID for cleanup
                         if sync_result.get("id"):
+                            printer_synced_ids[printer.name].add(sync_result["id"])
+                            # Add newly created spool to cache
                             spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                             if not spool_exists:
                                 cached_spools.append(sync_result)
@@ -453,7 +494,10 @@ async def sync_all_printers(
     for printer_name, current_tray_uuids in printer_tray_uuids.items():
         try:
             cleared = await client.clear_location_for_removed_spools(
-                printer_name, current_tray_uuids, cached_spools=cached_spools
+                printer_name,
+                current_tray_uuids,
+                cached_spools=cached_spools,
+                synced_spool_ids=printer_synced_ids.get(printer_name, set()),
             )
             if cleared > 0:
                 logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)

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

@@ -103,13 +103,16 @@ def _apply_log_level(debug: bool):
 
     # Also adjust third-party loggers
     if debug:
-        logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
+        logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+        logging.getLogger("aiosqlite").setLevel(logging.WARNING)
         logging.getLogger("httpcore").setLevel(logging.DEBUG)
         logging.getLogger("httpx").setLevel(logging.DEBUG)
+        logging.getLogger("paho.mqtt").setLevel(logging.DEBUG)
     else:
         logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
         logging.getLogger("httpcore").setLevel(logging.WARNING)
         logging.getLogger("httpx").setLevel(logging.WARNING)
+        logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
     logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
 
@@ -473,7 +476,7 @@ async def _collect_support_info() -> dict:
                 for unit in ams_units:
                     trays = unit.get("tray", [])
                     ams_tray_count += len([t for t in trays if t.get("tray_type")])
-                has_vt_tray = state.raw_data.get("vt_tray") is not None
+                has_vt_tray = bool(state.raw_data.get("vt_tray"))
 
             info["printers"].append(
                 {

+ 340 - 0
backend/app/api/routes/system.py

@@ -1,6 +1,10 @@
 """System information API routes."""
 
+import asyncio
+import os
 import platform
+import time
+from collections.abc import Callable
 from datetime import datetime
 from pathlib import Path
 
@@ -23,6 +27,11 @@ from backend.app.services.printer_manager import printer_manager
 
 router = APIRouter(prefix="/system", tags=["system"])
 
+STORAGE_USAGE_CACHE_SECONDS = 300
+_storage_usage_cache: dict | None = None
+_storage_usage_cache_ts: float | None = None
+_storage_usage_lock = asyncio.Lock()
+
 
 def get_directory_size(path: Path) -> int:
     """Calculate total size of a directory in bytes."""
@@ -62,6 +71,326 @@ def format_uptime(seconds: float) -> str:
     return " ".join(parts) if parts else "< 1m"
 
 
+def _is_under(path: Path, root: Path) -> bool:
+    try:
+        path.resolve().relative_to(root.resolve())
+        return True
+    except ValueError:
+        return False
+
+
+def _get_database_paths() -> list[Path]:
+    candidates = [settings.base_dir / "bambuddy.db", settings.base_dir / "bambutrack.db"]
+    return [path for path in candidates if path.exists()]
+
+
+def _get_database_items() -> list[dict]:
+    items: list[dict] = []
+    for path in _get_database_paths():
+        try:
+            size = path.stat().st_size
+        except OSError:
+            continue
+        items.append(
+            {
+                "name": path.name,
+                "path": str(path),
+                "bytes": size,
+                "formatted": format_bytes(size),
+            }
+        )
+    items.sort(key=lambda item: item["bytes"], reverse=True)
+    return items
+
+
+def _get_app_dir() -> Path:
+    return settings.static_dir.parent
+
+
+def _get_data_dirs() -> list[Path]:
+    return [
+        settings.archive_dir,
+        settings.log_dir,
+        settings.plate_calibration_dir,
+        settings.base_dir / "virtual_printer",
+        settings.base_dir / "firmware",
+    ]
+
+
+def _is_system_path(path: Path) -> bool:
+    app_dir = _get_app_dir()
+    if not _is_under(path, app_dir):
+        return False
+    return all(not _is_under(path, data_dir) for data_dir in _get_data_dirs())
+
+
+def _get_storage_rules() -> list[tuple[str, str, Callable]]:
+    base_dir = settings.base_dir
+    archive_dir = settings.archive_dir
+    library_dir = archive_dir / "library"
+    virtual_printer_dir = base_dir / "virtual_printer"
+    upload_dir = virtual_printer_dir / "uploads"
+
+    db_paths = set(_get_database_paths())
+
+    return [
+        (
+            "database",
+            "Database",
+            lambda path: path in db_paths,
+        ),
+        (
+            "library_thumbnails",
+            "Library Thumbnails",
+            lambda path: _is_under(path, library_dir / "thumbnails"),
+        ),
+        (
+            "library_files",
+            "Library Files",
+            lambda path: _is_under(path, library_dir / "files"),
+        ),
+        (
+            "library_other",
+            "Library Other",
+            lambda path: _is_under(path, library_dir),
+        ),
+        (
+            "archive_timelapses",
+            "Timelapses",
+            lambda path: _is_under(path, archive_dir) and "timelapse" in path.name.lower(),
+        ),
+        (
+            "archive_thumbnails",
+            "Thumbnails",
+            lambda path: _is_under(path, archive_dir) and path.name.lower().startswith("thumbnail"),
+        ),
+        (
+            "archive_files",
+            "Archives",
+            lambda path: _is_under(path, archive_dir),
+        ),
+        (
+            "virtual_printer_upload_cache",
+            "Virtual Printer Upload Cache",
+            lambda path: _is_under(path, upload_dir / "cache"),
+        ),
+        (
+            "virtual_printer_uploads",
+            "Virtual Printer Uploads",
+            lambda path: _is_under(path, upload_dir),
+        ),
+        (
+            "virtual_printer_certs",
+            "Virtual Printer Certs",
+            lambda path: _is_under(path, virtual_printer_dir / "certs"),
+        ),
+        (
+            "virtual_printer_other",
+            "Virtual Printer Other",
+            lambda path: _is_under(path, virtual_printer_dir),
+        ),
+        (
+            "downloads",
+            "Downloads",
+            lambda path: _is_under(path, base_dir / "firmware"),
+        ),
+        (
+            "plate_calibration",
+            "Plate Calibration",
+            lambda path: _is_under(path, settings.plate_calibration_dir),
+        ),
+        (
+            "logs",
+            "Logs",
+            lambda path: _is_under(path, settings.log_dir),
+        ),
+    ]
+
+
+def _classify_file(path: Path, rules: list[tuple[str, str, Callable]]) -> tuple[str, str]:
+    for key, label, matcher in rules:
+        try:
+            if matcher(path):
+                return key, label
+        except OSError:
+            continue
+    return "other_data", "Other"
+
+
+def _format_percentage(part: int, total: int) -> float:
+    if total <= 0:
+        return 0.0
+    return round((part / total) * 100, 2)
+
+
+def _get_other_bucket(path: Path, base_dir: Path) -> str:
+    try:
+        relative = path.resolve().relative_to(base_dir.resolve())
+    except ValueError:
+        return path.parent.name or path.name
+
+    parts = relative.parts
+    return parts[0] if parts else path.name
+
+
+def _walk_files(roots: list[Path]) -> list[Path]:
+    files: list[Path] = []
+    stack = [root for root in roots if root.exists()]
+    while stack:
+        current = stack.pop()
+        try:
+            with os.scandir(current) as entries:
+                for entry in entries:
+                    try:
+                        if entry.is_symlink():
+                            continue
+                        if entry.is_dir(follow_symlinks=False):
+                            stack.append(Path(entry.path))
+                        elif entry.is_file(follow_symlinks=False):
+                            files.append(Path(entry.path))
+                    except OSError:
+                        continue
+        except OSError:
+            continue
+    return files
+
+
+def _scan_storage_usage() -> dict:
+    base_dir = settings.base_dir
+    rules = _get_storage_rules()
+
+    roots = _get_data_dirs()
+
+    seen_roots = set()
+    unique_roots = []
+    for root in roots:
+        resolved = root.resolve()
+        if resolved not in seen_roots:
+            seen_roots.add(resolved)
+            unique_roots.append(root)
+
+    total_bytes = 0
+    error_count = 0
+    category_sizes: dict[str, dict] = {}
+    other_breakdown: dict[tuple[str, str], int] = {}
+    database_items = _get_database_items()
+
+    files = _walk_files(unique_roots)
+    for file_path in files:
+        try:
+            size = file_path.stat().st_size
+        except OSError:
+            error_count += 1
+            continue
+
+        total_bytes += size
+
+        key, label = _classify_file(file_path, rules)
+        if key not in category_sizes:
+            category_sizes[key] = {"key": key, "label": label, "bytes": 0}
+        category_sizes[key]["bytes"] += size
+
+        if key == "other_data":
+            bucket = _get_other_bucket(file_path, base_dir)
+            kind = "system" if _is_system_path(file_path) else "data"
+            other_breakdown[(bucket, kind)] = other_breakdown.get((bucket, kind), 0) + size
+
+    for item in database_items:
+        total_bytes += item["bytes"]
+        key = "database"
+        label = "Database"
+        if key not in category_sizes:
+            category_sizes[key] = {"key": key, "label": label, "bytes": 0}
+        category_sizes[key]["bytes"] += item["bytes"]
+
+    categories = []
+    for item in category_sizes.values():
+        bytes_value = item["bytes"]
+        categories.append(
+            {
+                "key": item["key"],
+                "label": item["label"],
+                "bytes": bytes_value,
+                "formatted": format_bytes(bytes_value),
+                "percent_of_total": _format_percentage(bytes_value, total_bytes),
+            }
+        )
+
+    categories.sort(key=lambda entry: entry["bytes"], reverse=True)
+
+    other_items = []
+    for (bucket, kind), size in other_breakdown.items():
+        other_items.append(
+            {
+                "bucket": bucket,
+                "label": bucket,
+                "kind": kind,
+                "deletable": kind != "system",
+                "bytes": size,
+                "formatted": format_bytes(size),
+                "percent_of_total": _format_percentage(size, total_bytes),
+            }
+        )
+
+    other_items.sort(key=lambda entry: entry["bytes"], reverse=True)
+
+    return {
+        "roots": [str(root) for root in unique_roots],
+        "total_bytes": total_bytes,
+        "total_formatted": format_bytes(total_bytes),
+        "categories": categories,
+        "other_breakdown": other_items,
+        "scan_errors": error_count,
+    }
+
+
+async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict:
+    global _storage_usage_cache
+    global _storage_usage_cache_ts
+
+    now = time.time()
+    if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:
+        age = now - _storage_usage_cache_ts
+        if age < max_age_seconds:
+            return {
+                **_storage_usage_cache,
+                "cache": {
+                    "hit": True,
+                    "age_seconds": round(age, 2),
+                    "max_age_seconds": max_age_seconds,
+                },
+            }
+
+    async with _storage_usage_lock:
+        now = time.time()
+        if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:
+            age = now - _storage_usage_cache_ts
+            if age < max_age_seconds:
+                return {
+                    **_storage_usage_cache,
+                    "cache": {
+                        "hit": True,
+                        "age_seconds": round(age, 2),
+                        "max_age_seconds": max_age_seconds,
+                    },
+                }
+
+        snapshot = await asyncio.to_thread(_scan_storage_usage)
+        _storage_usage_cache = {
+            **snapshot,
+            "generated_at": datetime.now().isoformat(),
+        }
+        _storage_usage_cache_ts = time.time()
+        return {
+            **_storage_usage_cache,
+            "cache": {
+                "hit": False,
+                "age_seconds": 0,
+                "max_age_seconds": max_age_seconds,
+            },
+        }
+
+
 @router.get("/info")
 async def get_system_info(
     db: AsyncSession = Depends(get_db),
@@ -199,3 +528,14 @@ async def get_system_info(
             "percent": psutil.cpu_percent(interval=0.1),
         },
     }
+
+
+@router.get("/storage-usage")
+async def get_storage_usage(
+    refresh: bool = False,
+    max_age_seconds: int = STORAGE_USAGE_CACHE_SECONDS,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
+    """Get storage usage breakdown for Bambuddy data directories."""
+    max_age_seconds = max(0, min(max_age_seconds, 3600))
+    return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)

+ 13 - 0
backend/app/api/routes/updates.py

@@ -9,12 +9,14 @@ import sys
 
 import httpx
 from fastapi import APIRouter, BackgroundTasks, Depends
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.settings import Settings
 from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
@@ -176,6 +178,17 @@ async def check_for_updates(
     """Check GitHub for available updates."""
     global _update_status
 
+    # Respect the check_updates setting
+    result = await db.execute(select(Settings).where(Settings.key == "check_updates"))
+    setting = result.scalar_one_or_none()
+    if setting and setting.value.lower() == "false":
+        return {
+            "update_available": False,
+            "current_version": APP_VERSION,
+            "latest_version": None,
+            "message": "Update checks are disabled",
+        }
+
     _update_status = {
         "status": "checking",
         "progress": 0,

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

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

+ 828 - 0
backend/app/core/catalog_defaults.py

@@ -0,0 +1,828 @@
+"""Default spool and color catalog entries."""
+
+# (name, weight_in_grams)
+DEFAULT_SPOOL_CATALOG: list[tuple[str, int]] = [
+    ("3D FilaPrint - Cardboard", 210),
+    ("3D FilaPrint - Plastic", 238),
+    ("3D Fuel - Plastic", 264),
+    ("3D Power - Plastic", 220),
+    ("3D Solutech - Plastic", 173),
+    ("3DE - Cardboard", 136),
+    ("3DE - Plastic", 181),
+    ("3DHOJOR - Cardboard", 157),
+    ("3DJake - Cardboard", 209),
+    ("3DJake - Plastic", 232),
+    ("3DJake 250g - Plastic", 91),
+    ("3DJake ecoPLA - Plastic", 210),
+    ("3DXTech - Plastic", 258),
+    ("Acccreate - Plastic", 161),
+    ("Amazon Basics - Plastic", 234),
+    ("Amolen - Plastic", 150),
+    ("AMZ3D - Plastic", 233),
+    ("Anycubic - Cardboard", 125),
+    ("Anycubic - Plastic", 127),
+    ("Atomic Filament - Plastic", 272),
+    ("Aurapol - Plastic", 220),
+    ("Azure Film - Plastic", 163),
+    ("Bambu Lab - Plastic High Temp", 216),
+    ("Bambu Lab - Plastic Low Temp", 250),
+    ("Bambu Lab - Plastic White", 253),
+    ("BQ - Plastic", 218),
+    ("Colorfabb - Plastic", 236),
+    ("Colorfabb 750g - Cardboard", 152),
+    ("Colorfabb 750g - Plastic", 254),
+    ("Comgrow - Cardboard", 166),
+    ("Creality - Cardboard", 180),
+    ("Creality - Plastic", 135),
+    ("Das Filament - Plastic", 211),
+    ("Devil Design - Plastic", 256),
+    ("Duramic 3D - Cardboard", 136),
+    ("Elegoo - Cardboard", 153),
+    ("Elegoo - Plastic", 111),
+    ("Eryone - Cardboard", 156),
+    ("Eryone - Plastic", 187),
+    ("eSUN - Cardboard", 147),
+    ("eSUN - Plastic", 240),
+    ("eSUN 2.5kg - Plastic", 634),
+    ("Extrudr - Plastic", 244),
+    ("Fiberlogy - Plastic", 260),
+    ("Filament PM - Plastic", 224),
+    ("Fillamentum - Plastic", 230),
+    ("Flashforge - Plastic", 167),
+    ("FormFutura - Cardboard", 155),
+    ("FormFutura 750g - Plastic", 212),
+    ("Geeetech - Plastic", 178),
+    ("Gembird - Cardboard", 143),
+    ("Hatchbox - Plastic", 225),
+    ("Inland - Cardboard", 142),
+    ("Inland - Plastic", 210),
+    ("Jayo - Cardboard", 120),
+    ("Jayo - Plastic", 126),
+    ("Jayo 250g - Plastic", 58),
+    ("Kingroon - Cardboard", 155),
+    ("Kingroon - Plastic", 156),
+    ("KVP - Plastic", 263),
+    ("Matter Hackers - Plastic", 215),
+    ("MG Chemicals - Cardboard", 150),
+    ("MG Chemicals - Plastic", 239),
+    ("Mika3D - Plastic", 175),
+    ("MonoPrice - Plastic", 221),
+    ("Overture - Cardboard", 150),
+    ("Overture - Plastic", 237),
+    ("PolyMaker - Cardboard", 137),
+    ("PolyMaker - Plastic", 220),
+    ("PolyMaker 3kg - Cardboard", 418),
+    ("PolyTerra PLA - Cardboard", 147),
+    ("PrimaSelect - Plastic", 222),
+    ("ProtoPasta - Cardboard", 80),
+    ("Prusament - Plastic", 201),
+    ("Prusament - Plastic w/ Cardboard Core", 196),
+    ("Rosa3D - Plastic", 245),
+    ("Sakata3D - Plastic", 205),
+    ("Snapmaker - Cardboard", 148),
+    ("Sovol - Cardboard", 145),
+    ("Spectrum - Cardboard", 180),
+    ("Spectrum - Plastic", 257),
+    ("Sunlu - Plastic", 117),
+    ("Sunlu - Plastic V2", 165),
+    ("Sunlu - Plastic V3", 179),
+    ("Sunlu 250g - Plastic", 55),
+    ("UltiMaker - Plastic", 235),
+    ("Voolt3D - Plastic", 190),
+    ("Voxelab - Plastic", 171),
+    ("Wanhao - Plastic", 267),
+    ("Ziro - Plastic", 166),
+    ("ZYLtech - Plastic", 179),
+]
+
+# (manufacturer, color_name, hex_color, material)
+DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
+    # Bambu Lab PLA Basic (from official hex code PDF)
+    ("Bambu Lab", "Jade White", "#FFFFFF", "PLA Basic"),
+    ("Bambu Lab", "Black", "#000000", "PLA Basic"),
+    ("Bambu Lab", "Silver", "#A6A9AA", "PLA Basic"),
+    ("Bambu Lab", "Light Gray", "#D1D3D5", "PLA Basic"),
+    ("Bambu Lab", "Gray", "#8E9089", "PLA Basic"),
+    ("Bambu Lab", "Dark Gray", "#545454", "PLA Basic"),
+    ("Bambu Lab", "Red", "#C12E1F", "PLA Basic"),
+    ("Bambu Lab", "Maroon Red", "#9D2235", "PLA Basic"),
+    ("Bambu Lab", "Magenta", "#EC008C", "PLA Basic"),
+    ("Bambu Lab", "Hot Pink", "#F5547C", "PLA Basic"),
+    ("Bambu Lab", "Pink", "#F55A74", "PLA Basic"),
+    ("Bambu Lab", "Beige", "#F7E6DE", "PLA Basic"),
+    ("Bambu Lab", "Yellow", "#F4EE2A", "PLA Basic"),
+    ("Bambu Lab", "Sunflower Yellow", "#FEC600", "PLA Basic"),
+    ("Bambu Lab", "Gold", "#E4BD68", "PLA Basic"),
+    ("Bambu Lab", "Orange", "#FF6A13", "PLA Basic"),
+    ("Bambu Lab", "Pumpkin Orange", "#FF9016", "PLA Basic"),
+    ("Bambu Lab", "Bright Green", "#BECF00", "PLA Basic"),
+    ("Bambu Lab", "Bambu Green", "#00AE42", "PLA Basic"),
+    ("Bambu Lab", "Mistletoe Green", "#3F8E43", "PLA Basic"),
+    ("Bambu Lab", "Turquoise", "#00B1B7", "PLA Basic"),
+    ("Bambu Lab", "Cyan", "#0086D6", "PLA Basic"),
+    ("Bambu Lab", "Blue", "#0A2989", "PLA Basic"),
+    ("Bambu Lab", "Blue Grey", "#5B6579", "PLA Basic"),
+    ("Bambu Lab", "Cobalt Blue", "#0056B8", "PLA Basic"),
+    ("Bambu Lab", "Purple", "#5E43B7", "PLA Basic"),
+    ("Bambu Lab", "Indigo Purple", "#482960", "PLA Basic"),
+    ("Bambu Lab", "Brown", "#9D432C", "PLA Basic"),
+    ("Bambu Lab", "Cocoa Brown", "#6F5034", "PLA Basic"),
+    ("Bambu Lab", "Bronze", "#847D48", "PLA Basic"),
+    # Bambu Lab PLA Matte (from official hex code PDF)
+    ("Bambu Lab", "Ivory White", "#FFFFFF", "PLA Matte"),
+    ("Bambu Lab", "Bone White", "#CBC6B8", "PLA Matte"),
+    ("Bambu Lab", "Desert Tan", "#E8DBB7", "PLA Matte"),
+    ("Bambu Lab", "Latte Brown", "#D3B7A7", "PLA Matte"),
+    ("Bambu Lab", "Caramel", "#AE835B", "PLA Matte"),
+    ("Bambu Lab", "Terracotta", "#B15533", "PLA Matte"),
+    ("Bambu Lab", "Dark Brown", "#7D6556", "PLA Matte"),
+    ("Bambu Lab", "Dark Chocolate", "#4D3324", "PLA Matte"),
+    ("Bambu Lab", "Lilac Purple", "#AE96D4", "PLA Matte"),
+    ("Bambu Lab", "Sakura Pink", "#E8AFCF", "PLA Matte"),
+    ("Bambu Lab", "Mandarin Orange", "#F99963", "PLA Matte"),
+    ("Bambu Lab", "Lemon Yellow", "#F7D959", "PLA Matte"),
+    ("Bambu Lab", "Plum", "#950051", "PLA Matte"),
+    ("Bambu Lab", "Scarlet Red", "#DE4343", "PLA Matte"),
+    ("Bambu Lab", "Dark Red", "#BB3D43", "PLA Matte"),
+    ("Bambu Lab", "Dark Green", "#68724D", "PLA Matte"),
+    ("Bambu Lab", "Grass Green", "#61C680", "PLA Matte"),
+    ("Bambu Lab", "Apple Green", "#C2E189", "PLA Matte"),
+    ("Bambu Lab", "Ice Blue", "#A3D8E1", "PLA Matte"),
+    ("Bambu Lab", "Sky Blue", "#56B7E6", "PLA Matte"),
+    ("Bambu Lab", "Marine Blue", "#0078BF", "PLA Matte"),
+    ("Bambu Lab", "Dark Blue", "#042F56", "PLA Matte"),
+    ("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
+    ("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
+    ("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
+    # Bambu Lab PLA Silk+ (from store page)
+    ("Bambu Lab", "Gold", "#F4A925", "PLA Silk"),
+    ("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk"),
+    ("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk"),
+    ("Bambu Lab", "Blue", "#008BDA", "PLA Silk"),
+    ("Bambu Lab", "Purple", "#8671CB", "PLA Silk"),
+    ("Bambu Lab", "Candy Red", "#D02727", "PLA Silk"),
+    ("Bambu Lab", "Candy Green", "#018814", "PLA Silk"),
+    ("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk"),
+    ("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk"),
+    ("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk"),
+    ("Bambu Lab", "Mint", "#96DCB9", "PLA Silk"),
+    ("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk"),
+    ("Bambu Lab", "White", "#FFFFFF", "PLA Silk"),
+    # Bambu Lab PLA Sparkle (from store page)
+    ("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
+    ("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),
+    ("Bambu Lab", "Crimson Red Sparkle", "#792B36", "PLA Sparkle"),
+    ("Bambu Lab", "Royal Purple Sparkle", "#483D8B", "PLA Sparkle"),
+    ("Bambu Lab", "Alpine Green Sparkle", "#3F5443", "PLA Sparkle"),
+    ("Bambu Lab", "Onyx Black Sparkle", "#2D2B28", "PLA Sparkle"),
+    # Bambu Lab PLA Translucent (from official hex code PDF)
+    ("Bambu Lab", "Teal", "#009FA1", "PLA Translucent"),
+    ("Bambu Lab", "Light Jade", "#96D8AF", "PLA Translucent"),
+    ("Bambu Lab", "Blue", "#0047BB", "PLA Translucent"),
+    ("Bambu Lab", "Mellow Yellow", "#F5DBAB", "PLA Translucent"),
+    ("Bambu Lab", "Purple", "#8344B0", "PLA Translucent"),
+    ("Bambu Lab", "Cherry Pink", "#F5B6CD", "PLA Translucent"),
+    ("Bambu Lab", "Orange", "#F74E02", "PLA Translucent"),
+    ("Bambu Lab", "Ice Blue", "#B8CDE9", "PLA Translucent"),
+    ("Bambu Lab", "Red", "#B50011", "PLA Translucent"),
+    ("Bambu Lab", "Lavender", "#B8ACD6", "PLA Translucent"),
+    # Bambu Lab PLA Glow (from store page)
+    ("Bambu Lab", "Glow Green", "#A1FFAC", "PLA Glow"),
+    ("Bambu Lab", "Glow Yellow", "#F8FF80", "PLA Glow"),
+    ("Bambu Lab", "Glow Pink", "#F17B8F", "PLA Glow"),
+    ("Bambu Lab", "Glow Blue", "#7AC0E9", "PLA Glow"),
+    ("Bambu Lab", "Glow Orange", "#FF9D5B", "PLA Glow"),
+    # Bambu Lab PLA Galaxy (from store page)
+    ("Bambu Lab", "Brown", "#684A43", "PLA Galaxy"),
+    ("Bambu Lab", "Green", "#3B665E", "PLA Galaxy"),
+    ("Bambu Lab", "Nebulae", "#424379", "PLA Galaxy"),
+    ("Bambu Lab", "Purple", "#594177", "PLA Galaxy"),
+    # Bambu Lab PLA Metal (from store page)
+    ("Bambu Lab", "Iridium Gold Metallic", "#B39B84", "PLA Metal"),
+    ("Bambu Lab", "Copper Brown Metallic", "#AA6443", "PLA Metal"),
+    ("Bambu Lab", "Oxide Green Metallic", "#1D7C6A", "PLA Metal"),
+    ("Bambu Lab", "Cobalt Blue Metallic", "#39699E", "PLA Metal"),
+    ("Bambu Lab", "Iron Gray Metallic", "#43403D", "PLA Metal"),
+    # Bambu Lab PLA Marble (from store page)
+    ("Bambu Lab", "White Marble", "#F7F3F0", "PLA Marble"),
+    ("Bambu Lab", "Red Granite", "#AD4E38", "PLA Marble"),
+    # Bambu Lab PLA Wood (from store page)
+    ("Bambu Lab", "Black Walnut", "#4F3F24", "PLA Wood"),
+    ("Bambu Lab", "Rosewood", "#4C241C", "PLA Wood"),
+    ("Bambu Lab", "Clay Brown", "#995F11", "PLA Wood"),
+    ("Bambu Lab", "Classic Birch", "#918669", "PLA Wood"),
+    ("Bambu Lab", "White Oak", "#D6CCA3", "PLA Wood"),
+    ("Bambu Lab", "Ochre Yellow", "#C98935", "PLA Wood"),
+    # Bambu Lab PLA Tough+ (from official hex code PDF)
+    ("Bambu Lab", "White", "#FFFFFF", "PLA Tough"),
+    ("Bambu Lab", "Gray", "#AFB1AE", "PLA Tough"),
+    ("Bambu Lab", "Black", "#000000", "PLA Tough"),
+    ("Bambu Lab", "Silver", "#959698", "PLA Tough"),
+    ("Bambu Lab", "Yellow", "#F4D53F", "PLA Tough"),
+    ("Bambu Lab", "Cyan", "#009BD8", "PLA Tough"),
+    ("Bambu Lab", "Orange", "#DC3A27", "PLA Tough"),
+    # Bambu Lab PLA-CF (from official hex code PDF)
+    ("Bambu Lab", "Burgundy Red", "#951E23", "PLA-CF"),
+    ("Bambu Lab", "Iris Purple", "#69398E", "PLA-CF"),
+    ("Bambu Lab", "Matcha Green", "#5C9748", "PLA-CF"),
+    ("Bambu Lab", "Jeans Blue", "#6E88BC", "PLA-CF"),
+    ("Bambu Lab", "Royal Blue", "#2842AD", "PLA-CF"),
+    ("Bambu Lab", "Lava Gray", "#4D5054", "PLA-CF"),
+    ("Bambu Lab", "Black", "#000000", "PLA-CF"),
+    # Bambu Lab ABS (from official hex code PDF)
+    ("Bambu Lab", "White", "#FFFFFF", "ABS"),
+    ("Bambu Lab", "Desert Tan", "#E8DBB7", "ABS"),
+    ("Bambu Lab", "Olive", "#789D4A", "ABS"),
+    ("Bambu Lab", "Azure", "#489FDF", "ABS"),
+    ("Bambu Lab", "Navy Blue", "#0C2340", "ABS"),
+    ("Bambu Lab", "Blue", "#0A2CA5", "ABS"),
+    ("Bambu Lab", "Tangerine Yellow", "#FFC72C", "ABS"),
+    ("Bambu Lab", "Orange", "#FF6A13", "ABS"),
+    ("Bambu Lab", "Red", "#D32941", "ABS"),
+    ("Bambu Lab", "Purple", "#AF1685", "ABS"),
+    ("Bambu Lab", "Silver", "#87909A", "ABS"),
+    ("Bambu Lab", "Black", "#000000", "ABS"),
+    # Bambu Lab ASA (from store page)
+    ("Bambu Lab", "White", "#FFFAF2", "ASA"),
+    ("Bambu Lab", "Gray", "#8A949E", "ASA"),
+    ("Bambu Lab", "Red", "#E02928", "ASA"),
+    ("Bambu Lab", "Green", "#00A6A0", "ASA"),
+    ("Bambu Lab", "Blue", "#2140B4", "ASA"),
+    ("Bambu Lab", "Black", "#000000", "ASA"),
+    # Bambu Lab PETG HF (from store page)
+    ("Bambu Lab", "Yellow", "#FFD00B", "PETG HF"),
+    ("Bambu Lab", "Orange", "#F75403", "PETG HF"),
+    ("Bambu Lab", "Green", "#00AE42", "PETG HF"),
+    ("Bambu Lab", "Red", "#EB3A3A", "PETG HF"),
+    ("Bambu Lab", "Blue", "#002E96", "PETG HF"),
+    ("Bambu Lab", "Black", "#000000", "PETG HF"),
+    ("Bambu Lab", "White", "#FFFFFF", "PETG HF"),
+    ("Bambu Lab", "Cream", "#F9DFB9", "PETG HF"),
+    ("Bambu Lab", "Lime Green", "#6EE53C", "PETG HF"),
+    ("Bambu Lab", "Forest Green", "#39541A", "PETG HF"),
+    ("Bambu Lab", "Lake Blue", "#1F79E5", "PETG HF"),
+    ("Bambu Lab", "Peanut Brown", "#875718", "PETG HF"),
+    ("Bambu Lab", "Gray", "#ADB1B2", "PETG HF"),
+    ("Bambu Lab", "Dark Gray", "#515151", "PETG HF"),
+    # Bambu Lab PETG Translucent (from store page)
+    ("Bambu Lab", "Translucent Gray", "#8E8E8E", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Light Blue", "#61B0FF", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Olive", "#748C45", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Brown", "#C9A381", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Teal", "#77EDD7", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Orange", "#FF911A", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Purple", "#D6ABFF", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Pink", "#F9C1BD", "PETG Translucent"),
+    # Bambu Lab PETG-CF (from official hex code PDF)
+    ("Bambu Lab", "Brick Red", "#9F332A", "PETG-CF"),
+    ("Bambu Lab", "Violet Purple", "#583061", "PETG-CF"),
+    ("Bambu Lab", "Indigo Blue", "#324585", "PETG-CF"),
+    ("Bambu Lab", "Malachite Green", "#16B08E", "PETG-CF"),
+    ("Bambu Lab", "Black", "#000000", "PETG-CF"),
+    ("Bambu Lab", "Titan Gray", "#565656", "PETG-CF"),
+    # Bambu Lab TPU 95A HF (from store page)
+    ("Bambu Lab", "White", "#FFFFFF", "TPU 95A"),
+    ("Bambu Lab", "Yellow", "#F3E600", "TPU 95A"),
+    ("Bambu Lab", "Blue", "#0072CE", "TPU 95A"),
+    ("Bambu Lab", "Red", "#C8102E", "TPU 95A"),
+    ("Bambu Lab", "Gray", "#898D8D", "TPU 95A"),
+    ("Bambu Lab", "Black", "#101820", "TPU 95A"),
+    # Bambu Lab TPU 90A (from official hex code PDF)
+    ("Bambu Lab", "Black", "#000000", "TPU 90A"),
+    ("Bambu Lab", "White", "#FFFFFF", "TPU 90A"),
+    ("Bambu Lab", "Grape Jelly", "#D6ABFF", "TPU 90A"),
+    ("Bambu Lab", "Crystal Blue", "#7EB4E1", "TPU 90A"),
+    ("Bambu Lab", "Cocoa Brown", "#5C4738", "TPU 90A"),
+    # Bambu Lab PAHT-CF
+    ("Bambu Lab", "Black", "#1A1A1A", "PAHT-CF"),
+    # Bambu Lab Support Materials
+    ("Bambu Lab", "Natural", "#F5F5DC", "PLA Support"),
+    ("Bambu Lab", "Natural", "#F5F5DC", "PVA Support"),
+    # Polymaker PolyTerra PLA
+    ("Polymaker", "Cotton White", "#F5F5F5", "PolyTerra PLA"),
+    ("Polymaker", "Charcoal Black", "#2B2B2B", "PolyTerra PLA"),
+    ("Polymaker", "Marble White", "#E8E8E8", "PolyTerra PLA"),
+    ("Polymaker", "Fossil Grey", "#6B6B6B", "PolyTerra PLA"),
+    ("Polymaker", "Shadow Black", "#1A1A1A", "PolyTerra PLA"),
+    ("Polymaker", "Army Red", "#8B0000", "PolyTerra PLA"),
+    ("Polymaker", "Lava Red", "#CF1020", "PolyTerra PLA"),
+    ("Polymaker", "Sakura Pink", "#FFB7C5", "PolyTerra PLA"),
+    ("Polymaker", "Rose", "#FF007F", "PolyTerra PLA"),
+    ("Polymaker", "Peach", "#FFCBA4", "PolyTerra PLA"),
+    ("Polymaker", "Banana", "#FFE135", "PolyTerra PLA"),
+    ("Polymaker", "Savannah Yellow", "#F4C430", "PolyTerra PLA"),
+    ("Polymaker", "Sunrise Orange", "#FF6600", "PolyTerra PLA"),
+    ("Polymaker", "Muted Green", "#4F7942", "PolyTerra PLA"),
+    ("Polymaker", "Forest Green", "#228B22", "PolyTerra PLA"),
+    ("Polymaker", "Mint", "#98FF98", "PolyTerra PLA"),
+    ("Polymaker", "Lavender Purple", "#B57EDC", "PolyTerra PLA"),
+    ("Polymaker", "Sapphire Blue", "#0F52BA", "PolyTerra PLA"),
+    ("Polymaker", "Ice", "#D6ECEF", "PolyTerra PLA"),
+    # Prusament PLA
+    ("Prusament", "Jet Black", "#1A1A1A", "PLA"),
+    ("Prusament", "Galaxy Black", "#1F1F1F", "PLA"),
+    ("Prusament", "Pristine White", "#FFFFFF", "PLA"),
+    ("Prusament", "Gentleman's Grey", "#5A5A5A", "PLA"),
+    ("Prusament", "Lipstick Red", "#C21E1E", "PLA"),
+    ("Prusament", "Orange", "#FF6600", "PLA"),
+    ("Prusament", "Pineapple Yellow", "#FFD700", "PLA"),
+    ("Prusament", "Jungle Green", "#29AB87", "PLA"),
+    ("Prusament", "Azure Blue", "#007FFF", "PLA"),
+    ("Prusament", "Royal Blue", "#4169E1", "PLA"),
+    ("Prusament", "Mystic Purple", "#7B68EE", "PLA"),
+    # eSUN PLA+ (from FilamentColors.xyz measured swatches)
+    ("eSUN", "Beige", "#ECCAB0", "PLA+"),
+    ("eSUN", "Black", "#373838", "PLA+"),
+    ("eSUN", "Blue", "#054795", "PLA+"),
+    ("eSUN", "Bone White", "#C2BAA7", "PLA+"),
+    ("eSUN", "Brown", "#6F513C", "PLA+"),
+    ("eSUN", "Cool White", "#E1E4E5", "PLA+"),
+    ("eSUN", "Dark Blue", "#2F314D", "PLA+"),
+    ("eSUN", "Fire Engine Red", "#91202B", "PLA+"),
+    ("eSUN", "Gold", "#C99B26", "PLA+"),
+    ("eSUN", "Gray", "#697480", "PLA+"),
+    ("eSUN", "Green", "#015E58", "PLA+"),
+    ("eSUN", "Grey", "#5F6574", "PLA+"),
+    ("eSUN", "Light Blue", "#48BFD5", "PLA+"),
+    ("eSUN", "Light Brown", "#A27556", "PLA+"),
+    ("eSUN", "Luminous Blue", "#C8CAC8", "PLA+"),
+    ("eSUN", "Magenta", "#DA3B6C", "PLA+"),
+    ("eSUN", "Olive Green", "#555B45", "PLA+"),
+    ("eSUN", "Orange", "#EF7749", "PLA+"),
+    ("eSUN", "Peak Green", "#A1DA7C", "PLA+"),
+    ("eSUN", "Pink", "#E78397", "PLA+"),
+    ("eSUN", "Purple", "#8350A4", "PLA+"),
+    ("eSUN", "Red", "#C4402A", "PLA+"),
+    ("eSUN", "Silver", "#8B8889", "PLA+"),
+    ("eSUN", "Skin", "#E3C7AF", "PLA+"),
+    ("eSUN", "White", "#E1E9E9", "PLA+"),
+    ("eSUN", "Yellow", "#FBCE2B", "PLA+"),
+    # eSUN Pro PLA+
+    ("eSUN", "Blue", "#065AA1", "Pro PLA+"),
+    # eSUN PLA
+    ("eSUN", "Glow in the Dark", "#C5C2AB", "PLA"),
+    ("eSUN", "Marble White", "#B5BCC0", "PLA"),
+    ("eSUN", "Natural Wood", "#EBCFA6", "PLA"),
+    ("eSUN", "Pine Green", "#375C49", "PLA"),
+    ("eSUN", "UV Change Purple", "#CABBA9", "PLA"),
+    ("eSUN", "eTwinkling Blue", "#115CAF", "PLA"),
+    ("eSUN", "eStars Galaxy Black", "#403936", "PLA"),
+    # eSUN PLA Silk
+    ("eSUN", "Silk Blue", "#2275AA", "PLA Silk"),
+    ("eSUN", "Silk Bronze", "#829172", "PLA Silk"),
+    ("eSUN", "Silk Copper", "#AE6B2F", "PLA Silk"),
+    ("eSUN", "Silk Cyan", "#34A7CF", "PLA Silk"),
+    ("eSUN", "Silk Dark Yellow", "#D4A62E", "PLA Silk"),
+    ("eSUN", "Silk Gold", "#C48E2F", "PLA Silk"),
+    ("eSUN", "Silk Green", "#7FCB43", "PLA Silk"),
+    ("eSUN", "Silk Jacinth", "#DA8061", "PLA Silk"),
+    ("eSUN", "Silk Lime", "#C1D762", "PLA Silk"),
+    ("eSUN", "Silk Magic Green Blue", "#508669", "PLA Silk"),
+    ("eSUN", "Silk Purple", "#905295", "PLA Silk"),
+    ("eSUN", "Silk Red", "#C94830", "PLA Silk"),
+    ("eSUN", "Silk Rose Gold", "#C7886B", "PLA Silk"),
+    ("eSUN", "Silk Silver", "#B5C1C5", "PLA Silk"),
+    ("eSUN", "Silk Violet", "#B93CA1", "PLA Silk"),
+    ("eSUN", "Silk White", "#E3E0DB", "PLA Silk"),
+    ("eSUN", "Silk Yellow", "#DED74B", "PLA Silk"),
+    # eSUN PLA Metal
+    ("eSUN", "Bronze", "#917F57", "PLA Metal"),
+    # eSUN PLA-ST
+    ("eSUN", "Grey", "#626C77", "PLA-ST"),
+    # eSUN PETG
+    ("eSUN", "Black", "#353434", "PETG"),
+    ("eSUN", "Magenta", "#E03E76", "PETG"),
+    ("eSUN", "Solid Blue", "#1A6FB4", "PETG"),
+    ("eSUN", "Solid Green", "#008A58", "PETG"),
+    ("eSUN", "Solid Purple", "#7A4795", "PETG"),
+    ("eSUN", "Solid White", "#F4F1F1", "PETG"),
+    ("eSUN", "Solid Yellow", "#F0CA41", "PETG"),
+    ("eSUN", "Translucent Green", "#378041", "PETG"),
+    ("eSUN", "Translucent Orange", "#DD7135", "PETG"),
+    ("eSUN", "White", "#E7EDED", "PETG"),
+    # eSUN PETG-HS (High Speed)
+    ("eSUN", "Black", "#424445", "PETG-HS"),
+    ("eSUN", "Solid Blue", "#1A6FB4", "PETG-HS"),
+    # eSUN ABS
+    ("eSUN", "Black", "#3F3A3F", "ABS"),
+    ("eSUN", "Brown", "#624741", "ABS"),
+    ("eSUN", "Natural", "#D9E3DD", "ABS"),
+    ("eSUN", "Pine Green", "#3C694E", "ABS"),
+    ("eSUN", "Pink", "#E86477", "ABS"),
+    ("eSUN", "Red", "#A74237", "ABS"),
+    ("eSUN", "Silver", "#838080", "ABS"),
+    # eSUN ABS+
+    ("eSUN", "Gray", "#616777", "ABS+"),
+    ("eSUN", "Green", "#018068", "ABS+"),
+    ("eSUN", "Natural", "#E4DEC9", "ABS+"),
+    ("eSUN", "Orange", "#EE7845", "ABS+"),
+    ("eSUN", "Silver", "#7F807E", "ABS+"),
+    ("eSUN", "White", "#E1E1DF", "ABS+"),
+    ("eSUN", "Yellow", "#D3BC0F", "ABS+"),
+    # Hatchbox PLA
+    ("Hatchbox", "White", "#FFFFFF", "PLA"),
+    ("Hatchbox", "Black", "#000000", "PLA"),
+    ("Hatchbox", "Gray", "#808080", "PLA"),
+    ("Hatchbox", "Red", "#FF0000", "PLA"),
+    ("Hatchbox", "Blue", "#0000FF", "PLA"),
+    ("Hatchbox", "Green", "#00FF00", "PLA"),
+    ("Hatchbox", "Yellow", "#FFFF00", "PLA"),
+    ("Hatchbox", "Orange", "#FFA500", "PLA"),
+    ("Hatchbox", "Purple", "#800080", "PLA"),
+    ("Hatchbox", "Pink", "#FFC0CB", "PLA"),
+    ("Hatchbox", "True Blue", "#0073CF", "PLA"),
+    ("Hatchbox", "True Green", "#008000", "PLA"),
+    # Overture PLA (from FilamentColors.xyz measured swatches)
+    ("Overture", "Black", "#2B292E", "PLA"),
+    ("Overture", "Blue", "#034070", "PLA"),
+    ("Overture", "Cement Gray", "#48494A", "PLA"),
+    ("Overture", "Dark Blue", "#124775", "PLA"),
+    ("Overture", "Fresh Red", "#C01F1D", "PLA"),
+    ("Overture", "Gray Blue", "#6D8790", "PLA"),
+    ("Overture", "Green", "#318C49", "PLA"),
+    ("Overture", "Highlight Yellow", "#FBF93C", "PLA"),
+    ("Overture", "Light Blue", "#7CC4D5", "PLA"),
+    ("Overture", "Light Gray", "#8F9694", "PLA"),
+    ("Overture", "Neon Green Air", "#C5ED33", "PLA"),
+    ("Overture", "Olive Green", "#8F843D", "PLA"),
+    ("Overture", "Pink", "#DC99B4", "PLA"),
+    ("Overture", "Red", "#C9341A", "PLA"),
+    ("Overture", "Royal Gold", "#C58F31", "PLA"),
+    ("Overture", "Space Grey", "#797779", "PLA"),
+    ("Overture", "White", "#E7EBE3", "PLA"),
+    # Overture PLA Matte
+    ("Overture", "Black", "#3F3E41", "PLA Matte"),
+    ("Overture", "Blue", "#277EAB", "PLA Matte"),
+    ("Overture", "Brick Red", "#AE4848", "PLA Matte"),
+    ("Overture", "Green", "#5EAE73", "PLA Matte"),
+    ("Overture", "Light Grey", "#919598", "PLA Matte"),
+    ("Overture", "Light Brown", "#BF9C80", "PLA Matte"),
+    ("Overture", "Light Green", "#A1C1A5", "PLA Matte"),
+    ("Overture", "Olive Green", "#B59837", "PLA Matte"),
+    ("Overture", "Orange", "#F59752", "PLA Matte"),
+    ("Overture", "Pink", "#EBBDCE", "PLA Matte"),
+    ("Overture", "Purple", "#978DC5", "PLA Matte"),
+    ("Overture", "White", "#E1E4DD", "PLA Matte"),
+    ("Overture", "Yellow", "#FFD359", "PLA Matte"),
+    # Overture PLA Pro
+    ("Overture", "Digital Blue", "#008FBE", "PLA Pro"),
+    ("Overture", "Light Blue", "#68C8DB", "PLA Pro"),
+    ("Overture", "Orange", "#F27C1B", "PLA Pro"),
+    ("Overture", "Purple", "#7B5DB0", "PLA Pro"),
+    ("Overture", "Red", "#E62F18", "PLA Pro"),
+    ("Overture", "Yellow", "#DFB233", "PLA Pro"),
+    # Overture PETG
+    ("Overture", "Black", "#2F2821", "PETG"),
+    ("Overture", "Blue", "#225291", "PETG"),
+    ("Overture", "Clear", "#BEC3C5", "PETG"),
+    ("Overture", "Pink", "#E0A1BA", "PETG"),
+    ("Overture", "Purple", "#67518F", "PETG"),
+    ("Overture", "Rock White", "#C2C8C9", "PETG"),
+    ("Overture", "Red", "#AB291B", "PETG"),
+    ("Overture", "Space Grey", "#80817E", "PETG"),
+    ("Overture", "Translucent Blue", "#38487B", "PETG"),
+    ("Overture", "White", "#E7E9E7", "PETG"),
+    ("Overture", "Yellow", "#E6B93C", "PETG"),
+    # Overture ABS
+    ("Overture", "Diamond Gray", "#5D5F5F", "ABS"),
+    ("Overture", "Diamond Purple", "#6B649D", "ABS"),
+    # Overture Silk PLA
+    ("Overture", "Gold", "#CA9B52", "Silk PLA"),
+    ("Overture", "Neon Green", "#C2D74D", "Silk PLA"),
+    ("Overture", "Copper", "#B27052", "Silk PLA"),
+    # Overture Glow PLA
+    ("Overture", "Glow Blue", "#4EA2AA", "Glow PLA"),
+    ("Overture", "Glow Orange", "#C2895E", "Glow PLA"),
+    ("Overture", "Glow Red", "#C27B7D", "Glow PLA"),
+    ("Overture", "Glow Yellow", "#E3F079", "Glow PLA"),
+    # Sunlu PLA (from FilamentColors.xyz measured swatches)
+    ("Sunlu", "Black", "#3C3C3C", "PLA"),
+    ("Sunlu", "Blue", "#006AB8", "PLA"),
+    ("Sunlu", "Cherry Red", "#EA4A5D", "PLA"),
+    ("Sunlu", "Glow in the Dark", "#CBCAB8", "PLA"),
+    ("Sunlu", "Green Mint", "#4CCB9A", "PLA"),
+    ("Sunlu", "Grey", "#6B6E6E", "PLA"),
+    ("Sunlu", "Orange", "#E77932", "PLA"),
+    ("Sunlu", "Red", "#AC3637", "PLA"),
+    ("Sunlu", "Sky Blue", "#0CB7CC", "PLA"),
+    ("Sunlu", "Sunny Orange", "#FF7235", "PLA"),
+    ("Sunlu", "Transparent", "#C8C7BF", "PLA"),
+    ("Sunlu", "Transparent Orange", "#DB7F42", "PLA"),
+    ("Sunlu", "White", "#DEDFD9", "PLA"),
+    ("Sunlu", "Wood", "#D5BA95", "PLA"),
+    # Sunlu PLA Silk
+    ("Sunlu", "Silk Black", "#737272", "PLA Silk"),
+    ("Sunlu", "Silk Green", "#34C0A5", "PLA Silk"),
+    ("Sunlu", "Silk Red", "#CD5C62", "PLA Silk"),
+    ("Sunlu", "Silky Silver", "#C6CBD0", "PLA Silk"),
+    # Sunlu PLA Meta
+    ("Sunlu", "Blue", "#00B2CC", "PLA Meta"),
+    ("Sunlu", "Mint Green", "#03A490", "PLA Meta"),
+    ("Sunlu", "Sakura Pink", "#F5B5C2", "PLA Meta"),
+    ("Sunlu", "Taro Purple", "#A69ED0", "PLA Meta"),
+    # Sunlu PLA+
+    ("Sunlu", "Beige", "#DDBCAC", "PLA+"),
+    ("Sunlu", "Black", "#3A3B3B", "PLA+"),
+    ("Sunlu", "Blue", "#0063A0", "PLA+"),
+    ("Sunlu", "Green", "#4EE349", "PLA+"),
+    ("Sunlu", "Light Gold", "#D3943D", "PLA+"),
+    ("Sunlu", "Mint Green", "#00B39A", "PLA+"),
+    ("Sunlu", "Orange", "#ED7432", "PLA+"),
+    ("Sunlu", "Pure Yellow", "#FFBD2C", "PLA+"),
+    ("Sunlu", "Purple", "#8887C5", "PLA+"),
+    ("Sunlu", "Red", "#B34044", "PLA+"),
+    ("Sunlu", "Silk Blue", "#33ACD4", "PLA+"),
+    ("Sunlu", "Silk Brass", "#F1A050", "PLA+"),
+    ("Sunlu", "Silk Pink", "#FFCAD9", "PLA+"),
+    ("Sunlu", "Silk White", "#EEEFE7", "PLA+"),
+    ("Sunlu", "Skin", "#F7BEA1", "PLA+"),
+    ("Sunlu", "White", "#E6E6E2", "PLA+"),
+    # Sunlu PETG
+    ("Sunlu", "Black", "#3F4141", "PETG"),
+    ("Sunlu", "Blue", "#0068AB", "PETG"),
+    ("Sunlu", "Green", "#67DB25", "PETG"),
+    ("Sunlu", "Olive Green", "#707D63", "PETG"),
+    ("Sunlu", "Transparent", "#BAB9B4", "PETG"),
+    ("Sunlu", "White", "#DBDDD9", "PETG"),
+    # Sunlu ABS
+    ("Sunlu", "Black", "#404142", "ABS"),
+    # Creality Hyper PLA (from FilamentColors.xyz measured swatches)
+    ("Creality", "Black", "#282C2C", "Hyper PLA"),
+    ("Creality", "Blue", "#0881BE", "Hyper PLA"),
+    ("Creality", "Grey", "#7A7C7C", "Hyper PLA"),
+    ("Creality", "Purple", "#B0347E", "Hyper PLA"),
+    ("Creality", "Red", "#C32E2F", "Hyper PLA"),
+    ("Creality", "White", "#DEE4E1", "Hyper PLA"),
+    # Creality Hyper PLA-CF
+    ("Creality", "Black", "#322F2D", "Hyper PLA-CF"),
+    # Creality PLA
+    ("Creality", "Gray", "#8F9395", "PLA"),
+    ("Creality", "White", "#E1DFD0", "PLA"),
+    # Creality PETG
+    ("Creality", "White", "#E3E5E1", "PETG"),
+    # Creality Silk PLA
+    ("Creality", "Blue-Green", "#479B7D", "Silk PLA"),
+    # Elegoo PLA (from FilamentColors.xyz measured swatches)
+    ("Elegoo", "Black", "#282929", "PLA"),
+    ("Elegoo", "Clear", "#BEBBBF", "PLA"),
+    ("Elegoo", "Galaxy Black", "#32464E", "PLA"),
+    ("Elegoo", "Galaxy Purple", "#3A2F6F", "PLA"),
+    ("Elegoo", "Grey", "#B5B7B7", "PLA"),
+    ("Elegoo", "Peacock Blue", "#21606B", "PLA"),
+    ("Elegoo", "Sky Blue", "#46C8D4", "PLA"),
+    # Elegoo PLA+
+    ("Elegoo", "Black", "#343132", "PLA+"),
+    ("Elegoo", "Orange", "#CC6A2F", "PLA+"),
+    ("Elegoo", "Purple", "#6E45A7", "PLA+"),
+    # Elegoo Silk PLA
+    ("Elegoo", "Coral Pink", "#DB6E6D", "Silk PLA"),
+    ("Elegoo", "Gold", "#E2AC00", "Silk PLA"),
+    ("Elegoo", "Silver", "#93969B", "Silk PLA"),
+    # Jayo PLA+ (from FilamentColors.xyz measured swatches)
+    ("Jayo", "Black", "#2F2E2D", "PLA+"),
+    ("Jayo", "Cherry Red", "#C43536", "PLA+"),
+    ("Jayo", "White", "#D9E0E7", "PLA+"),
+    # Inland PLA (from FilamentColors.xyz measured swatches)
+    ("Inland", "Black", "#27272C", "PLA"),
+    ("Inland", "Blue", "#044482", "PLA"),
+    ("Inland", "Coral", "#C16062", "PLA"),
+    ("Inland", "Egyptian Blue", "#075AAC", "PLA"),
+    ("Inland", "Gold", "#D7B536", "PLA"),
+    ("Inland", "Green", "#407166", "PLA"),
+    ("Inland", "Grey", "#6F7983", "PLA"),
+    ("Inland", "Light Blue", "#3CA4B8", "PLA"),
+    ("Inland", "Military Green", "#5B6D37", "PLA"),
+    ("Inland", "Pink", "#FC97AF", "PLA"),
+    ("Inland", "Red", "#C43220", "PLA"),
+    ("Inland", "Silver", "#8A8F92", "PLA"),
+    ("Inland", "True Red", "#B13137", "PLA"),
+    ("Inland", "White", "#E0E3E3", "PLA"),
+    ("Inland", "Wood", "#DEB98F", "PLA"),
+    # Inland PLA+
+    ("Inland", "Black", "#2B272B", "PLA+"),
+    ("Inland", "Blue", "#054990", "PLA+"),
+    ("Inland", "Bone White", "#ABA18F", "PLA+"),
+    ("Inland", "Dark Blue", "#2C3353", "PLA+"),
+    ("Inland", "Light Blue", "#079FBF", "PLA+"),
+    ("Inland", "Magenta", "#DE2B60", "PLA+"),
+    ("Inland", "Orange", "#FB8B5A", "PLA+"),
+    ("Inland", "Pink", "#F291A4", "PLA+"),
+    ("Inland", "Purple", "#744FA0", "PLA+"),
+    ("Inland", "Silver", "#868A8B", "PLA+"),
+    ("Inland", "White", "#E3E5E5", "PLA+"),
+    ("Inland", "Yellow", "#F8D008", "PLA+"),
+    # Inland PETG
+    ("Inland", "Blue", "#084480", "PETG"),
+    ("Inland", "Green", "#2B783E", "PETG"),
+    ("Inland", "Magenta", "#E14170", "PETG"),
+    ("Inland", "Transparent", "#D1D6D1", "PETG"),
+    ("Inland", "True Red", "#97392B", "PETG"),
+    # Inland ABS
+    ("Inland", "Grey", "#8A97A2", "ABS"),
+    ("Inland", "Light Blue", "#6CBECF", "ABS"),
+    ("Inland", "Orange", "#E8712F", "ABS"),
+    # Inland Tough PLA
+    ("Inland", "Light Gray", "#8D9497", "Tough PLA"),
+    ("Inland", "Yellow", "#FFBB3F", "Tough PLA"),
+    # Eryone PLA (from FilamentColors.xyz measured swatches)
+    ("Eryone", "Galaxy Purple", "#60617B", "PLA"),
+    ("Eryone", "Galaxy Red", "#8E3332", "PLA"),
+    ("Eryone", "Glow in the Dark", "#C2C1AF", "PLA"),
+    ("Eryone", "Ivory White", "#DCDCD3", "PLA"),
+    ("Eryone", "Silk Blue", "#64A9D3", "PLA"),
+    ("Eryone", "Silk Copper", "#B36A50", "PLA"),
+    ("Eryone", "Silk Gold", "#D5983D", "PLA"),
+    ("Eryone", "Silk Gold Copper", "#D69366", "PLA"),
+    ("Eryone", "Silk Gold Silver", "#ABA787", "PLA"),
+    ("Eryone", "Ultra Silk Black", "#5B6264", "PLA"),
+    ("Eryone", "Ultra Silk Copper", "#B46A4D", "PLA"),
+    ("Eryone", "Ultra Silk Silver", "#999BA5", "PLA"),
+    # Eryone PLA+
+    ("Eryone", "Army Green", "#5D644D", "PLA+"),
+    # Eryone ASA
+    ("Eryone", "Black", "#414446", "ASA"),
+    # Eryone PLA Wood
+    ("Eryone", "Light Wood", "#A5886E", "PLA Wood"),
+    # ColorFabb PLA (from FilamentColors.xyz measured swatches)
+    ("ColorFabb", "Stonefill Light Gray", "#A9B2B7", "PLA"),
+    ("ColorFabb", "WoodFill", "#B89775", "PLA"),
+    # ColorFabb PLA/PHA
+    ("ColorFabb", "CopperFill", "#9D7465", "PLA/PHA"),
+    ("ColorFabb", "CorkFill", "#7F6150", "PLA/PHA"),
+    ("ColorFabb", "Natural", "#CFCFC2", "PLA/PHA"),
+    # ColorFabb XT
+    ("ColorFabb", "Light Gray", "#BFC5BE", "XT"),
+    ("ColorFabb", "Black", "#3B3635", "XT"),
+    # Fillamentum PLA Extrafill (from FilamentColors.xyz measured swatches)
+    ("Fillamentum", "Baby Blue", "#B9D7DC", "PLA Extrafill"),
+    ("Fillamentum", "Chocolate Brown", "#5B4A45", "PLA Extrafill"),
+    ("Fillamentum", "Cobalt Blue", "#333D5C", "PLA Extrafill"),
+    ("Fillamentum", "Crystal Clear Smaragd Green", "#028D77", "PLA Extrafill"),
+    ("Fillamentum", "Everybody's Magenta", "#E1347D", "PLA Extrafill"),
+    ("Fillamentum", "Gold Happens", "#BC994D", "PLA Extrafill"),
+    ("Fillamentum", "Mukha", "#A88866", "PLA Extrafill"),
+    ("Fillamentum", "Pearl Night Blue", "#045589", "PLA Extrafill"),
+    ("Fillamentum", "Pearl Ruby Red", "#791F2A", "PLA Extrafill"),
+    ("Fillamentum", "Rapunzel Silver", "#AFAFB0", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Cherry", "#752F38", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Galaxy", "#333928", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Grey", "#5A5963", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Starlight", "#343A4F", "PLA Extrafill"),
+    ("Fillamentum", "Wizard's Voodoo", "#3F465E", "PLA Extrafill"),
+    # Fillamentum PLA (Crystal Clear / Timberfill / Vertigo lines)
+    ("Fillamentum", "Crystal Clear", "#EBECF2", "PLA"),
+    ("Fillamentum", "Crystal Clear Amethyst Purple", "#9F99BC", "PLA"),
+    ("Fillamentum", "Crystal Clear Iceland Blue", "#82BBCD", "PLA"),
+    ("Fillamentum", "Crystal Clear Tangerine Orange", "#ECD082", "PLA"),
+    ("Fillamentum", "Lilac", "#A99FCF", "PLA"),
+    ("Fillamentum", "Timberfill Cinnamon", "#AC7C67", "PLA"),
+    ("Fillamentum", "Timberfill Rosewood", "#6A564E", "PLA"),
+    ("Fillamentum", "Vertigo Jade", "#217F60", "PLA"),
+    # Fillamentum ASA Extrafill
+    ("Fillamentum", "Anthracite Grey", "#4B4F50", "ASA Extrafill"),
+    ("Fillamentum", "Green Grass", "#678653", "ASA Extrafill"),
+    ("Fillamentum", "Grey Blue", "#495965", "ASA Extrafill"),
+    ("Fillamentum", "Metallic Grey", "#878A8C", "ASA Extrafill"),
+    ("Fillamentum", "Sky Blue", "#0783B6", "ASA Extrafill"),
+    ("Fillamentum", "Snow White", "#F3F3EF", "ASA Extrafill"),
+    ("Fillamentum", "Traffic Black", "#3B3B3F", "ASA Extrafill"),
+    ("Fillamentum", "Traffic White", "#E9E7DA", "ASA Extrafill"),
+    ("Fillamentum", "White Aluminium", "#9CA1A2", "ASA Extrafill"),
+    # Fillamentum CPE HG100
+    ("Fillamentum", "Black Soul", "#292B27", "CPE HG100"),
+    ("Fillamentum", "Ghost White", "#E5E8E7", "CPE HG100"),
+    ("Fillamentum", "Natural", "#DCE4DF", "CPE HG100"),
+    # Fillamentum Flexfill TPU 98A
+    ("Fillamentum", "Blue Transparent", "#047990", "Flexfill TPU 98A"),
+    ("Fillamentum", "Carrot Orange", "#EA6E21", "Flexfill TPU 98A"),
+    ("Fillamentum", "Metallic Grey", "#8F8E8F", "Flexfill TPU 98A"),
+    ("Fillamentum", "Pistachio Green", "#A7BE36", "Flexfill TPU 98A"),
+    ("Fillamentum", "Signal Red", "#9A2222", "Flexfill TPU 98A"),
+    ("Fillamentum", "Traffic Black", "#26262A", "Flexfill TPU 98A"),
+    ("Fillamentum", "Vertigo Grey", "#515150", "Flexfill TPU 98A"),
+    # FormFutura PLA (from FilamentColors.xyz measured swatches)
+    ("FormFutura", "Basalt Grey", "#5B5F61", "PLA"),
+    ("FormFutura", "Dark Blue", "#084B86", "PLA"),
+    ("FormFutura", "Galaxy Champagne Gold", "#AE9D83", "PLA"),
+    ("FormFutura", "Gold High Gloss", "#C89B4B", "PLA"),
+    ("FormFutura", "High Gloss White", "#D0D7D8", "PLA"),
+    ("FormFutura", "Magenta High Gloss", "#B94474", "PLA"),
+    ("FormFutura", "Stonefil Terracotta", "#BD634C", "PLA"),
+    ("FormFutura", "Yellow Green", "#7AA837", "PLA"),
+    # FormFutura ePLA
+    ("FormFutura", "Pure Orange", "#FA9145", "EasyFil PLA"),
+    # FormFutura rPLA
+    ("FormFutura", "ReForm Black", "#3A3B3B", "ReForm rPLA"),
+    ("FormFutura", "ReForm White", "#F3F3EC", "ReForm rPLA"),
+    # Fiberlogy PLA (from FilamentColors.xyz measured swatches)
+    ("Fiberlogy", "Mineral White", "#E2D9CD", "PLA"),
+    ("Fiberlogy", "Aurora", "#3C4452", "Easy PLA"),
+    ("Fiberlogy", "Army Green", "#535F4F", "Impact PLA"),
+    # Fiberlogy ASA
+    ("Fiberlogy", "Olive Green", "#61634B", "ASA"),
+    # Fiberlogy Easy PETG
+    ("Fiberlogy", "White", "#F2F2EE", "Easy PETG"),
+    # Fiberlogy FiberSilk
+    ("Fiberlogy", "Green", "#A2D780", "FiberSilk Metallic"),
+    # MatterHackers Build PLA (from FilamentColors.xyz measured swatches)
+    ("MatterHackers", "Blue", "#044786", "Build PLA"),
+    ("MatterHackers", "Magenta", "#CD4263", "Build PLA"),
+    ("MatterHackers", "Red", "#C4351B", "Build PLA"),
+    ("MatterHackers", "Shiny Gold", "#DFAC1E", "Build PLA"),
+    ("MatterHackers", "Silky Copper", "#C76F35", "Build PLA"),
+    ("MatterHackers", "Silky Silver", "#BDBDB8", "Build PLA"),
+    ("MatterHackers", "Silky Teal", "#078EBC", "Build PLA"),
+    ("MatterHackers", "Silky Yellow", "#EDB554", "Build PLA"),
+    ("MatterHackers", "Yellow", "#EBC100", "Build PLA"),
+    # MatterHackers PLA
+    ("MatterHackers", "Gold", "#E7AC37", "PLA"),
+    ("MatterHackers", "Lime Green", "#75BA52", "PLA"),
+    ("MatterHackers", "Pearl White", "#D4DCDD", "PLA"),
+    ("MatterHackers", "Red", "#E54931", "PLA"),
+    # MatterHackers Pro PLA
+    ("MatterHackers", "Electric Pink", "#F35886", "Pro PLA"),
+    ("MatterHackers", "Jet Gray", "#474B4C", "Pro PLA"),
+    # MatterHackers PETG
+    ("MatterHackers", "Clear", "#D5DDDA", "PETG"),
+    ("MatterHackers", "White", "#E9EBEF", "PETG"),
+    # MatterHackers NylonX / NylonG
+    ("MatterHackers", "Black", "#3D3C38", "NylonX"),
+    ("MatterHackers", "White", "#DCDED9", "NylonG"),
+    # Protopasta HTPLA (from FilamentColors.xyz measured swatches)
+    ("Protopasta", "Atikam Teal", "#135859", "HTPLA"),
+    ("Protopasta", "Blood of My Enemies", "#7A1A23", "HTPLA"),
+    ("Protopasta", "Blue Opaque", "#044A86", "HTPLA"),
+    ("Protopasta", "Blue Wonder Glitter Flake", "#20556F", "HTPLA"),
+    ("Protopasta", "Bobbi's Purple Iris", "#542B5C", "HTPLA"),
+    ("Protopasta", "Brass Composite", "#8C7A4F", "HTPLA"),
+    ("Protopasta", "Bronze Composite", "#635146", "HTPLA"),
+    ("Protopasta", "Candy Apple Metallic Red", "#A32423", "HTPLA"),
+    ("Protopasta", "Cloverleaf Metallic Green", "#245A3F", "HTPLA"),
+    ("Protopasta", "Copper Composite", "#976252", "HTPLA"),
+    ("Protopasta", "Cupid's Crush Metallic Pink", "#EA8699", "HTPLA"),
+    ("Protopasta", "Double Espresso Metallic Brown", "#6B473B", "HTPLA"),
+    ("Protopasta", "Dragon Fruit Smoothie", "#B3295F", "HTPLA"),
+    ("Protopasta", "Dragon Scale Purple", "#8A80AC", "HTPLA"),
+    ("Protopasta", "Dusty Smoke", "#8F9491", "HTPLA"),
+    ("Protopasta", "Electric Lemonade Metallic Yellow", "#E4CA6B", "HTPLA"),
+    ("Protopasta", "Empire Strikes Metallic Black", "#393B3B", "HTPLA"),
+    ("Protopasta", "Fluorescent Yellow", "#D4DC3A", "HTPLA"),
+    ("Protopasta", "Galactic Empire Metallic Purple", "#3B3F5D", "HTPLA"),
+    ("Protopasta", "Glitter's Mane", "#128C93", "HTPLA"),
+    ("Protopasta", "Gold Dust Glitter Flake", "#BFAE6D", "HTPLA"),
+    ("Protopasta", "Good as Gold", "#9A774B", "HTPLA"),
+    ("Protopasta", "Good Old Gray", "#6D737B", "HTPLA"),
+    ("Protopasta", "Green Glowing Natural", "#D4D3AD", "HTPLA"),
+    ("Protopasta", "Heartthrob Red Metallic", "#7E3030", "HTPLA"),
+    ("Protopasta", "Joel's Highfive Blue", "#056B9A", "HTPLA"),
+    ("Protopasta", "Lootsef Green", "#8FB841", "HTPLA"),
+    ("Protopasta", "Luke's Proton Purple", "#7D3F59", "HTPLA"),
+    ("Protopasta", "Mahogany", "#7F5D4F", "HTPLA"),
+    ("Protopasta", "Matte Fiber Black", "#3F3F3E", "HTPLA"),
+    ("Protopasta", "Matte Fiber Daffodil", "#B79868", "HTPLA"),
+    ("Protopasta", "Matte Fiber Gray", "#767A7D", "HTPLA"),
+    ("Protopasta", "Matte Fiber Walnut", "#6F5D4E", "HTPLA"),
+    ("Protopasta", "Matte Fiber White", "#F4EADB", "HTPLA"),
+    ("Protopasta", "Mermaid's Tale Metallic Teal", "#026768", "HTPLA"),
+    ("Protopasta", "Moonstruck White Satin", "#DCE4DD", "HTPLA"),
+    ("Protopasta", "Obsidian", "#474743", "HTPLA"),
+    ("Protopasta", "Opaque Black", "#312F30", "HTPLA"),
+    ("Protopasta", "Opaque Natural", "#CBD0D0", "HTPLA"),
+    ("Protopasta", "Opaque White", "#DFE4E2", "HTPLA"),
+    ("Protopasta", "Orange Papaya Smoothie", "#C57231", "HTPLA"),
+    ("Protopasta", "Out of Darts Orange", "#E58429", "HTPLA"),
+    ("Protopasta", "Pineapple Banana Smoothie", "#D0A645", "HTPLA"),
+    ("Protopasta", "Pretty in Pink Pearl", "#CE95AE", "HTPLA"),
+    ("Protopasta", "Red Hot Cinnamon", "#7C4448", "HTPLA"),
+    ("Protopasta", "Red Opaque", "#972425", "HTPLA"),
+    ("Protopasta", "Second to None Silver", "#B4B6B6", "HTPLA"),
+    ("Protopasta", "Sparkling Spruce", "#47614C", "HTPLA"),
+    ("Protopasta", "Stardust Glitter Flake", "#AAB1AE", "HTPLA"),
+    ("Protopasta", "Summertime Green", "#89A78A", "HTPLA"),
+    ("Protopasta", "Tangerine Orange Metallic Gold", "#C05834", "HTPLA"),
+    ("Protopasta", "Translucent Iridescent Ice", "#C4CBC8", "HTPLA"),
+    ("Protopasta", "Translucent Silver Smoke", "#A5ACA9", "HTPLA"),
+    ("Protopasta", "Unicorn Tears White Glitter", "#D7DEDE", "HTPLA"),
+    ("Protopasta", "What Karat? Smooth Gold", "#CD974B", "HTPLA"),
+    ("Protopasta", "White", "#F6F5F0", "HTPLA"),
+    ("Protopasta", "White Marble", "#C6CECF", "HTPLA"),
+    ("Protopasta", "Winter Blue Glitter Flake", "#056F9D", "HTPLA"),
+    # Protopasta PLA
+    ("Protopasta", "Black", "#323132", "PLA"),
+    ("Protopasta", "Conductive", "#373838", "PLA"),
+    ("Protopasta", "Iron Composite", "#555451", "PLA"),
+    ("Protopasta", "Natural", "#E1DFD6", "PLA"),
+    ("Protopasta", "Steel Composite", "#676561", "PLA"),
+    # Protopasta Carbon Fiber PLA
+    ("Protopasta", "Black", "#424140", "Carbon Fiber PLA"),
+    # 3DXTECH (from FilamentColors.xyz measured swatches)
+    ("3DXTECH", "Natural", "#DED7C6", "ASA"),
+    ("3DXTECH", "Black", "#444342", "Carbon Fiber PLA"),
+    ("3DXTECH", "Venom", "#CACC19", "ECOMAX PLA"),
+    ("3DXTECH", "Simubone", "#EAE0CB", "PLA"),
+    ("3DXTECH", "Blue Frost", "#B1C1C5", "rPETG"),
+    # Sakata3D PLA (from FilamentColors.xyz measured swatches)
+    ("Sakata3D", "Red", "#B63A32", "PLA"),
+    ("Sakata3D", "Silk Sunset", "#F49545", "PLA"),
+    ("Sakata3D", "Surf Green", "#00C1A8", "PLA"),
+]

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

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

@@ -1,14 +1,30 @@
+from sqlalchemy import event
 from sqlalchemy.exc import OperationalError
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import DeclarativeBase
 
 from backend.app.core.config import settings
 
+
+def _set_sqlite_pragmas(dbapi_conn, connection_record):
+    """Set SQLite pragmas on each new connection for concurrency and performance."""
+    cursor = dbapi_conn.cursor()
+    # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
+    cursor.execute("PRAGMA journal_mode = WAL")
+    # Wait up to 5 seconds when the database is locked instead of failing immediately
+    cursor.execute("PRAGMA busy_timeout = 5000")
+    cursor.execute("PRAGMA synchronous = NORMAL")
+    cursor.close()
+
+
 engine = create_async_engine(
     settings.database_url,
     echo=settings.debug,
 )
 
+# Register the pragma listener on the underlying sync engine
+event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
+
 async_session = async_sessionmaker(
     engine,
     class_=AsyncSession,
@@ -29,6 +45,7 @@ async def reinitialize_database():
         settings.database_url,
         echo=settings.debug,
     )
+    event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
     async_session = async_sessionmaker(
         engine,
         class_=AsyncSession,
@@ -59,6 +76,7 @@ async def init_db():
         ams_history,
         api_key,
         archive,
+        color_catalog,
         external_link,
         filament,
         github_backup,
@@ -71,6 +89,7 @@ async def init_db():
         notification_template,
         orca_base_cache,
         pending_upload,
+        print_log,
         print_queue,
         printer,
         project,
@@ -78,6 +97,11 @@ async def init_db():
         settings,
         slot_preset,
         smart_plug,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
         user,
     )
 
@@ -93,6 +117,10 @@ async def init_db():
     # Seed default groups and migrate existing users
     await seed_default_groups()
 
+    # Seed default catalog entries
+    await seed_spool_catalog()
+    await seed_color_catalog()
+
 
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
@@ -161,6 +189,13 @@ async def run_migrations(conn):
         # Column already exists
         pass
 
+    # Migration: Add is_deleted column to maintenance_types for soft-deletes
+    try:
+        await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        # Column already exists
+        pass
+
     # Migration: Add custom_interval_type column to printer_maintenance
     try:
         await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
@@ -1118,6 +1153,69 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add inventory spool tracking columns
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN added_full BOOLEAN"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_used DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN encode_time DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add RFID tag matching columns to spool
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Create spool_usage_history table for filament consumption tracking
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS spool_usage_history (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
+                printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+                print_name VARCHAR(500),
+                weight_used REAL NOT NULL DEFAULT 0,
+                percent_used INTEGER NOT NULL DEFAULT 0,
+                status VARCHAR(20) NOT NULL DEFAULT 'completed',
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        )
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add open_in_new_tab column to external_links
+    try:
+        await conn.execute(text("ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add bed cooled notification column to notification_providers
+    try:
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_bed_cooled BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
@@ -1286,3 +1384,57 @@ async def seed_default_groups():
                     logger.info("Migrated user '%s' to Operators group", user.username)
 
             await session.commit()
+
+
+async def seed_spool_catalog():
+    """Seed the spool catalog with default entries if empty."""
+    import logging
+
+    from sqlalchemy import func, select
+
+    from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG
+    from backend.app.models.spool_catalog import SpoolCatalogEntry
+
+    logger = logging.getLogger(__name__)
+
+    async with async_session() as session:
+        result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry))
+        count = result.scalar() or 0
+        if count > 0:
+            return  # Already seeded
+
+        for name, weight in DEFAULT_SPOOL_CATALOG:
+            session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
+        await session.commit()
+        logger.info("Seeded %d default spool catalog entries", len(DEFAULT_SPOOL_CATALOG))
+
+
+async def seed_color_catalog():
+    """Seed the color catalog with default entries if empty."""
+    import logging
+
+    from sqlalchemy import func, select
+
+    from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
+    from backend.app.models.color_catalog import ColorCatalogEntry
+
+    logger = logging.getLogger(__name__)
+
+    async with async_session() as session:
+        result = await session.execute(select(func.count()).select_from(ColorCatalogEntry))
+        count = result.scalar() or 0
+        if count > 0:
+            return  # Already seeded
+
+        for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
+            session.add(
+                ColorCatalogEntry(
+                    manufacturer=manufacturer,
+                    color_name=color_name,
+                    hex_color=hex_color,
+                    material=material,
+                    is_default=True,
+                )
+            )
+        await session.commit()
+        logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))

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

@@ -62,6 +62,12 @@ class Permission(StrEnum):
     FILAMENTS_UPDATE = "filaments:update"
     FILAMENTS_DELETE = "filaments:delete"
 
+    # Inventory (Spool Inventory, Spool Catalog, Color Catalog)
+    INVENTORY_READ = "inventory:read"
+    INVENTORY_CREATE = "inventory:create"
+    INVENTORY_UPDATE = "inventory:update"
+    INVENTORY_DELETE = "inventory:delete"
+
     # Smart Plugs
     SMART_PLUGS_READ = "smart_plugs:read"
     SMART_PLUGS_CREATE = "smart_plugs:create"
@@ -201,6 +207,12 @@ PERMISSION_CATEGORIES = {
         Permission.FILAMENTS_UPDATE,
         Permission.FILAMENTS_DELETE,
     ],
+    "Inventory": [
+        Permission.INVENTORY_READ,
+        Permission.INVENTORY_CREATE,
+        Permission.INVENTORY_UPDATE,
+        Permission.INVENTORY_DELETE,
+    ],
     "Smart Plugs": [
         Permission.SMART_PLUGS_READ,
         Permission.SMART_PLUGS_CREATE,
@@ -335,6 +347,11 @@ DEFAULT_GROUPS = {
             Permission.FILAMENTS_CREATE.value,
             Permission.FILAMENTS_UPDATE.value,
             Permission.FILAMENTS_DELETE.value,
+            # Inventory - full access
+            Permission.INVENTORY_READ.value,
+            Permission.INVENTORY_CREATE.value,
+            Permission.INVENTORY_UPDATE.value,
+            Permission.INVENTORY_DELETE.value,
             # Smart Plugs - full access
             Permission.SMART_PLUGS_READ.value,
             Permission.SMART_PLUGS_CREATE.value,
@@ -390,6 +407,7 @@ DEFAULT_GROUPS = {
             Permission.LIBRARY_READ.value,
             Permission.PROJECTS_READ.value,
             Permission.FILAMENTS_READ.value,
+            Permission.INVENTORY_READ.value,
             Permission.SMART_PLUGS_READ.value,
             Permission.CAMERA_VIEW.value,
             Permission.MAINTENANCE_READ.value,

+ 513 - 18
backend/app/main.py

@@ -165,6 +165,7 @@ if not app_settings.debug:
     logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
     logging.getLogger("httpcore").setLevel(logging.WARNING)
     logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
 logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
 from fastapi.responses import FileResponse
@@ -184,6 +185,7 @@ from backend.app.api.routes import (
     firmware,
     github_backup,
     groups,
+    inventory,
     kprofiles,
     library,
     local_presets,
@@ -192,6 +194,7 @@ from backend.app.api.routes import (
     notification_templates,
     notifications,
     pending_uploads,
+    print_log,
     print_queue,
     printers,
     projects,
@@ -216,6 +219,7 @@ 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 (
@@ -253,6 +257,13 @@ _last_progress_milestone: dict[int, int] = {}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
 
+# Track timelapse file baselines at print start: {printer_id: set of MP4 filenames}
+# Used for snapshot-diff detection at print completion
+_timelapse_baselines: dict[int, set[str]] = {}
+
+# Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task}
+_bed_cooldown_tasks: dict[int, asyncio.Task] = {}
+
 
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
@@ -329,12 +340,14 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     bed_target = round(temps.get("bed_target", 0))
     nozzle_target = round(temps.get("nozzle_target", 0))
 
+    # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
+    vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
-        f"{state.chamber_light}:{state.active_extruder}"
+        f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -410,6 +423,9 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         # Find new errors that haven't been notified yet
         new_error_codes = current_error_codes - previously_notified
 
+        # Update tracking immediately to prevent duplicate notifications from concurrent callbacks
+        _notified_hms_errors[printer_id] = current_error_codes
+
         if new_error_codes:
             # Get the actual new errors for the notification
             # Filter to severity >= 2 (skip informational/status messages like H2D sends)
@@ -480,8 +496,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
             except Exception as e:
                 logging.getLogger(__name__).warning(f"HMS error notification failed: {e}")
 
-            # Update tracking with all current errors
-            _notified_hms_errors[printer_id] = current_error_codes
     else:
         # No HMS errors - clear tracking so future errors get notified
         if printer_id in _notified_hms_errors:
@@ -493,6 +507,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     )
 
 
+def _is_bambu_uuid(tray_uuid: str) -> bool:
+    """Check if a tray UUID looks like a valid Bambu Lab RFID UUID (non-empty, non-zero)."""
+    return bool(tray_uuid) and tray_uuid not in ("", "0" * len(tray_uuid))
+
+
 async def on_ams_change(printer_id: int, ams_data: list):
     """Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
     logger = logging.getLogger(__name__)
@@ -518,6 +537,232 @@ async def on_ams_change(printer_id: int, ams_data: list):
     except Exception as e:
         logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)
 
+    # Auto-unlink spool assignments with stale fingerprints
+    try:
+        async with async_session() as db:
+            from sqlalchemy.orm import selectinload
+
+            from backend.app.api.routes.inventory import _find_tray_in_ams_data
+            from backend.app.models.spool_assignment import SpoolAssignment as SA
+
+            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)
+                if not current_tray:
+                    logger.info(
+                        "Auto-unlink: spool %d AMS%d-T%d — tray not found in AMS data (slot empty?)",
+                        assignment.spool_id,
+                        assignment.ams_id,
+                        assignment.tray_id,
+                    )
+                    stale.append(assignment)  # Slot empty
+                elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
+                    # A Bambu Lab spool is in this slot — check if it's the same spool
+                    # that's currently assigned. If yes, keep the assignment (avoids
+                    # unnecessary unlink/re-assign/ams_filament_setting cycle that clears
+                    # the printer's filament preset on every startup).
+                    tray_uuid = current_tray.get("tray_uuid", "")
+                    tag_uid = current_tray.get("tag_uid", "")
+                    spool = assignment.spool
+                    spool_matches = False
+                    if spool:
+                        if (spool.tray_uuid and spool.tray_uuid.upper() == tray_uuid.upper()) or (
+                            spool.tag_uid
+                            and tag_uid
+                            and tag_uid != "0000000000000000"
+                            and spool.tag_uid.upper() == tag_uid.upper()
+                        ):
+                            spool_matches = True
+                    if spool_matches:
+                        # Same BL spool still in slot — keep assignment, update fingerprint if needed
+                        cur_color = current_tray.get("tray_color", "")
+                        cur_type = current_tray.get("tray_type", "")
+                        fp_color = assignment.fingerprint_color or ""
+                        fp_type = assignment.fingerprint_type or ""
+                        if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
+                            assignment.fingerprint_color = cur_color
+                            assignment.fingerprint_type = cur_type
+                            logger.debug(
+                                "Auto-unlink: spool %d AMS%d-T%d — same BL spool, updated fingerprint",
+                                assignment.spool_id,
+                                assignment.ams_id,
+                                assignment.tray_id,
+                            )
+                        continue
+                    # Different BL spool or unrecognized — unlink so auto-assign can match
+                    logger.info(
+                        "Auto-unlink: spool %d AMS%d-T%d — different Bambu Lab spool detected (uuid=%s)",
+                        assignment.spool_id,
+                        assignment.ams_id,
+                        assignment.tray_id,
+                        tray_uuid,
+                    )
+                    stale.append(assignment)
+                else:
+                    cur_color = current_tray.get("tray_color", "")
+                    cur_type = current_tray.get("tray_type", "")
+                    fp_color = assignment.fingerprint_color or ""
+                    fp_type = assignment.fingerprint_type or ""
+                    if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
+                        # Fingerprint mismatch — but check if tray now matches the
+                        # assigned spool (e.g. auto-configure changed the tray).
+                        spool = assignment.spool
+                        if spool:
+                            spool_color = (spool.rgba or "FFFFFFFF").upper()
+                            spool_type = (spool.material or "").upper()
+                            if cur_color.upper() == spool_color and cur_type.upper() == spool_type:
+                                # Tray was reconfigured to match the spool — update fingerprint
+                                logger.info(
+                                    "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
+                                    assignment.spool_id,
+                                    assignment.ams_id,
+                                    assignment.tray_id,
+                                )
+                                assignment.fingerprint_color = cur_color
+                                assignment.fingerprint_type = cur_type
+                                continue
+                        logger.info(
+                            "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch (cur=%s/%s fp=%s/%s spool=%s/%s)",
+                            assignment.spool_id,
+                            assignment.ams_id,
+                            assignment.tray_id,
+                            cur_color,
+                            cur_type,
+                            fp_color,
+                            fp_type,
+                            spool.rgba if spool else "?",
+                            spool.material if spool else "?",
+                        )
+                        stale.append(assignment)  # Spool changed
+            for a in stale:
+                await db.delete(a)
+            if stale:
+                logger.info("Auto-unlinked %d stale spool assignments for printer %d", len(stale), printer_id)
+            # Commit any changes (stale deletions and/or fingerprint updates)
+            await db.commit()
+    except Exception as e:
+        logger.warning("Spool assignment cleanup failed: %s", e)
+
+    # Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS)
+    try:
+        async with async_session() as db:
+            from backend.app.api.routes.settings import get_setting
+            from backend.app.models.spool_assignment import SpoolAssignment as SA
+            from backend.app.services.spool_tag_matcher import (
+                auto_assign_spool,
+                create_spool_from_tray,
+                get_spool_by_tag,
+                is_bambu_tag,
+                is_valid_tag,
+            )
+
+            _spoolman_on = await get_setting(db, "spoolman_enabled")
+            if not _spoolman_on or _spoolman_on.lower() != "true":
+                for ams_unit in ams_data:
+                    if not isinstance(ams_unit, dict):
+                        continue
+                    ams_id = int(ams_unit.get("id", 0))
+                    for tray in ams_unit.get("tray", []):
+                        if not isinstance(tray, dict):
+                            continue
+                        tray_id = int(tray.get("id", 0))
+                        tag_uid = tray.get("tag_uid", "")
+                        tray_uuid = tray.get("tray_uuid", "")
+                        tray_info_idx = tray.get("tray_info_idx", "")
+                        if not tray.get("tray_type"):
+                            continue  # Empty slot
+                        # Check if assignment already exists for this slot
+                        existing = await db.execute(
+                            select(SA)
+                            .options(selectinload(SA.spool))
+                            .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
+                        )
+                        existing_assignment = existing.scalar_one_or_none()
+                        if existing_assignment:
+                            # Sync spool weight_used from AMS remain — only INCREASE, never decrease.
+                            # 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:
+                                try:
+                                    remain_val = int(remain_raw)
+                                except (TypeError, ValueError):
+                                    remain_val = -1
+                                if 1 <= remain_val <= 100:
+                                    lw = existing_assignment.spool.label_weight or 1000
+                                    new_used = round(lw * (100 - remain_val) / 100.0, 1)
+                                    current_used = existing_assignment.spool.weight_used or 0
+                                    if new_used > current_used + 1:
+                                        logger.info(
+                                            "Weight sync: spool %d weight_used %s -> %s (remain=%d)",
+                                            existing_assignment.spool_id,
+                                            current_used,
+                                            new_used,
+                                            remain_val,
+                                        )
+                                        existing_assignment.spool.weight_used = new_used
+                                        await db.commit()
+                            continue
+
+                        if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
+                            # BL spool with RFID tag: auto-match or auto-create
+                            spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
+                            if not spool:
+                                spool = await create_spool_from_tray(db, tray)
+                            await auto_assign_spool(
+                                printer_id,
+                                ams_id,
+                                tray_id,
+                                spool,
+                                printer_manager,
+                                db,
+                                tray_info_idx=tray_info_idx,
+                            )
+                            await db.commit()
+                            await ws_manager.broadcast(
+                                {
+                                    "type": "spool_auto_assigned",
+                                    "printer_id": printer_id,
+                                    "ams_id": ams_id,
+                                    "tray_id": tray_id,
+                                    "spool_id": spool.id,
+                                }
+                            )
+                            logger.info(
+                                "RFID auto-assigned spool %d to printer %d AMS%d-T%d",
+                                spool.id,
+                                printer_id,
+                                ams_id,
+                                tray_id,
+                            )
+                        elif is_valid_tag(tag_uid, tray_uuid):
+                            # Non-BL spool with some tag — let user choose
+                            await ws_manager.broadcast(
+                                {
+                                    "type": "unknown_tag",
+                                    "printer_id": printer_id,
+                                    "ams_id": ams_id,
+                                    "tray_id": tray_id,
+                                    "tag_uid": tag_uid,
+                                    "tray_uuid": tray_uuid,
+                                }
+                            )
+                        else:
+                            # No tag at all — let user choose from inventory
+                            await ws_manager.broadcast(
+                                {
+                                    "type": "unknown_tag",
+                                    "printer_id": printer_id,
+                                    "ams_id": ams_id,
+                                    "tray_id": tray_id,
+                                    "tag_uid": "",
+                                    "tray_uuid": "",
+                                }
+                            )
+    except Exception as e:
+        logger.warning("RFID spool auto-assign failed: %s", e)
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
@@ -571,6 +816,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
                 )
                 return
 
+            # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
+            from sqlalchemy.orm import selectinload
+
+            from backend.app.models.spool_assignment import SpoolAssignment
+
+            inventory_weights: dict[tuple[int, int], float] = {}
+            try:
+                assign_result = await db.execute(
+                    select(SpoolAssignment)
+                    .options(selectinload(SpoolAssignment.spool))
+                    .where(SpoolAssignment.printer_id == printer_id)
+                )
+                for assignment in assign_result.scalars().all():
+                    spool = assignment.spool
+                    if spool and spool.label_weight > 0:
+                        remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                        inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining
+            except Exception as e:
+                logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
+
             # Sync each AMS tray
             synced = 0
             for ams_unit in ams_data:
@@ -583,11 +848,13 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         continue  # Empty tray
 
                     try:
+                        inv_remaining = inventory_weights.get((ams_id, tray.tray_id))
                         result = await client.sync_ams_tray(
                             tray,
                             printer_name,
                             disable_weight_sync=disable_weight_sync,
                             cached_spools=cached_spools,
+                            inventory_remaining=inv_remaining,
                         )
                         if result:
                             synced += 1
@@ -728,6 +995,12 @@ async def on_print_start(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
+    # Cancel any active bed cooldown task for this printer
+    existing_task = _bed_cooldown_tasks.pop(printer_id, None)
+    if existing_task and not existing_task.done():
+        existing_task.cancel()
+        logger.info("[BED-COOL] Cancelled bed cooldown monitor for printer %s (new print started)", printer_id)
+
     # Clear cached cover images so the new print's thumbnail is fetched fresh
     from backend.app.api.routes.printers import clear_cover_cache
 
@@ -749,6 +1022,19 @@ async def on_print_start(printer_id: int, data: dict):
     except Exception:
         pass  # Don't fail print start callback if MQTT fails
 
+    # Capture AMS tray remain% for filament consumption tracking (skip if Spoolman handles usage)
+    try:
+        async with async_session() as db:
+            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
+
+            await usage_on_print_start(printer_id, data, printer_manager)
+    except Exception as e:
+        logger.warning("Usage tracker on_print_start failed: %s", e)
+
     # Track if notification was sent (to avoid sending twice)
     notification_sent = False
 
@@ -989,7 +1275,17 @@ async def on_print_start(printer_id: int, data: dict):
             select(PrintArchive)
             .where(PrintArchive.printer_id == printer_id)
             .where(PrintArchive.status == "printing")
-            .where(PrintArchive.print_name.ilike(f"%{check_name}%"))
+            .where(
+                or_(
+                    PrintArchive.print_name == check_name,
+                    PrintArchive.filename.in_(
+                        [
+                            f"{check_name}.3mf",
+                            f"{check_name}.gcode.3mf",
+                        ]
+                    ),
+                )
+            )
             .order_by(PrintArchive.created_at.desc())
             .limit(1)
         )
@@ -1404,6 +1700,18 @@ async def on_print_start(printer_id: int, data: dict):
                     await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager)
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
+
+                # Capture timelapse file baseline for snapshot-diff on completion
+                try:
+                    baseline_files, _ = await _list_timelapse_mp4s(printer)
+                    _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
+                    logger.info(
+                        "[TIMELAPSE] Baseline at print start: %s MP4 files for printer %s",
+                        len(_timelapse_baselines[printer_id]),
+                        printer_id,
+                    )
+                except Exception as e:
+                    logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
         finally:
             if temp_path and temp_path.exists():
                 temp_path.unlink()
@@ -1435,7 +1743,7 @@ async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
     return [], None
 
 
-async def _scan_for_timelapse_with_retries(archive_id: int):
+async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
     """
     Scan for timelapse with retries using a snapshot-diff approach.
 
@@ -1443,6 +1751,10 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
     clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
     waiting, then look for any NEW filename that appears after each delay.
 
+    If baseline_names is provided (captured at print start), it is used directly.
+    Otherwise falls back to taking a baseline at completion time (best-effort
+    for prints started before app restart).
+
     Falls back to name-matching (print name contained in MP4 filename) if no
     new file appears after all retries.
     """
@@ -1468,18 +1780,28 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
                 logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
                 return
 
-            result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-            printer = result.scalar_one_or_none()
-            if not printer:
-                logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
-                return
+            if baseline_names is not None:
+                # Use pre-captured baseline from print start (no race condition)
+                logger.info(
+                    "[TIMELAPSE] Using print-start baseline: %s existing MP4 files for archive %s",
+                    len(baseline_names),
+                    archive_id,
+                )
+            else:
+                # Fallback: take baseline now (e.g. app restarted mid-print)
+                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+                printer = result.scalar_one_or_none()
+                if not printer:
+                    logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
+                    return
 
-            # Snapshot current MP4 filenames as baseline
-            baseline_files, _ = await _list_timelapse_mp4s(printer)
-            baseline_names: set[str] = {f.get("name", "") for f in baseline_files}
-            logger.info(
-                "[TIMELAPSE] Baseline snapshot: %s existing MP4 files for archive %s", len(baseline_names), archive_id
-            )
+                baseline_files, _ = await _list_timelapse_mp4s(printer)
+                baseline_names = {f.get("name", "") for f in baseline_files}
+                logger.info(
+                    "[TIMELAPSE] Baseline snapshot (fallback): %s existing MP4 files for archive %s",
+                    len(baseline_names),
+                    archive_id,
+                )
 
             # Derive base_name for name-matching fallback
             base_name = Path(archive.filename).stem if archive.filename else ""
@@ -1637,6 +1959,9 @@ async def on_print_complete(printer_id: int, data: dict):
     except Exception as e:
         logger.warning("[CALLBACK] WebSocket send_print_complete failed: %s", e)
 
+    # Capture user info before clearing (needed for print log entry)
+    _print_user_info = printer_manager.get_current_print_user(printer_id)
+
     # Clear current print user tracking (Issue #206)
     printer_manager.clear_current_print_user(printer_id)
 
@@ -1837,6 +2162,62 @@ async def on_print_complete(printer_id: int, data: dict):
 
     log_timing("Archive status update")
 
+    # Write independent print log entry (separate table, never touches archives)
+    try:
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
+            from backend.app.services.print_log import write_log_entry
+
+            archive = await db.get(PrintArchive, archive_id)
+            if archive:
+                p_info = printer_manager.get_printer(printer_id)
+                await write_log_entry(
+                    db,
+                    status=data.get("status", "completed"),
+                    print_name=archive.print_name,
+                    printer_name=p_info.name if p_info else None,
+                    printer_id=printer_id,
+                    started_at=archive.started_at,
+                    completed_at=archive.completed_at,
+                    filament_type=archive.filament_type,
+                    filament_color=archive.filament_color,
+                    filament_used_grams=archive.filament_used_grams,
+                    thumbnail_path=archive.thumbnail_path,
+                    created_by_username=_print_user_info.get("username") if _print_user_info else None,
+                )
+                await db.commit()
+                logger.info("[PRINT_LOG] Log entry written for archive %s", archive_id)
+    except Exception as e:
+        logger.warning("[PRINT_LOG] Failed to write log entry for archive %s: %s", archive_id, e)
+
+    log_timing("Print log entry")
+
+    # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
+    usage_results: list[dict] = []
+    try:
+        async with async_session() as db:
+            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_complete as usage_on_print_complete
+
+            async with async_session() as db:
+                usage_results = await usage_on_print_complete(
+                    printer_id, data, printer_manager, db, archive_id=archive_id
+                )
+                if usage_results:
+                    await ws_manager.broadcast(
+                        {
+                            "type": "spool_usage_logged",
+                            "printer_id": printer_id,
+                            "usage": usage_results,
+                        }
+                    )
+                    log_timing("Usage tracker")
+    except Exception as e:
+        logger.warning("Usage tracker on_print_complete failed: %s", e)
+
     # Report filament usage to Spoolman if print completed successfully
     if data.get("status") == "completed":
         try:
@@ -2028,6 +2409,25 @@ async def on_print_complete(printer_id: int, data: dict):
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
                         }
+
+                        # Scale filament usage for partial prints
+                        if print_status != "completed" and archive.filament_used_grams:
+                            progress = data.get("progress") or 0
+                            scale = max(0.0, min(progress / 100.0, 1.0))
+                            archive_data["actual_filament_grams"] = round(archive.filament_used_grams * scale, 1)
+                            archive_data["progress"] = progress
+
+                        # Pass per-slot data from archive.extra_data
+                        if archive.extra_data and archive.extra_data.get("filament_slots"):
+                            slots = archive.extra_data["filament_slots"]
+                            if print_status != "completed":
+                                scale = max(0.0, min((data.get("progress") or 0) / 100.0, 1.0))
+                                slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
+                            archive_data["filament_slots"] = slots
+
+                        # Pass usage tracker results for AMS slot info in notifications
+                        if usage_results:
+                            archive_data["usage_results"] = usage_results
                         # Add finish photo URL and image bytes if available
                         if finish_photo_filename:
                             from backend.app.api.routes.settings import get_setting
@@ -2172,6 +2572,87 @@ async def on_print_complete(printer_id: int, data: dict):
                 pass  # Best-effort timelapse session cancellation on error
 
     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
@@ -2179,7 +2660,8 @@ async def on_print_complete(printer_id: int, data: dict):
         logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
         # Schedule timelapse scan as background task with retries
         # The printer needs time to encode the video after print completion
-        asyncio.create_task(_scan_for_timelapse_with_retries(archive_id))
+        baseline = _timelapse_baselines.pop(printer_id, None)
+        asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
         log_timing("Timelapse scan scheduled")
 
     # Update queue item if this was a scheduled print
@@ -2195,7 +2677,14 @@ async def on_print_complete(printer_id: int, data: dict):
                 .where(PrintQueueItem.printer_id == printer_id)
                 .where(PrintQueueItem.status == "printing")
             )
-            queue_item = result.scalar_one_or_none()
+            printing_items = list(result.scalars().all())
+            if len(printing_items) > 1:
+                logger.warning(
+                    "BUG: Multiple queue items in 'printing' status for printer %s: %s",
+                    printer_id,
+                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
+                )
+            queue_item = printing_items[0] if printing_items else None
             if queue_item:
                 status = data.get("status", "completed")
                 queue_item.status = status
@@ -2746,6 +3235,10 @@ async def lifespan(app: FastAPI):
     if virtual_printer_manager.is_enabled:
         await virtual_printer_manager.configure(enabled=False)
 
+    await mqtt_smart_plug_service.disconnect(timeout=2)
+
+    await mqtt_relay.disconnect(timeout=2)
+
 
 app = FastAPI(
     title=app_settings.app_name,
@@ -2905,10 +3398,12 @@ app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
+app.include_router(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(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(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)

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

@@ -1,6 +1,7 @@
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
+from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
@@ -16,6 +17,11 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.spool import Spool
+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.user import User
 
 __all__ = [
@@ -43,4 +49,10 @@ __all__ = [
     "GitHubBackupLog",
     "LocalPreset",
     "OrcaBaseProfile",
+    "Spool",
+    "SpoolKProfile",
+    "SpoolAssignment",
+    "SpoolCatalogEntry",
+    "SpoolUsageHistory",
+    "ColorCatalogEntry",
 ]

+ 20 - 0
backend/app/models/color_catalog.py

@@ -0,0 +1,20 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ColorCatalogEntry(Base):
+    """Color catalog entry for automatic color lookup when adding spools."""
+
+    __tablename__ = "color_catalog"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    manufacturer: Mapped[str] = mapped_column(String(200))
+    color_name: Mapped[str] = mapped_column(String(200))
+    hex_color: Mapped[str] = mapped_column(String(7))  # #RRGGBB
+    material: Mapped[str | None] = mapped_column(String(100))
+    is_default: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 2 - 1
backend/app/models/external_link.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import DateTime, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -16,6 +16,7 @@ class ExternalLink(Base):
     url: Mapped[str] = mapped_column(String(500))
     icon: Mapped[str] = mapped_column(String(50), default="link")
     custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
+    open_in_new_tab: Mapped[bool] = mapped_column(Boolean, default=False)
     sort_order: 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())

+ 1 - 0
backend/app/models/maintenance.py

@@ -22,6 +22,7 @@ class MaintenanceType(Base):
     icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI
     wiki_url: Mapped[str | None] = mapped_column(String(500))  # Documentation link
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom
+    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)  # Hidden/removed type
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
     # Relationships

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

@@ -83,6 +83,9 @@ class NotificationProvider(Base):
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
 
+    # Event triggers - Bed cooled after print
+    on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
+
     # Event triggers - Print queue
     on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
     on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer

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

@@ -97,6 +97,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "AMS Temperature Alert",
         "body_template": "{printer} {ams_label}: Temperature {temperature}°C exceeds {threshold}°C threshold",
     },
+    {
+        "event_type": "bed_cooled",
+        "name": "Bed Cooled",
+        "title_template": "Bed Cooled",
+        "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
+    },
     {
         "event_type": "test",
         "name": "Test Notification",

+ 31 - 0
backend/app/models/print_log.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class PrintLogEntry(Base):
+    """Independent print log entry. Written when print events occur.
+
+    This is a separate table from archives/queue — clearing the log
+    never touches archives or queue items.
+    """
+
+    __tablename__ = "print_log_entries"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    print_name: Mapped[str | None] = mapped_column(String(255))
+    printer_name: Mapped[str | None] = mapped_column(String(255))
+    printer_id: Mapped[int | None] = mapped_column(Integer)
+    status: Mapped[str] = mapped_column(String(20))  # completed, failed, stopped, cancelled, skipped
+    started_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+    duration_seconds: Mapped[int | None] = mapped_column(Integer)
+    filament_type: Mapped[str | None] = mapped_column(String(50))
+    filament_color: Mapped[str | None] = mapped_column(String(50))
+    filament_used_grams: Mapped[float | None] = mapped_column(Float)
+    thumbnail_path: Mapped[str | None] = mapped_column(String(500))
+    created_by_username: Mapped[str | None] = mapped_column(String(100))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 44 - 0
backend/app/models/spool.py

@@ -0,0 +1,44 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class Spool(Base):
+    """Spool inventory item for tracking filament spools and their properties."""
+
+    __tablename__ = "spool"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    material: Mapped[str] = mapped_column(String(50))  # PLA, PETG, ABS, etc.
+    subtype: Mapped[str | None] = mapped_column(String(50))  # Basic, Matte, Silk, etc.
+    color_name: Mapped[str | None] = mapped_column(String(100))  # "Jade White"
+    rgba: Mapped[str | None] = mapped_column(String(8))  # RRGGBBAA hex
+    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)
+    weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
+    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)
+    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)
+    tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)
+    data_origin: Mapped[str | None] = mapped_column(String(20))  # How data was populated: manual, rfid_auto, nfc_link
+    tag_type: Mapped[str | None] = mapped_column(String(20))  # Tag vendor: bambulab, generic, etc.
+    archived_at: Mapped[datetime | None] = mapped_column(DateTime)  # NULL = active
+    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())
+
+    k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
+    assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
+
+
+from backend.app.models.spool_assignment import SpoolAssignment  # noqa: E402
+from backend.app.models.spool_k_profile import SpoolKProfile  # noqa: E402

+ 35 - 0
backend/app/models/spool_assignment.py

@@ -0,0 +1,35 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SpoolAssignment(Base):
+    """Assignment of a spool to a specific AMS slot on a printer."""
+
+    __tablename__ = "spool_assignment"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer)  # 0-3, 128+ (HT), 254/255 (ext)
+    tray_id: Mapped[int] = mapped_column(Integer)  # 0-3
+    fingerprint_color: Mapped[str | None] = mapped_column(String(8))  # tray_color snapshot
+    fingerprint_type: Mapped[str | None] = mapped_column(String(50))  # tray_type snapshot
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    spool: Mapped["Spool"] = relationship(back_populates="assignments")
+    printer: Mapped["Printer"] = relationship()
+
+    __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id"),)
+
+    @property
+    def printer_name(self) -> str | None:
+        """Get printer name from loaded relationship."""
+        return self.printer.name if self.printer else None
+
+
+from backend.app.models.printer import Printer  # noqa: E402, F401
+from backend.app.models.spool import Spool  # noqa: E402, F401

+ 18 - 0
backend/app/models/spool_catalog.py

@@ -0,0 +1,18 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SpoolCatalogEntry(Base):
+    """Spool weight catalog entry for weight lookup when adding spools."""
+
+    __tablename__ = "spool_catalog"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(200))
+    weight: Mapped[int] = mapped_column(Integer)
+    is_default: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 31 - 0
backend/app/models/spool_k_profile.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SpoolKProfile(Base):
+    """K-value calibration profile for a spool on a specific printer/nozzle combo."""
+
+    __tablename__ = "spool_k_profile"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    extruder: Mapped[int] = mapped_column(Integer, default=0)  # 0 or 1 (H2D)
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="0.4")  # "0.4", "0.6"
+    nozzle_type: Mapped[str | None] = mapped_column(String(50))
+    k_value: Mapped[float] = mapped_column(Float)  # e.g. 0.020
+    name: Mapped[str | None] = mapped_column(String(100))  # Profile display name
+    cali_idx: Mapped[int | None] = mapped_column(Integer)  # Calibration index on printer
+    setting_id: Mapped[str | None] = mapped_column(String(50))  # Full setting ID
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    spool: Mapped["Spool"] = relationship(back_populates="k_profiles")
+    printer: Mapped["Printer"] = relationship()
+
+
+from backend.app.models.printer import Printer  # noqa: E402, F401
+from backend.app.models.spool import Spool  # noqa: E402, F401

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

@@ -0,0 +1,21 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SpoolUsageHistory(Base):
+    """Record of filament consumption for a spool during a print."""
+
+    __tablename__ = "spool_usage_history"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    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))
+    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
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

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

@@ -49,6 +49,7 @@ class SlicerSetting(BaseModel):
     version: str | None = None
     user_id: str | None = None
     updated_time: str | None = None
+    is_custom: bool = False
 
 
 class SlicerSettingsResponse(BaseModel):

+ 3 - 0
backend/app/schemas/external_link.py

@@ -9,6 +9,7 @@ class ExternalLinkBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
     url: str = Field(..., min_length=1, max_length=500, description="External URL")
     icon: str = Field(default="link", max_length=50, description="Lucide icon name")
+    open_in_new_tab: bool = False
 
     @field_validator("url")
     @classmethod
@@ -31,6 +32,7 @@ class ExternalLinkUpdate(BaseModel):
     name: str | None = Field(default=None, min_length=1, max_length=50)
     url: str | None = Field(default=None, min_length=1, max_length=500)
     icon: str | None = Field(default=None, max_length=50)
+    open_in_new_tab: bool | None = None
 
     @field_validator("url")
     @classmethod
@@ -45,6 +47,7 @@ class ExternalLinkResponse(ExternalLinkBase):
     """Response schema for external links."""
 
     id: int
+    open_in_new_tab: bool
     custom_icon: str | None = None
     sort_order: int
     created_at: datetime

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

@@ -53,6 +53,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
 
+    # Event triggers - Bed cooled
+    on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
+
     # Event triggers - Print queue
     on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
     on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
@@ -129,6 +132,9 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
 
+    # Event triggers - Bed cooled
+    on_bed_cooled: bool | None = None
+
     # Event triggers - Print queue
     on_queue_job_added: bool | None = None
     on_queue_job_assigned: bool | None = None

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

@@ -20,6 +20,7 @@ class EventType(StrEnum):
     MAINTENANCE_DUE = "maintenance_due"
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
+    BED_COOLED = "bed_cooled"
     TEST = "test"
 
 
@@ -31,12 +32,34 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "filename",
         "duration",
         "filament_grams",
+        "filament_details",
+        "finish_photo_url",
+        "timestamp",
+        "app_name",
+    ],
+    "print_failed": [
+        "printer",
+        "filename",
+        "duration",
+        "filament_grams",
+        "filament_details",
+        "progress",
+        "reason",
+        "finish_photo_url",
+        "timestamp",
+        "app_name",
+    ],
+    "print_stopped": [
+        "printer",
+        "filename",
+        "duration",
+        "filament_grams",
+        "filament_details",
+        "progress",
         "finish_photo_url",
         "timestamp",
         "app_name",
     ],
-    "print_failed": ["printer", "filename", "duration", "reason", "finish_photo_url", "timestamp", "app_name"],
-    "print_stopped": ["printer", "filename", "duration", "finish_photo_url", "timestamp", "app_name"],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
@@ -44,6 +67,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
+    "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -72,6 +96,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "duration": "1h 18m",
         "filament_grams": "15.2",
+        "filament_details": "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g",
         "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg",
         "timestamp": "2024-01-15 15:48",
         "app_name": "Bambuddy",
@@ -80,6 +105,9 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 45m",
+        "filament_grams": "7.6",
+        "filament_details": "AMS-A T1 PLA: 7.6g",
+        "progress": "50",
         "reason": "Filament runout",
         "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg",
         "timestamp": "2024-01-15 15:15",
@@ -89,6 +117,9 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 30m",
+        "filament_grams": "4.6",
+        "filament_details": "AMS-A T2 PLA: 4.6g",
+        "progress": "30",
         "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
@@ -143,6 +174,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "bed_cooled": {
+        "printer": "Bambu X1C",
+        "bed_temp": "34",
+        "threshold": "35",
+        "filename": "Benchy",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

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

@@ -0,0 +1,25 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class PrintLogEntrySchema(BaseModel):
+    id: int
+    print_name: str | None = None
+    printer_name: str | None = None
+    printer_id: int | None = None
+    status: str
+    started_at: datetime | None = None
+    completed_at: datetime | None = None
+    duration_seconds: int | None = None
+    filament_type: str | None = None
+    filament_color: str | None = None
+    filament_used_grams: float | None = None
+    thumbnail_path: str | None = None
+    created_by_username: str | None = None
+    created_at: datetime
+
+
+class PrintLogResponse(BaseModel):
+    items: list[PrintLogEntrySchema]
+    total: int

+ 1 - 1
backend/app/schemas/printer.py

@@ -199,7 +199,7 @@ class PrinterStatus(BaseModel):
     hms_errors: list[HMSErrorResponse] = []
     ams: list[AMSUnit] = []
     ams_exists: bool = False
-    vt_tray: AMSTray | None = None  # Virtual tray / external spool
+    vt_tray: list[AMSTray] = []  # Virtual tray / external spool(s)
     sdcard: bool = False  # SD card inserted
     store_to_sdcard: bool = False  # Store sent files on SD card
     timelapse: bool = False  # Timelapse recording active

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

@@ -39,6 +39,11 @@ class AppSettings(BaseModel):
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
+    # Bed cooled notification threshold
+    bed_cooled_threshold: float = Field(
+        default=35.0, description="Bed temperature threshold for cooled notification (°C)"
+    )
+
     # AMS threshold settings for humidity and temperature coloring
     ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
     ams_humidity_fair: int = Field(
@@ -161,6 +166,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     notification_language: str | None = None
+    bed_cooled_threshold: float | None = None
     ams_humidity_good: int | None = None
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None

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

@@ -0,0 +1,109 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class SpoolBase(BaseModel):
+    material: str = Field(..., min_length=1, max_length=50)
+    subtype: str | None = None
+    color_name: str | None = None
+    rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
+    brand: str | None = None
+    label_weight: int = 1000
+    core_weight: int = 250
+    weight_used: float = 0
+    slicer_filament: str | None = None
+    slicer_filament_name: str | None = None
+    nozzle_temp_min: int | None = None
+    nozzle_temp_max: int | None = None
+    note: str | None = None
+    tag_uid: str | None = None
+    tray_uuid: str | None = None
+    data_origin: str | None = None
+    tag_type: str | None = None
+
+
+class SpoolCreate(SpoolBase):
+    pass
+
+
+class SpoolUpdate(BaseModel):
+    material: str | None = None
+    subtype: str | None = None
+    color_name: str | None = None
+    rgba: str | None = None
+    brand: str | None = None
+    label_weight: int | None = None
+    core_weight: int | None = None
+    weight_used: float | None = None
+    slicer_filament: str | None = None
+    slicer_filament_name: str | None = None
+    nozzle_temp_min: int | None = None
+    nozzle_temp_max: int | None = None
+    note: str | None = None
+    tag_uid: str | None = None
+    tray_uuid: str | None = None
+    data_origin: str | None = None
+    tag_type: str | None = None
+
+
+class SpoolKProfileBase(BaseModel):
+    printer_id: int
+    extruder: int = 0
+    nozzle_diameter: str = "0.4"
+    nozzle_type: str | None = None
+    k_value: float
+    name: str | None = None
+    cali_idx: int | None = None
+    setting_id: str | None = None
+
+
+class SpoolKProfileResponse(SpoolKProfileBase):
+    id: int
+    spool_id: int
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class SpoolResponse(SpoolBase):
+    id: int
+    added_full: bool | None = None
+    last_used: datetime | None = None
+    encode_time: datetime | None = None
+    tag_uid: str | None = None
+    tray_uuid: str | None = None
+    data_origin: str | None = None
+    tag_type: str | None = None
+    archived_at: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+    k_profiles: list[SpoolKProfileResponse] = []
+
+    class Config:
+        from_attributes = True
+
+
+class SpoolAssignmentCreate(BaseModel):
+    spool_id: int
+    printer_id: int
+    ams_id: int
+    tray_id: int
+
+
+class SpoolAssignmentResponse(BaseModel):
+    id: int
+    spool_id: int
+    printer_id: int
+    printer_name: str | None = None
+    ams_id: int
+    tray_id: int
+    fingerprint_color: str | None = None
+    fingerprint_type: str | None = None
+    created_at: datetime
+    spool: SpoolResponse | None = None
+    configured: bool = False
+
+    class Config:
+        from_attributes = True

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

@@ -0,0 +1,17 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class SpoolUsageHistoryResponse(BaseModel):
+    id: int
+    spool_id: int
+    printer_id: int | None = None
+    print_name: str | None = None
+    weight_used: float
+    percent_used: int
+    status: str
+    created_at: datetime
+
+    class Config:
+        from_attributes = True

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

@@ -151,6 +151,27 @@ class ThreeMFParser:
                         self.metadata["_slice_filament_type"] = ", ".join(types)
                     if colors:
                         self.metadata["_slice_filament_color"] = ",".join(colors)
+
+                    # Collect per-slot filament usage for tracking & notifications
+                    filament_slots = []
+                    for f in filaments:
+                        slot_id = f.get("id")
+                        used_g_str = f.get("used_g", "0")
+                        try:
+                            used_g = float(used_g_str)
+                        except (ValueError, TypeError):
+                            used_g = 0
+                        if used_g > 0 and slot_id:
+                            filament_slots.append(
+                                {
+                                    "slot_id": int(slot_id),
+                                    "used_g": round(used_g, 2),
+                                    "type": f.get("type", ""),
+                                    "color": f.get("color", ""),
+                                }
+                            )
+                    if filament_slots:
+                        self.metadata["filament_slots"] = filament_slots
         except Exception:
             pass  # Skip unparseable slice_info metadata
 
@@ -706,10 +727,10 @@ class ArchiveService:
                 sha256.update(chunk)
         return sha256.hexdigest()
 
-    async def get_duplicate_hashes(self) -> set[str]:
-        """Get all content hashes that appear more than once.
+    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[str]]:
+        """Get all content hashes and print names that appear more than once.
 
-        Returns a set of hashes that have duplicates.
+        Returns a tuple of (duplicate_hashes, duplicate_names).
         """
         from sqlalchemy import func
 
@@ -719,7 +740,17 @@ class ArchiveService:
             .group_by(PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
         )
-        return {row[0] for row in result.all()}
+        duplicate_hashes = {row[0] for row in result.all()}
+
+        result = await self.db.execute(
+            select(func.lower(PrintArchive.print_name))
+            .where(PrintArchive.print_name.isnot(None))
+            .group_by(func.lower(PrintArchive.print_name))
+            .having(func.count(PrintArchive.id) > 1)
+        )
+        duplicate_names = {row[0] for row in result.all()}
+
+        return duplicate_hashes, duplicate_names
 
     async def find_duplicates(
         self,
@@ -1026,8 +1057,9 @@ class ArchiveService:
         if not archive:
             return False
 
-        # Delete files - with CRITICAL safety checks to prevent accidental deletion
-        # of parent directories (e.g., /opt) if file_path is empty/malformed
+        # Resolve the directory to delete BEFORE committing the DB change
+        dir_to_delete: Path | None = None
+
         if archive.file_path and archive.file_path.strip():
             file_path = settings.base_dir / archive.file_path
             if file_path.exists():
@@ -1041,13 +1073,11 @@ class ArchiveService:
                         f"SECURITY: Refusing to delete archive {archive_id} - "
                         f"path {archive_dir} is outside archive directory {settings.archive_dir}"
                     )
-                    # Still delete the database record, just not the files
                     await self.db.delete(archive)
                     await self.db.commit()
                     return True
 
                 # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir
-                # (should be archive_dir/uuid/file.3mf, so parent should be archive_dir/uuid)
                 try:
                     relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
                     if len(relative_path.parts) < 1:
@@ -1061,16 +1091,22 @@ class ArchiveService:
                 except ValueError:
                     pass  # Already handled above
 
-                shutil.rmtree(archive_dir, ignore_errors=True)
+                dir_to_delete = archive_dir
         else:
             logger.error(
                 f"SECURITY: Refusing to delete files for archive {archive_id} - "
                 f"file_path is empty or invalid: '{archive.file_path}'"
             )
 
-        # Delete database record
+        # Delete database record FIRST — if the commit fails (e.g. database locked
+        # during concurrent bulk deletes), the files stay on disk and nothing is lost.
         await self.db.delete(archive)
         await self.db.commit()
+
+        # Only delete files AFTER the DB commit succeeds to avoid orphaned records
+        if dir_to_delete:
+            shutil.rmtree(dir_to_delete, ignore_errors=True)
+
         return True
 
     async def attach_timelapse(

+ 1 - 1
backend/app/services/bambu_ftp.py

@@ -181,7 +181,7 @@ class BambuFTPClient:
         if self._ftp:
             try:
                 self._ftp.quit()
-            except (OSError, ftplib.Error):
+            except (OSError, ftplib.Error, EOFError):
                 pass  # Best-effort FTP cleanup; connection may already be closed
             self._ftp = None
 

+ 221 - 116
backend/app/services/bambu_mqtt.py

@@ -11,6 +11,7 @@ import asyncio
 import json
 import logging
 import ssl
+import threading
 import time
 from collections import deque
 from collections.abc import Callable
@@ -127,7 +128,7 @@ class PrinterState:
     chamber_light: bool = False
     # Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow
     active_extruder: int = 0
-    # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
+    # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     tray_now: int = 255
     # Pending load target - used to track what tray we're loading for H2D disambiguation
     pending_tray_target: int | None = None
@@ -279,6 +280,7 @@ class BambuMQTTClient:
         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
+        self._disconnection_event: threading.Event | None = None
         self._previous_ams_hash: str | None = None  # Track AMS changes
 
         # K-profile command tracking
@@ -357,6 +359,8 @@ class BambuMQTTClient:
         self.state.connected = False
         if self.on_state_change:
             self.on_state_change(self.state)
+        if self._disconnection_event:
+            self._disconnection_event.set()
 
     def _on_message(self, client, userdata, msg):
         try:
@@ -368,7 +372,7 @@ class BambuMQTTClient:
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
-                logger.info("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2))
+                logger.debug("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2))
             # Log message if logging is enabled
             if self._logging_enabled:
                 self._message_log.append(
@@ -396,7 +400,7 @@ class BambuMQTTClient:
         # Handle xcam data (camera settings and AI detection) at top level
         if "xcam" in payload:
             xcam_data = payload["xcam"]
-            logger.info("[%s] Received xcam data at top level: %s", self.serial_number, xcam_data)
+            logger.debug("[%s] Received xcam data at top level: %s", self.serial_number, xcam_data)
             self._parse_xcam_data(xcam_data)
             # Fire state change callback for top-level xcam (not nested in "print")
             if "print" not in payload and self.on_state_change:
@@ -405,7 +409,7 @@ class BambuMQTTClient:
         # Handle system responses (accessories info, etc.)
         if "system" in payload:
             system_data = payload["system"]
-            logger.info("[%s] Received system data: %s", self.serial_number, system_data)
+            logger.debug("[%s] Received system data: %s", self.serial_number, system_data)
             self._handle_system_response(system_data)
 
         # Handle info responses (firmware version info from get_version command)
@@ -430,12 +434,12 @@ class BambuMQTTClient:
 
             # Check if xcam is nested inside print data
             if "xcam" in print_data:
-                logger.info("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
+                logger.debug("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
                 self._parse_xcam_data(print_data["xcam"])
 
             # Log when we see gcode_state changes
             if "gcode_state" in print_data:
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
@@ -447,14 +451,33 @@ class BambuMQTTClient:
                 except Exception as e:
                     logger.error("[%s] Error handling AMS data from print: %s", self.serial_number, e)
 
+            # Handle vir_slot (H2-series external spool data) — list of external trays
+            # Process vir_slot FIRST so it takes priority over vt_tray
+            if "vir_slot" in print_data:
+                vir_slot = print_data["vir_slot"]
+                if isinstance(vir_slot, list) and vir_slot:
+                    # Fix: single-nozzle printers (X1C, P1S, A1) report their single
+                    # external slot with id=255 in vir_slot, but tray_now=254 when active.
+                    # Remap id=255→254 for single-slot printers so active detection works.
+                    # Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).
+                    if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255":
+                        vir_slot[0]["id"] = "254"
+                    self.state.raw_data["vt_tray"] = vir_slot
+
             # Handle vt_tray (virtual tray / external spool) data
-            if "vt_tray" in print_data:
+            # Only use vt_tray if vir_slot is NOT in this message AND we don't already
+            # have vir_slot data (H2-series sends vt_tray as a single active spool dict
+            # which would overwrite the correct multi-slot vir_slot data)
+            if "vt_tray" in print_data and "vir_slot" not in print_data:
                 vt_tray = print_data["vt_tray"]
-                self.state.raw_data["vt_tray"] = vt_tray
-                # Log vt_tray to investigate per-extruder data for H2D
-                if not hasattr(self, "_vt_tray_logged") or not self._vt_tray_logged:
-                    logger.info("[%s] vt_tray data: %s", self.serial_number, vt_tray)
-                    self._vt_tray_logged = True
+                existing = self.state.raw_data.get("vt_tray")
+                # Don't let a single-spool vt_tray dict overwrite multi-slot vir_slot data
+                if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1:
+                    pass  # Keep the vir_slot data
+                else:
+                    if isinstance(vt_tray, dict):
+                        vt_tray = [vt_tray]
+                    self.state.raw_data["vt_tray"] = vt_tray
 
             # Parse ams_status directly from print data (NOT from print.ams)
             # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
@@ -482,7 +505,10 @@ class BambuMQTTClient:
 
             # Check for K-profile response (extrusion_cali)
             if "command" in print_data:
-                logger.debug("[%s] Received command response: %s", self.serial_number, print_data.get("command"))
+                cmd = print_data.get("command")
+                logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
+                if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
+                    logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
             if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
                 self._handle_kprofile_response(print_data)
 
@@ -502,7 +528,7 @@ class BambuMQTTClient:
             # Log response for debugging - but DON'T use it to update nozzle data
             # because it returns stale values (e.g., 'stainless_steel' when the
             # actual nozzle is 'HH01' hardened steel high-flow)
-            logger.info("[%s] Accessories response (not used for nozzle data): %s", self.serial_number, data)
+            logger.debug("[%s] Accessories response (not used for nozzle data): %s", self.serial_number, data)
 
     def _handle_version_info(self, data: dict):
         """Handle version info response from get_version command.
@@ -598,7 +624,7 @@ class BambuMQTTClient:
             if should_accept_value("spaghetti_detector", cfg_spaghetti):
                 old_value = self.state.print_options.spaghetti_detector
                 if cfg_spaghetti != old_value:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] spaghetti_detector changed (from cfg): {old_value} -> {cfg_spaghetti}"
                     )
                 self.state.print_options.spaghetti_detector = cfg_spaghetti
@@ -606,7 +632,7 @@ class BambuMQTTClient:
             # Check hold timer for sensitivity before accepting
             if "halt_print_sensitivity" not in self._xcam_hold_start:
                 if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] Sensitivity changed (from cfg): "
                         f"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}"
                     )
@@ -622,7 +648,7 @@ class BambuMQTTClient:
                 else:
                     # Hold expired - accept from cfg
                     if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] Sensitivity synced (from cfg after hold): "
                             f"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}"
                         )
@@ -633,14 +659,14 @@ class BambuMQTTClient:
             cfg_pileup, cfg_pileup_sens = decode_detector(8)
             if should_accept_value("pileup_detector", cfg_pileup):
                 if cfg_pileup != self.state.print_options.pileup_detector:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] pileup_detector changed (from cfg): {self.state.print_options.pileup_detector} -> {cfg_pileup}"
                     )
                     self.state.print_options.pileup_detector = cfg_pileup
             # Pileup sensitivity with hold timer
             if "pileup_sensitivity" not in self._xcam_hold_start:
                 if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] pileup_sensitivity changed (from cfg): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}"
                     )
                     self.state.print_options.pileup_sensitivity = cfg_pileup_sens
@@ -649,7 +675,7 @@ class BambuMQTTClient:
                 elapsed = current_time - hold_start
                 if elapsed > self._xcam_hold_time:
                     if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] pileup_sensitivity synced (from cfg after hold): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}"
                         )
                         self.state.print_options.pileup_sensitivity = cfg_pileup_sens
@@ -659,14 +685,14 @@ class BambuMQTTClient:
             cfg_clump, cfg_clump_sens = decode_detector(11)
             if should_accept_value("clump_detector", cfg_clump):
                 if cfg_clump != self.state.print_options.nozzle_clumping_detector:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] nozzle_clumping_detector changed (from cfg): {self.state.print_options.nozzle_clumping_detector} -> {cfg_clump}"
                     )
                     self.state.print_options.nozzle_clumping_detector = cfg_clump
             # Clump sensitivity with hold timer
             if "nozzle_clumping_sensitivity" not in self._xcam_hold_start:
                 if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] nozzle_clumping_sensitivity changed (from cfg): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}"
                     )
                     self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens
@@ -675,7 +701,7 @@ class BambuMQTTClient:
                 elapsed = current_time - hold_start
                 if elapsed > self._xcam_hold_time:
                     if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] nozzle_clumping_sensitivity synced (from cfg after hold): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}"
                         )
                         self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens
@@ -685,14 +711,14 @@ class BambuMQTTClient:
             cfg_airprint, cfg_airprint_sens = decode_detector(14)
             if should_accept_value("airprint_detector", cfg_airprint):
                 if cfg_airprint != self.state.print_options.airprint_detector:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] airprint_detector changed (from cfg): {self.state.print_options.airprint_detector} -> {cfg_airprint}"
                     )
                     self.state.print_options.airprint_detector = cfg_airprint
             # Airprint sensitivity with hold timer
             if "airprint_sensitivity" not in self._xcam_hold_start:
                 if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] airprint_sensitivity changed (from cfg): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}"
                     )
                     self.state.print_options.airprint_sensitivity = cfg_airprint_sens
@@ -701,7 +727,7 @@ class BambuMQTTClient:
                 elapsed = current_time - hold_start
                 if elapsed > self._xcam_hold_time:
                     if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] airprint_sensitivity synced (from cfg after hold): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}"
                         )
                         self.state.print_options.airprint_sensitivity = cfg_airprint_sens
@@ -807,7 +833,7 @@ class BambuMQTTClient:
                         pending_slot = pending_target % 4
                         if pending_slot == parsed_tray_now:
                             # Slot matches our pending target - use the full global ID
-                            logger.info(
+                            logger.debug(
                                 f"[{self.serial_number}] H2D tray_now disambiguation: "
                                 f"slot {parsed_tray_now} matches pending_tray_target {pending_target} -> using global ID {pending_target}"
                             )
@@ -836,7 +862,7 @@ class BambuMQTTClient:
                             snow_slot = snow_tray % 4 if snow_tray < 128 else -1
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
-                                    logger.info(
+                                    logger.debug(
                                         f"[{self.serial_number}] H2D tray_now from snow: "
                                         f"extruder[{active_ext}] snow={snow_tray} (slot {snow_slot})"
                                     )
@@ -864,7 +890,7 @@ class BambuMQTTClient:
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
                                 global_tray_id = active_ams_id * 4 + parsed_tray_now
-                                logger.info(
+                                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}"
                                 )
@@ -937,12 +963,20 @@ class BambuMQTTClient:
                         if tray_id is not None and tray_id in existing_trays:
                             # Merge: start with existing, update with new non-empty values
                             merged_tray = existing_trays[tray_id].copy()
+                            # Detect slot-clearing updates (spool removal):
+                            # When tray_type is explicitly empty, clear everything
+                            # including RFID data (tag_uid/tray_uuid).
+                            slot_clearing = new_tray.get("tray_type") == ""
                             for key, value in new_tray.items():
                                 # Fields that should always be updated (even with empty/zero values):
                                 # - remain, k, id, cali_idx: status indicators where 0 is valid
-                                # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
-                                #   tray_color, tray_id_name: slot content indicators that must be
-                                #   cleared when a spool is removed (fixes #147 - old AMS empty slot)
+                                # - tray_type, tray_sub_brands, tray_info_idx, tray_color,
+                                #   tray_id_name: slot content indicators that must be cleared
+                                #   when a spool is removed (fixes #147 - old AMS empty slot)
+                                # NOTE: tag_uid and tray_uuid are NOT in always_update_fields.
+                                # They are only cleared during spool removal (slot_clearing=True).
+                                # Periodic AMS updates often include empty RFID fields which
+                                # would overwrite valid data from the initial pushall.
                                 always_update_fields = (
                                     "remain",
                                     "k",
@@ -950,17 +984,20 @@ class BambuMQTTClient:
                                     "cali_idx",
                                     "tray_type",
                                     "tray_sub_brands",
-                                    "tag_uid",
-                                    "tray_uuid",
                                     "tray_info_idx",
                                     "tray_color",
                                     "tray_id_name",
                                 )
-                                if key in always_update_fields or value not in (
-                                    None,
-                                    "",
-                                    "0000000000000000",
-                                    "00000000000000000000000000000000",
+                                if (
+                                    key in always_update_fields
+                                    or slot_clearing
+                                    or value
+                                    not in (
+                                        None,
+                                        "",
+                                        "0000000000000000",
+                                        "00000000000000000000000000000000",
+                                    )
                                 ):
                                     merged_tray[key] = value
                             merged_trays.append(merged_tray)
@@ -999,7 +1036,7 @@ class BambuMQTTClient:
                         slot_exists = (tray_exist_bits >> global_bit) & 1
                         if not slot_exists and tray.get("tray_type"):
                             # Slot is marked empty but has data - clear it
-                            logger.info(
+                            logger.debug(
                                 f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
                                 f"(tray_exist_bits bit {global_bit} = 0)"
                             )
@@ -1061,8 +1098,10 @@ class BambuMQTTClient:
         if ams_hash != self._previous_ams_hash:
             self._previous_ams_hash = ams_hash
             if self.on_ams_change:
-                logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number)
-                self.on_ams_change(ams_list)
+                logger.debug("[%s] AMS data changed, triggering sync callback", self.serial_number)
+                # Pass merged AMS data (not raw ams_list) — partial MQTT updates
+                # may lack fields like 'remain' that the merged state preserves
+                self.on_ams_change(merged_ams)
 
     def _update_state(self, data: dict):
         """Update printer state from message data."""
@@ -1125,7 +1164,7 @@ class BambuMQTTClient:
         if not hasattr(self, "_fan_fields_logged"):
             fan_fields = {k: v for k, v in data.items() if "fan" in k.lower()}
             if fan_fields:
-                logger.info("[%s] Fan fields in MQTT data: %s", self.serial_number, fan_fields)
+                logger.debug("[%s] Fan fields in MQTT data: %s", self.serial_number, fan_fields)
                 self._fan_fields_logged = True
 
         if "cooling_fan_speed" in data:
@@ -1142,7 +1181,7 @@ class BambuMQTTClient:
             new_stg = data["stg_cur"]
             # Always log ANY stg_cur change for debugging filament operations
             if new_stg != self.state.stg_cur:
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
                 )
             self.state.stg_cur = new_stg
@@ -1154,15 +1193,15 @@ class BambuMQTTClient:
         # Log all fields for debugging dual-nozzle temperature discovery (only once)
         if "bed_temper" in data and not hasattr(self, "_temp_fields_logged"):
             temp_fields = {k: v for k, v in data.items() if "temp" in k.lower() or "chamber" in k.lower()}
-            logger.info("[%s] Temperature-related fields: %s", self.serial_number, temp_fields)
+            logger.debug("[%s] Temperature-related fields: %s", self.serial_number, temp_fields)
             # Log ALL keys in print data for H2D temperature discovery
             all_keys = sorted(data.keys())
-            logger.info("[%s] ALL print data keys (%s): %s", self.serial_number, len(all_keys), all_keys)
+            logger.debug("[%s] ALL print data keys (%s): %s", self.serial_number, len(all_keys), all_keys)
             self._temp_fields_logged = True
 
         # Log vir_slot data (once) - this may contain per-extruder slot mapping for H2D
         if "vir_slot" in data and not hasattr(self, "_vir_slot_logged"):
-            logger.info("[%s] vir_slot data: %s", self.serial_number, data["vir_slot"])
+            logger.debug("[%s] vir_slot data: %s", self.serial_number, data["vir_slot"])
             self._vir_slot_logged = True
 
         # Log nozzle hardware info fields (once)
@@ -1172,7 +1211,7 @@ class BambuMQTTClient:
             if "nozzle" in k.lower() or "hw" in k.lower() or "extruder" in k.lower() or "upgrade" in k.lower()
         }
         if nozzle_fields and not hasattr(self, "_nozzle_fields_logged"):
-            logger.info("[%s] Nozzle/hardware fields in MQTT data: %s", self.serial_number, nozzle_fields)
+            logger.debug("[%s] Nozzle/hardware fields in MQTT data: %s", self.serial_number, nozzle_fields)
             self._nozzle_fields_logged = True
         # Parse active extruder from device.extruder.state bit 8
         # bit 8 = 0 → RIGHT extruder (active_extruder=0)
@@ -1184,7 +1223,7 @@ class BambuMQTTClient:
                 # Extract bit 8 for extruder position
                 new_extruder = (state_val >> 8) & 0x1
                 if new_extruder != self.state.active_extruder:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]"
                     )
                     self.state.active_extruder = new_extruder
@@ -1199,12 +1238,12 @@ class BambuMQTTClient:
                     state_val = ext_data["state"]
                     # Extract bits 12-14 (3 bits) for switch state
                     switch_state = (state_val >> 12) & 0x7
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] device.extruder.state={state_val} (switch_state bits 12-14: {switch_state})"
                     )
                 # Log 'cur' field if present (might indicate current/active extruder)
                 if "cur" in ext_data:
-                    logger.info("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
+                    logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
@@ -1406,7 +1445,7 @@ class BambuMQTTClient:
                                     global_tray = ams_id * 4 + (slot & 0x03)
                                     old_val = self.state.h2d_extruder_snow.get(ext_id)
                                     if old_val != global_tray:
-                                        logger.info(
+                                        logger.debug(
                                             f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
                                             f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}"
                                         )
@@ -1416,7 +1455,7 @@ class BambuMQTTClient:
                                     normalized = 254 if slot != 255 else 255
                                     old_val = self.state.h2d_extruder_snow.get(ext_id)
                                     if old_val != normalized:
-                                        logger.info(
+                                        logger.debug(
                                             f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
                                             f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}"
                                         )
@@ -1425,7 +1464,7 @@ class BambuMQTTClient:
                                     # External spool with hub mapping
                                     old_val = self.state.h2d_extruder_snow.get(ext_id)
                                     if old_val != ams_id:
-                                        logger.info(
+                                        logger.debug(
                                             f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
                                             f"raw={snow} -> external hub {ams_id}"
                                         )
@@ -1450,7 +1489,7 @@ class BambuMQTTClient:
                 if "modeCur" in airduct_data:
                     new_mode = airduct_data["modeCur"]
                     if new_mode != self.state.airduct_mode:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}"
                         )
                     self.state.airduct_mode = new_mode
@@ -1558,7 +1597,7 @@ class BambuMQTTClient:
         # Parse HMS (Health Management System) errors
         if "hms" in data:
             hms_list = data["hms"]
-            logger.info("[%s] HMS data received: %s", self.serial_number, hms_list)
+            logger.debug("[%s] HMS data received: %s", self.serial_number, hms_list)
             self.state.hms_errors = []
             if isinstance(hms_list, list):
                 for hms in hms_list:
@@ -1602,7 +1641,7 @@ class BambuMQTTClient:
                 # code stores the short format string for lookup
                 short_code = f"{module:04X}_{error:04X}"
 
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
                 )
 
@@ -1637,7 +1676,7 @@ class BambuMQTTClient:
                 home_flag = home_flag & 0xFFFFFFFF
             store_to_sdcard = bool((home_flag >> 11) & 1)
             if store_to_sdcard != self.state.store_to_sdcard:
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] store_to_sdcard changed: {self.state.store_to_sdcard} -> {store_to_sdcard}"
                 )
             self.state.store_to_sdcard = store_to_sdcard
@@ -1661,21 +1700,21 @@ class BambuMQTTClient:
                 if "timelapse" in ipcam_data:
                     timelapse_enabled = ipcam_data.get("timelapse") == "enable"
                     if timelapse_enabled != self.state.timelapse:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] timelapse changed (from ipcam): {self.state.timelapse} -> {timelapse_enabled}"
                         )
                     self.state.timelapse = timelapse_enabled
                     # Track if timelapse was ever active during this print
                     if self.state.timelapse and self._was_running:
                         self._timelapse_during_print = True
-                        logger.info("[%s] Timelapse detected during print (from ipcam)", self.serial_number)
+                        logger.debug("[%s] Timelapse detected during print (from ipcam)", self.serial_number)
             else:
                 self.state.ipcam = ipcam_data is True
 
         # Parse WiFi signal strength (dBm)
         if "wifi_signal" in data:
             wifi_signal = data["wifi_signal"]
-            logger.info("[%s] wifi_signal received: %s", self.serial_number, wifi_signal)
+            logger.debug("[%s] wifi_signal received: %s", self.serial_number, wifi_signal)
             if isinstance(wifi_signal, (int, float)):
                 self.state.wifi_signal = int(wifi_signal)
             elif isinstance(wifi_signal, str):
@@ -1689,7 +1728,9 @@ class BambuMQTTClient:
         if "spd_lvl" in data:
             new_speed = data["spd_lvl"]
             if new_speed != self.state.speed_level:
-                logger.info("[%s] speed_level changed: %s -> %s", self.serial_number, self.state.speed_level, new_speed)
+                logger.debug(
+                    "[%s] speed_level changed: %s -> %s", self.serial_number, self.state.speed_level, new_speed
+                )
             self.state.speed_level = new_speed
 
         # Parse skipped objects from printer status (s_obj field)
@@ -1700,7 +1741,7 @@ class BambuMQTTClient:
                 # Update skipped objects from printer's list
                 new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))]
                 if new_skipped != self.state.skipped_objects:
-                    logger.info("[%s] skipped_objects updated from printer: %s", self.serial_number, new_skipped)
+                    logger.debug("[%s] skipped_objects updated from printer: %s", self.serial_number, new_skipped)
                     self.state.skipped_objects = new_skipped
 
         # Parse chamber light status from lights_report
@@ -1712,7 +1753,7 @@ class BambuMQTTClient:
                     if isinstance(light, dict) and light.get("node") == "chamber_light":
                         new_light_state = light.get("mode") == "on"
                         if new_light_state != self.state.chamber_light:
-                            logger.info(
+                            logger.debug(
                                 f"[{self.serial_number}] chamber_light changed: {self.state.chamber_light} -> {new_light_state}"
                             )
                         self.state.chamber_light = new_light_state
@@ -1777,7 +1818,7 @@ class BambuMQTTClient:
                     )
                     if not hasattr(self, "_nozzle_rack_logged") and nozzle_info:
                         self._nozzle_rack_logged = True
-                        logger.info(
+                        logger.debug(
                             "[%s] Nozzle info: %d entries, IDs: %s",
                             self.serial_number,
                             len(nozzle_info),
@@ -1829,11 +1870,11 @@ class BambuMQTTClient:
         # Track RUNNING state for more robust completion detection
         if self.state.state == "RUNNING" and current_file:
             if not self._was_running:
-                logger.info("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
+                logger.debug("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
                 # Check if timelapse was enabled in the same message (xcam parsed before this)
                 if self.state.timelapse:
                     self._timelapse_during_print = True
-                    logger.info("[%s] Timelapse detected when entering RUNNING state", self.serial_number)
+                    logger.debug("[%s] Timelapse detected when entering RUNNING state", self.serial_number)
             self._was_running = True
             self._completion_triggered = False
 
@@ -1851,7 +1892,7 @@ class BambuMQTTClient:
             # We preserve that value instead of blindly resetting to False.
             if self.state.timelapse:
                 self._timelapse_during_print = True
-                logger.info("[%s] Timelapse detected at print start", self.serial_number)
+                logger.debug("[%s] Timelapse detected at print start", self.serial_number)
             else:
                 self._timelapse_during_print = False
 
@@ -1967,7 +2008,7 @@ class BambuMQTTClient:
         if not self._client or not self.state.connected:
             logger.warning("[%s] request_status_update: not connected", self.serial_number)
             return False
-        logger.info("[%s] Requesting status update (pushall)", self.serial_number)
+        logger.debug("[%s] Requesting status update (pushall)", self.serial_number)
         self._request_push_all()
         # Note: get_accessories returns stale nozzle data on H2D.
         # The correct nozzle data comes from push_status response.
@@ -2124,7 +2165,7 @@ class BambuMQTTClient:
             }
 
             if is_h2d:
-                logger.info(
+                logger.debug(
                     "[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)",
                     self.serial_number,
                 )
@@ -2133,7 +2174,7 @@ class BambuMQTTClient:
             # P2S printer doesn't support vibration calibration like X1/P1 series
             if self.model and self.model.upper().strip() in ("P2S", "N7"):
                 command["print"]["vibration_cali"] = False
-                logger.info("[%s] P2S detected: disabling vibration_cali", self.serial_number)
+                logger.debug("[%s] P2S detected: disabling vibration_cali", self.serial_number)
 
             # Add AMS mapping if provided
             if ams_mapping is not None:
@@ -2212,7 +2253,7 @@ class BambuMQTTClient:
 
         command_json = json.dumps(command)
         self._client.publish(self.topic_publish, command_json, qos=1)
-        logger.info(
+        logger.debug(
             "[%s] Set xcam option: %s=%s, sensitivity=%s", self.serial_number, module_name, enabled, sensitivity
         )
         logger.debug("[%s] MQTT command sent: %s", self.serial_number, command_json)
@@ -2291,7 +2332,7 @@ class BambuMQTTClient:
 
         command_json = json.dumps(command)
         self._client.publish(self.topic_publish, command_json, qos=1)
-        logger.info("[%s] Set print option: %s=%s", self.serial_number, option_name, enabled)
+        logger.debug("[%s] Set print option: %s=%s", self.serial_number, option_name, enabled)
 
         # Set hold timer
         hold_key = f"print_option_{option_name}"
@@ -2371,11 +2412,13 @@ class BambuMQTTClient:
 
         return True
 
-    def disconnect(self):
+    def disconnect(self, timeout: float = 0):
         """Disconnect from the printer."""
         if self._client:
-            self._client.loop_stop()
+            self._disconnection_event = threading.Event()
             self._client.disconnect()
+            self._disconnection_event.wait(timeout=timeout)
+            self._client.loop_stop()
             self._client = None
             self.state.connected = False
 
@@ -2415,7 +2458,7 @@ class BambuMQTTClient:
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
-        _response_seq_id = data.get("sequence_id", "?")
+        response_seq_id = data.get("sequence_id", "?")
         filaments = data.get("filaments", [])
         expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
         has_pending_request = self._pending_kprofile_response is not None
@@ -2423,7 +2466,8 @@ class BambuMQTTClient:
         # Log all incoming responses when we have a pending request (for debugging)
         if has_pending_request:
             logger.info(
-                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}"
+                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
+                f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
             )
 
         # If we have a pending request, only accept responses with matching nozzle_diameter
@@ -2649,7 +2693,7 @@ class BambuMQTTClient:
         logger.info(
             f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
         )
-        logger.info("[%s] K-profile SET command: %s", self.serial_number, command_json)
+        logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
@@ -2717,7 +2761,7 @@ class BambuMQTTClient:
 
         command_json = json.dumps(command)
         logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
-        logger.info("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
+        logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
@@ -2786,7 +2830,7 @@ class BambuMQTTClient:
         logger.info(
             f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
         )
-        logger.info("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
+        logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
         # Use QoS 1 for reliable delivery (at least once)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
@@ -3296,6 +3340,7 @@ class BambuMQTTClient:
         command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}}
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id)
+
         return True, f"Refreshing AMS {ams_id} tray {tray_id}"
 
     def ams_set_filament_setting(
@@ -3332,18 +3377,33 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set AMS filament setting: not connected", self.serial_number)
             return False
 
-        # Calculate slot_id based on AMS type
-        if ams_id <= 3:
+        # Calculate mqtt IDs based on AMS type
+        if ams_id == 255:
+            vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+            if len(vt_tray) > 1:
+                # Dual external slots (H2D): each ext slot is its own virtual AMS unit
+                # (254=ext-L / slot 0, 255=ext-R / slot 1)
+                mqtt_ams_id = 254 + tray_id
+            else:
+                # Single external slot (X1C, P1S, A1): always ams_id=255
+                mqtt_ams_id = 255
+            mqtt_tray_id = 0
+            slot_id = 0
+        elif ams_id <= 3:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = tray_id
         else:
-            # AMS-HT or external: slot_id = 0
+            # AMS-HT: single tray per unit
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
 
         command = {
             "print": {
                 "command": "ams_filament_setting",
-                "ams_id": ams_id,
-                "tray_id": tray_id,
+                "ams_id": mqtt_ams_id,
+                "tray_id": mqtt_tray_id,
                 "slot_id": slot_id,
                 "tray_info_idx": tray_info_idx,
                 "tray_type": tray_type,
@@ -3381,17 +3441,32 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot reset AMS slot: not connected", self.serial_number)
             return False
 
-        # Calculate slot_id based on AMS type
-        if ams_id <= 3:
+        # Calculate mqtt IDs based on AMS type
+        if ams_id == 255:
+            vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+            if len(vt_tray) > 1:
+                # Dual external slots (H2D): each ext slot is its own virtual AMS unit
+                mqtt_ams_id = 254 + tray_id
+            else:
+                # Single external slot (X1C, P1S, A1): always ams_id=255
+                mqtt_ams_id = 255
+            mqtt_tray_id = 0
+            slot_id = 0
+        elif ams_id <= 3:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = tray_id
         else:
+            # AMS-HT: single tray per unit
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
 
         command = {
             "print": {
                 "command": "ams_filament_setting",
-                "ams_id": ams_id,
-                "tray_id": tray_id,
+                "ams_id": mqtt_ams_id,
+                "tray_id": mqtt_tray_id,
                 "slot_id": slot_id,
                 "tray_info_idx": "",
                 "tray_type": "",
@@ -3416,20 +3491,21 @@ class BambuMQTTClient:
         cali_idx: int,
         filament_id: str,
         nozzle_diameter: str = "0.4",
-        setting_id: str | None = None,
     ) -> bool:
         """Set calibration profile (K value) for an AMS slot.
 
         This command selects a K profile from the printer's calibration list.
         Use cali_idx=-1 to use the default K value (0.020).
 
+        Note: Do NOT send setting_id in this command — BambuStudio never includes
+        it, and adding it causes the firmware to mislink the profile on X1C/P1S.
+
         Args:
             ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
             tray_id: Tray ID within the AMS (0-3)
             cali_idx: Calibration profile index (-1 for default)
             filament_id: Filament preset ID (same as tray_info_idx)
             nozzle_diameter: Nozzle diameter string (e.g., "0.4")
-            setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
 
         Returns:
             True if command was sent, False otherwise
@@ -3438,13 +3514,34 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set calibration: not connected", self.serial_number)
             return False
 
-        # Calculate slot_id based on AMS type
-        # tray_id in the command should be the local tray index (0-3)
-        if ams_id <= 3:
+        # Calculate mqtt IDs based on AMS type.
+        # IMPORTANT: extrusion_cali_sel uses GLOBAL tray_id (unlike ams_filament_setting
+        # which uses LOCAL).  BambuStudio confirms: tray_id = ams_id * 4 + slot.
+        if ams_id == 255:
+            # External spool: extrusion_cali_sel uses GLOBAL tray_id (unlike
+            # ams_filament_setting which uses LOCAL tray_id=0).
+            vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+            if len(vt_tray) > 1:
+                # Dual external slots (H2D): each ext slot is its own virtual AMS unit
+                # Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255
+                mqtt_ams_id = 254 + tray_id
+                mqtt_tray_id = 254 + tray_id
+            else:
+                # Single external slot (X1C, P1S, A1): global tray_id=254
+                mqtt_ams_id = 254
+                mqtt_tray_id = 254
+            slot_id = 0
+        elif ams_id <= 3:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = ams_id * 4 + tray_id
             slot_id = tray_id
         elif ams_id >= 128 and ams_id <= 135:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
         else:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
 
         command = {
@@ -3453,20 +3550,16 @@ class BambuMQTTClient:
                 "cali_idx": cali_idx,
                 "filament_id": filament_id,
                 "nozzle_diameter": nozzle_diameter,
-                "ams_id": ams_id,
-                "tray_id": tray_id,  # Local tray index (0-3), not global
+                "ams_id": mqtt_ams_id,
+                "tray_id": mqtt_tray_id,
                 "slot_id": slot_id,
                 "sequence_id": "0",
             }
         }
 
-        # Include setting_id if provided (helps slicer show correct K profile)
-        if setting_id:
-            command["print"]["setting_id"] = setting_id
-
         command_json = json.dumps(command)
         logger.info(
-            f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}"
+            f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}"
         )
         logger.debug("[%s] extrusion_cali_sel command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
@@ -3476,25 +3569,26 @@ class BambuMQTTClient:
         self,
         tray_id: int,
         k_value: float,
-        n_coef: float = 0.0,
         nozzle_diameter: str = "0.4",
-        bed_temp: int = 60,
         nozzle_temp: int = 220,
-        max_volumetric_speed: float = 20.0,
+        filament_id: str = "",
+        setting_id: str = "",
+        name: str = "",
+        cali_idx: int = -1,
     ) -> bool:
         """Directly set K value (pressure advance) for a tray.
 
-        This command sets the K value directly without selecting from stored profiles.
-        Use this when you want to apply a specific K value to a tray.
+        Uses the filaments array format required by current firmware.
 
         Args:
             tray_id: Global tray ID (ams_id * 4 + slot)
             k_value: Pressure advance K value (e.g., 0.020)
-            n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration)
             nozzle_diameter: Nozzle diameter string (e.g., "0.4")
-            bed_temp: Bed temperature for calibration reference
             nozzle_temp: Nozzle temperature for calibration reference
-            max_volumetric_speed: Max volumetric speed for calibration reference
+            filament_id: Filament preset ID (e.g., "GFA02")
+            setting_id: Setting ID (e.g., "GFSA02_07")
+            name: Profile display name
+            cali_idx: Calibration index (-1 for new)
 
         Returns:
             True if command was sent, False otherwise
@@ -3503,17 +3597,28 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             return False
 
+        nozzle_id = f"HS00-{nozzle_diameter}"
+
+        filament_entry = {
+            "ams_id": 0,
+            "cali_idx": cali_idx,
+            "extruder_id": 0,
+            "filament_id": filament_id,
+            "k_value": f"{k_value:.6f}",
+            "n_coef": "1.400000",
+            "name": name,
+            "nozzle_diameter": nozzle_diameter,
+            "nozzle_id": nozzle_id,
+            "setting_id": setting_id,
+            "tray_id": tray_id,
+        }
+
         command = {
             "print": {
                 "command": "extrusion_cali_set",
-                "tray_id": tray_id,
-                "k_value": k_value,
-                "n_coef": n_coef,
+                "filaments": [filament_entry],
                 "nozzle_diameter": nozzle_diameter,
-                "bed_temp": bed_temp,
-                "nozzle_temp": nozzle_temp,
-                "max_volumetric_speed": max_volumetric_speed,
-                "sequence_id": "0",
+                "sequence_id": str(self._sequence_id),
             }
         }
 

+ 36 - 15
backend/app/services/external_camera.py

@@ -362,7 +362,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     ]
 
     try:
-        print(f"[EXT-CAM] Running ffmpeg command: {' '.join(cmd[:6])}...")
+        logger.debug(f"Running ffmpeg command: {' '.join(cmd[:6])}...")
         process = await asyncio.create_subprocess_exec(
             *cmd,
             stdout=asyncio.subprocess.PIPE,
@@ -370,13 +370,12 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         )
 
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
-        print(
-            f"[EXT-CAM] ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
+        logger.debug(
+            f"ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
         )
 
         if process.returncode != 0:
             logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
-            print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
             return None
 
         if not stdout or len(stdout) < 100:
@@ -440,11 +439,9 @@ async def test_connection(url: str, camera_type: str) -> dict:
     Returns:
         Dict with {success: bool, error?: str, resolution?: str}
     """
-    print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
     logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
     try:
         frame = await capture_frame(url, camera_type, timeout=10)
-        print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
         logger.info("Capture result: %s bytes", len(frame) if frame else 0)
 
         if frame:
@@ -490,17 +487,41 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
     last_frame_time = 0.0
 
     if camera_type == "mjpeg":
-        # Proxy MJPEG stream directly
-        async for frame in _stream_mjpeg(url):
-            current_time = asyncio.get_event_loop().time()
-            if current_time - last_frame_time >= frame_interval:
-                last_frame_time = current_time
-                yield _format_mjpeg_frame(frame)
+        # Proxy MJPEG stream directly, with reconnect on timeout
+        max_retries = 3
+        for attempt in range(max_retries + 1):
+            frame_yielded = False
+            async for frame in _stream_mjpeg(url):
+                frame_yielded = True
+                current_time = asyncio.get_event_loop().time()
+                if current_time - last_frame_time >= frame_interval:
+                    last_frame_time = current_time
+                    yield _format_mjpeg_frame(frame)
+            if not frame_yielded or attempt == max_retries:
+                break
+            logger.warning(
+                "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
+                attempt + 1,
+                max_retries,
+            )
+            await asyncio.sleep(2)
 
     elif camera_type == "rtsp":
-        # Use ffmpeg to convert RTSP to MJPEG
-        async for frame in _stream_rtsp(url, fps):
-            yield _format_mjpeg_frame(frame)
+        # Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout
+        max_retries = 3
+        for attempt in range(max_retries + 1):
+            frame_yielded = False
+            async for frame in _stream_rtsp(url, fps):
+                frame_yielded = True
+                yield _format_mjpeg_frame(frame)
+            if not frame_yielded or attempt == max_retries:
+                break
+            logger.warning(
+                "External RTSP stream ended, reconnecting (attempt %d/%d)...",
+                attempt + 1,
+                max_retries,
+            )
+            await asyncio.sleep(2)
 
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera

+ 85 - 37
backend/app/services/firmware_check.py

@@ -1,8 +1,9 @@
 """
 Firmware Check Service
 
-Checks for firmware updates by fetching from Bambu Lab's official firmware download page.
-Also provides firmware download functionality for offline updates.
+Checks for firmware updates by fetching from Bambu Lab's official wiki and firmware
+download page. The wiki is used as the primary version source (always up-to-date),
+while the download page provides firmware file URLs for offline updates.
 """
 
 import logging
@@ -18,10 +19,13 @@ from backend.app.core.config import _data_dir
 
 logger = logging.getLogger(__name__)
 
-# Bambu Lab firmware download page
+# Bambu Lab firmware download page (for download URLs)
 BAMBU_FIRMWARE_BASE = "https://bambulab.com"
 FIRMWARE_PAGE = "/en/support/firmware-download/all"
 
+# Bambu Lab wiki (primary source for latest version detection)
+BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
+
 # Cache TTL in seconds (1 hour)
 CACHE_TTL = 3600
 
@@ -61,6 +65,20 @@ API_KEY_TO_DEV_MODEL = {
     "h2d-pro": "O1E",
 }
 
+# Wiki firmware release history pages (primary version source)
+API_KEY_TO_WIKI_PATH = {
+    "x1": "/en/x1/manual/X1-X1C-firmware-release-history",
+    "x1e": "/en/x1/manual/X1E-firmware-release-history",
+    "p1": "/en/p1/manual/p1p-firmware-release-history",
+    "a1": "/en/a1/manual/a1-firmware-release-history",
+    "a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
+    "h2d": "/en/h2d/manual/h2d-firmware-release-history",
+    "h2c": "/en/h2c/manual/h2c-firmware-release-history",
+    "h2s": "/en/h2s/manual/h2s-firmware-release-history",
+    "p2s": "/en/p2s/manual/p2s-firmware-release-history",
+    "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
+}
+
 
 @dataclass
 class FirmwareVersion:
@@ -109,11 +127,34 @@ class FirmwareCheckService:
 
         return self._build_id  # Return cached value if available
 
-    async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
-        """Fetch firmware versions for a specific printer from Bambu Lab API."""
+    async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
+        """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
+        wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
+        if not wiki_path:
+            return None
+
+        try:
+            url = f"{BAMBU_WIKI_BASE}{wiki_path}"
+            response = await self._client.get(url, follow_redirects=True)
+
+            if response.status_code == 200:
+                # Extract version strings (format: XX.XX.XX.XX), first match is the latest
+                versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
+                if versions:
+                    logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
+                    return versions[0]
+            else:
+                logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
+
+        except Exception as e:
+            logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
+
+        return None
+
+    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch firmware info from Bambu Lab's download page (has download URLs)."""
         build_id = await self._get_build_id()
         if not build_id:
-            logger.warning("No build ID available, cannot fetch firmware versions")
             return None
 
         try:
@@ -135,14 +176,37 @@ class FirmwareCheckService:
                         release_notes=latest.get("release_notes_en"),
                         release_time=latest.get("release_time"),
                     )
-            else:
-                # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
-                logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
 
         except Exception as e:
-            # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
-            logger.error("Error fetching firmware for %s: %s", api_key, e)
+            logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
+
+        return None
 
+    async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch firmware version info, using wiki as primary source and download page as fallback."""
+        # Try wiki first (always has the latest version)
+        wiki_version = await self._fetch_version_from_wiki(api_key)
+
+        # Try download page (has download URLs, may lag behind wiki)
+        download_info = await self._fetch_from_download_page(api_key)
+
+        if wiki_version:
+            # Wiki has the latest version — use it, attach download URL if available
+            download_url = ""
+            release_notes = None
+            if download_info and download_info.version == wiki_version:
+                download_url = download_info.download_url
+                release_notes = download_info.release_notes
+            return FirmwareVersion(
+                version=wiki_version,
+                download_url=download_url,
+                release_notes=release_notes,
+            )
+
+        if download_info:
+            return download_info
+
+        logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
         return None
 
     async def get_latest_version(self, model: str) -> FirmwareVersion | None:
@@ -260,14 +324,6 @@ class FirmwareCheckService:
         cache_dir.mkdir(parents=True, exist_ok=True)
         return cache_dir
 
-    def _get_cached_firmware_path(self, model: str, version: str) -> Path:
-        """Get the path where a firmware file would be cached."""
-        # Normalize model name for filename
-        model_safe = model.upper().replace(" ", "-").replace("/", "-")
-        version_safe = version.replace(".", "_")
-        filename = f"{model_safe}_{version_safe}.bin"
-        return self._get_firmware_cache_dir() / filename
-
     async def get_firmware_file_info(self, model: str) -> dict | None:
         """
         Get information about the firmware file for a model.
@@ -310,16 +366,16 @@ class FirmwareCheckService:
             logger.warning("No firmware download URL available for model: %s", model)
             return None
 
-        # Check if already cached
-        cached_path = self._get_cached_firmware_path(model, latest.version)
-        if cached_path.exists():
-            logger.info("Using cached firmware: %s", cached_path)
-            return cached_path
-
         # Extract original filename from URL (must preserve for SD card update)
         url_parts = latest.download_url.split("/")
         original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
 
+        # Check if already cached (using original filename so SD card gets the right name)
+        cached_path = self._get_firmware_cache_dir() / original_filename
+        if cached_path.exists():
+            logger.info("Using cached firmware: %s", cached_path)
+            return cached_path
+
         # Download to temp file first
         temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
 
@@ -343,22 +399,14 @@ class FirmwareCheckService:
                         if progress_callback:
                             progress_callback(downloaded, total_size, "Downloading firmware...")
 
-            # Also save a copy with the original filename for SD card
-            original_path = self._get_firmware_cache_dir() / original_filename
-            if original_path.exists():
-                original_path.unlink()
-
-            # Move temp to both cached path and original filename path
-            import shutil
+            # Move temp to final path, preserving original filename
+            temp_path.rename(cached_path)
 
-            shutil.copy2(temp_path, cached_path)
-            temp_path.rename(original_path)
-
-            logger.info("Firmware downloaded successfully: %s", original_path)
+            logger.info("Firmware downloaded successfully: %s", cached_path)
             if progress_callback:
                 progress_callback(downloaded, total_size, "Download complete")
 
-            return original_path
+            return cached_path
 
         except Exception as e:
             logger.error("Firmware download failed: %s", e)

+ 7 - 2
backend/app/services/mqtt_relay.py

@@ -36,6 +36,7 @@ class MQTTRelayService:
         self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
         self._smart_plug_service = None  # Lazy import to avoid circular dependency
         self._settings: dict = {}  # Store settings for smart plug service
+        self._disconnection_event: threading.Event | None = None
 
     async def configure(self, settings: dict) -> bool:
         """Configure MQTT connection from settings.
@@ -187,15 +188,19 @@ class MQTTRelayService:
             logger.warning("MQTT relay disconnected: %s", rc)
         else:
             logger.info("MQTT relay disconnected cleanly")
+        if self._disconnection_event:
+            self._disconnection_event.set()
 
-    async def disconnect(self):
+    async def disconnect(self, timeout: float = 0):
         """Disconnect from MQTT broker."""
         if self.client:
             try:
                 # Publish offline status before disconnecting
                 self._publish_status("offline")
-                self.client.loop_stop()
+                self._disconnection_event = threading.Event()
                 self.client.disconnect()
+                await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
+                self.client.loop_stop()
             except Exception as e:
                 logger.debug("MQTT disconnect error (ignored): %s", e)
             finally:

+ 8 - 2
backend/app/services/mqtt_smart_plug.py

@@ -3,6 +3,7 @@
 This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
 """
 
+import asyncio
 import json
 import logging
 import threading
@@ -52,6 +53,7 @@ class MQTTSmartPlugService:
         self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
         # plug_id -> latest data
         self.plug_data: dict[int, SmartPlugMQTTData] = {}
+        self._disconnection_event: threading.Event | None = None
         self._configured = False
         self._broker = ""
         self._port = 1883
@@ -209,6 +211,8 @@ class MQTTSmartPlugService:
             logger.warning("MQTT smart plug service disconnected: %s", rc)
         else:
             logger.info("MQTT smart plug service disconnected cleanly")
+        if self._disconnection_event:
+            self._disconnection_event.set()
 
     def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
         """Handle incoming MQTT message, extract data using JSON path."""
@@ -471,12 +475,14 @@ class MQTTSmartPlugService:
         timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
         return datetime.utcnow() - data.last_seen < timeout
 
-    async def disconnect(self):
+    async def disconnect(self, timeout: float = 0):
         """Disconnect from MQTT broker."""
         if self.client:
             try:
-                self.client.loop_stop()
+                self._disconnection_event = threading.Event()
                 self.client.disconnect()
+                await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
+                self.client.loop_stop()
             except Exception as e:
                 logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
             finally:

+ 79 - 19
backend/app/services/notification_service.py

@@ -267,19 +267,20 @@ class NotificationService:
 
         url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
 
-        # Check if message contains characters that break Markdown parsing
-        # URLs and error codes with underscores cause issues
-        has_url = "http://" in message or "https://" in message
-        # Check for underscores outside of the bold title (odd number of _ breaks markdown)
-        body_part = message.split("\n", 1)[1] if "\n" in message else ""
-        has_problematic_underscore = "_" in body_part
+        # Escape underscores in the message body so Telegram Markdown
+        # parsing doesn't break on job names like "A1_plate_8" or error
+        # codes like "0300_0001".  The title is already wrapped in *bold*
+        # markers, so only escape after the first newline.
+        if "\n" in message:
+            title_part, body_part = message.split("\n", 1)
+            body_part = body_part.replace("_", "\\_")
+            message = f"{title_part}\n{body_part}"
 
         data = {
             "chat_id": chat_id,
             "text": message,
+            "parse_mode": "Markdown",
         }
-        if not has_url and not has_problematic_underscore:
-            data["parse_mode"] = "Markdown"
 
         client = await self._get_client()
         response = await client.post(url, json=data)
@@ -344,7 +345,9 @@ class NotificationService:
         except Exception as e:
             return False, f"Email error: {str(e)}"
 
-    async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+    async def _send_discord(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
         """Send notification via Discord webhook."""
         webhook_url = config.get("webhook_url", "").strip()
 
@@ -355,18 +358,25 @@ class NotificationService:
             return False, "Invalid Discord webhook URL"
 
         # Discord embed format for nicer messages
-        data = {
-            "embeds": [
-                {
-                    "title": title,
-                    "description": message,
-                    "color": 0x00AE42,  # Bambu green
-                }
-            ]
+        embed = {
+            "title": title,
+            "description": message,
+            "color": 0x00AE42,  # Bambu green
         }
 
         client = await self._get_client()
-        response = await client.post(webhook_url, json=data)
+
+        if image_data:
+            # Attach image via multipart form-data and reference in embed
+            embed["image"] = {"url": "attachment://photo.jpg"}
+            payload = {"embeds": [embed]}
+            response = await client.post(
+                webhook_url,
+                data={"payload_json": json.dumps(payload)},
+                files={"files[0]": ("photo.jpg", image_data, "image/jpeg")},
+            )
+        else:
+            response = await client.post(webhook_url, json={"embeds": [embed]})
 
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
@@ -449,7 +459,7 @@ class NotificationService:
             elif provider.provider_type == "email":
                 return await self._send_email(config, title, message)
             elif provider.provider_type == "discord":
-                return await self._send_discord(config, title, message)
+                return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
             else:
@@ -718,6 +728,32 @@ class NotificationService:
             if archive_data.get("finish_photo_url"):
                 variables["finish_photo_url"] = archive_data["finish_photo_url"]
 
+            # Build per-slot breakdown string with AMS info when available
+            if archive_data.get("usage_results"):
+                parts = []
+                for u in archive_data["usage_results"]:
+                    ams_id = u.get("ams_id", 0)
+                    tray_id = u.get("tray_id", 0)
+                    material = u.get("material", "Unknown") or "Unknown"
+                    used = u.get("weight_used", 0)
+                    if ams_id >= 128:
+                        slot_label = "Ext"
+                    else:
+                        slot_label = f"AMS-{chr(65 + ams_id)} T{tray_id + 1}"
+                    parts.append(f"{slot_label} {material}: {used:.1f}g")
+                variables["filament_details"] = " | ".join(parts)
+            elif archive_data.get("filament_slots"):
+                parts = []
+                for slot in archive_data["filament_slots"]:
+                    ftype = slot.get("type", "Unknown") or "Unknown"
+                    used = slot.get("used_g", 0)
+                    parts.append(f"{ftype}: {used:.1f}g")
+                variables["filament_details"] = " | ".join(parts)
+
+            # Add progress for partial prints
+            if archive_data.get("progress") is not None:
+                variables["progress"] = str(archive_data["progress"])
+
         # Extract image data for providers that support attachments (e.g. Pushover)
         image_data = None
         if archive_data:
@@ -980,6 +1016,30 @@ class NotificationService:
             providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True
         )
 
+    async def on_bed_cooled(
+        self,
+        printer_id: int,
+        printer_name: str,
+        bed_temp: float,
+        threshold: float,
+        filename: str,
+        db: AsyncSession,
+    ):
+        """Handle bed cooled event - bed temperature dropped below threshold after print."""
+        providers = await self._get_providers_for_event(db, "on_bed_cooled", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "bed_temp": f"{bed_temp:.0f}",
+            "threshold": f"{threshold:.0f}",
+            "filename": self._clean_filename(filename) if filename else "Unknown",
+        }
+
+        title, message = await self._build_message_from_template(db, "bed_cooled", variables)
+        await self._send_to_providers(providers, title, message, db, "bed_cooled", printer_id, printer_name)
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()

+ 52 - 0
backend/app/services/print_log.py

@@ -0,0 +1,52 @@
+"""Service for writing independent print log entries.
+
+Log entries are written to a separate table and never touch archives or queue items.
+"""
+
+import logging
+from datetime import datetime
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.print_log import PrintLogEntry
+
+logger = logging.getLogger(__name__)
+
+
+async def write_log_entry(
+    db: AsyncSession,
+    *,
+    status: str,
+    print_name: str | None = None,
+    printer_name: str | None = None,
+    printer_id: int | None = None,
+    started_at: datetime | None = None,
+    completed_at: datetime | None = None,
+    filament_type: str | None = None,
+    filament_color: str | None = None,
+    filament_used_grams: float | None = None,
+    thumbnail_path: str | None = None,
+    created_by_username: str | None = None,
+) -> PrintLogEntry:
+    """Write a print log entry."""
+    duration = None
+    if started_at and completed_at:
+        duration = int((completed_at - started_at).total_seconds())
+
+    entry = PrintLogEntry(
+        print_name=print_name,
+        printer_name=printer_name,
+        printer_id=printer_id,
+        status=status,
+        started_at=started_at,
+        completed_at=completed_at,
+        duration_seconds=duration,
+        filament_type=filament_type,
+        filament_color=filament_color,
+        filament_used_grams=filament_used_grams,
+        thumbnail_path=thumbnail_path,
+        created_by_username=created_by_username,
+    )
+    db.add(entry)
+    await db.flush()
+    return entry

+ 54 - 45
backend/app/services/print_scheduler.py

@@ -4,7 +4,7 @@ import asyncio
 import json
 import logging
 import zipfile
-from datetime import datetime, timedelta
+from datetime import datetime
 from pathlib import Path
 
 import defusedxml.ElementTree as ET
@@ -23,6 +23,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -68,6 +69,12 @@ class PrintScheduler:
             if not items:
                 return
 
+            logger.info(
+                "Queue check: found %d pending items: %s",
+                len(items),
+                [(i.id, i.printer_id, i.archive_id, i.library_file_id) for i in items],
+            )
+
             # Track busy printers to avoid assigning multiple items to same printer
             busy_printers: set[int] = set()
 
@@ -332,10 +339,9 @@ class PrintScheduler:
                     if tray_type:
                         loaded_types.add(tray_type.upper())
 
-        # Check external spool (virtual tray, stored in raw_data["vt_tray"])
-        vt_tray = status.raw_data.get("vt_tray")
-        if vt_tray:
-            vt_type = vt_tray.get("tray_type")
+        # Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
             if vt_type:
                 loaded_types.add(vt_type.upper())
 
@@ -477,6 +483,12 @@ class PrintScheduler:
                             pass  # Skip filament entry with unparseable usage data
 
                 filaments.sort(key=lambda x: x["slot_id"])
+
+                # Enrich with nozzle mapping for dual-nozzle printers
+                nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+                if nozzle_mapping:
+                    for filament in filaments:
+                        filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
         except Exception as e:
             logger.warning("Failed to parse filament requirements: %s", e)
             return None
@@ -494,6 +506,9 @@ class PrintScheduler:
         """
         filaments = []
 
+        # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
+        ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
@@ -524,25 +539,28 @@ class PrintScheduler:
                             "is_ht": is_ht,
                             "is_external": False,
                             "global_tray_id": global_tray_id,
+                            "extruder_id": ams_extruder_map.get(str(ams_id)),
                         }
                     )
 
-        # Check external spool (vt_tray)
-        vt_tray = status.raw_data.get("vt_tray")
-        if vt_tray and vt_tray.get("tray_type"):
-            color = self._normalize_color(vt_tray.get("tray_color", ""))
-            filaments.append(
-                {
-                    "type": vt_tray["tray_type"],
-                    "color": color,
-                    "tray_info_idx": vt_tray.get("tray_info_idx", ""),
-                    "ams_id": -1,
-                    "tray_id": 0,
-                    "is_ht": False,
-                    "is_external": True,
-                    "global_tray_id": 254,
-                }
-            )
+        # Check external spool(s) (vt_tray is a list)
+        for idx, vt in enumerate(status.raw_data.get("vt_tray") or []):
+            if vt.get("tray_type"):
+                color = self._normalize_color(vt.get("tray_color", ""))
+                tray_id = int(vt.get("id", 254))
+                filaments.append(
+                    {
+                        "type": vt["tray_type"],
+                        "color": color,
+                        "tray_info_idx": vt.get("tray_info_idx", ""),
+                        "ams_id": -1,
+                        "tray_id": idx,
+                        "is_ht": False,
+                        "is_external": True,
+                        "global_tray_id": tray_id,
+                        "extruder_id": (tray_id - 254) if ams_extruder_map else None,
+                    }
+                )
 
         return filaments
 
@@ -616,6 +634,13 @@ 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
+            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
+
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
                 idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
@@ -694,9 +719,11 @@ class PrintScheduler:
         if not state:
             return False
 
-        # Printer is idle if state is IDLE, FINISH, FAILED, or unknown
-        # FAILED means previous print failed, printer is ready for new print
-        return state.state in ("IDLE", "FINISH", "FAILED", "unknown")
+        # IDLE = ready for next print
+        # FINISH/FAILED = ready only if user confirmed plate is cleared
+        return state.state == "IDLE" or (
+            state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
+        )
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
@@ -860,27 +887,6 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 return
 
-            # Safety: Check if this archive was printed recently (within 4 hours)
-            # This prevents phantom reprints if a queue item got stuck in "pending"
-            # after its print already started due to a crash/restart
-            if archive.status == "completed" and archive.completed_at:
-                completed_at = (
-                    archive.completed_at.replace(tzinfo=None) if archive.completed_at.tzinfo else archive.completed_at
-                )
-                time_since_completed = datetime.utcnow() - completed_at
-                if time_since_completed < timedelta(hours=4):
-                    logger.warning(
-                        f"Queue item {item.id}: Archive {item.archive_id} was already printed "
-                        f"{time_since_completed.total_seconds() / 3600:.1f} hours ago, skipping to prevent duplicate"
-                    )
-                    item.status = "skipped"
-                    item.error_message = (
-                        f"Archive was already printed {time_since_completed.total_seconds() / 3600:.1f} hours ago"
-                    )
-                    item.completed_at = datetime.utcnow()
-                    await db.commit()
-                    return
-
             file_path = settings.base_dir / archive.file_path
             filename = archive.filename
 
@@ -1034,6 +1040,9 @@ class PrintScheduler:
         item.status = "printing"
         item.started_at = datetime.utcnow()
         await db.commit()
+
+        # Consume the plate-cleared flag now that we're starting a print
+        printer_manager.consume_plate_cleared(item.printer_id)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
         # Start the print with AMS mapping, plate_id and print options

+ 67 - 37
backend/app/services/printer_manager.py

@@ -1,4 +1,6 @@
 import asyncio
+import logging
+import traceback
 from collections.abc import Callable
 
 from sqlalchemy import select
@@ -7,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.models.printer import Printer
 from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name
 
+logger = logging.getLogger(__name__)
+
 # Models that have a real chamber temperature sensor
 # Based on Home Assistant Bambu Lab integration
 # P1P/P1S and A1/A1Mini do NOT have chamber temp sensors
@@ -100,6 +104,8 @@ class PrinterManager:
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
+        # Track plate-cleared acknowledgments for queue flow
+        self._plate_cleared: set[int] = set()  # printer_ids where user confirmed plate is cleared
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
@@ -117,6 +123,18 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         self._current_print_user.pop(printer_id, None)
 
+    def set_plate_cleared(self, printer_id: int):
+        """Mark that user has cleared the build plate for this printer."""
+        self._plate_cleared.add(printer_id)
+
+    def is_plate_cleared(self, printer_id: int) -> bool:
+        """Check if user has confirmed the plate is cleared."""
+        return printer_id in self._plate_cleared
+
+    def consume_plate_cleared(self, printer_id: int):
+        """Clear the plate-cleared flag (called when scheduler starts next print)."""
+        self._plate_cleared.discard(printer_id)
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
         self._loop = loop
@@ -209,18 +227,18 @@ class PrinterManager:
         await asyncio.sleep(1)
         return client.state.connected
 
-    def disconnect_printer(self, printer_id: int):
+    def disconnect_printer(self, printer_id: int, timeout: float = 0):
         """Disconnect from a printer."""
         if printer_id in self._clients:
-            self._clients[printer_id].disconnect()
+            self._clients[printer_id].disconnect(timeout=timeout)
             del self._clients[printer_id]
         self._models.pop(printer_id, None)  # Clean up model cache
         self._printer_info.pop(printer_id, None)  # Clean up printer info cache
 
-    def disconnect_all(self):
+    def disconnect_all(self, timeout: float = 0):
         """Disconnect from all printers."""
         for printer_id in list(self._clients.keys()):
-            self.disconnect_printer(printer_id)
+            self.disconnect_printer(printer_id, timeout=timeout)
 
     def get_status(self, printer_id: int) -> PrinterState | None:
         """Get the current status of a printer (checks for stale connections)."""
@@ -290,6 +308,15 @@ class PrinterManager:
         use_ams: bool = True,
     ) -> bool:
         """Start a print on a connected printer."""
+        caller = traceback.extract_stack(limit=3)[0]
+        logger.info(
+            "PRINT COMMAND: printer=%s, file=%s, caller=%s:%s:%s",
+            printer_id,
+            filename,
+            caller.filename.split("/")[-1],
+            caller.lineno,
+            caller.name,
+        )
         if printer_id in self._clients:
             return self._clients[printer_id].start_print(
                 filename,
@@ -488,7 +515,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
     """
     # Parse AMS data from raw_data
     ams_units = []
-    vt_tray = None
+    vt_tray = []
     raw_data = state.raw_data or {}
 
     # Build K-profile lookup map: cali_idx -> k_value
@@ -519,7 +546,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
 
                 trays.append(
                     {
-                        "id": tray.get("id", 0),
+                        "id": int(tray.get("id", 0)),
                         "tray_color": tray.get("tray_color"),
                         "tray_type": tray.get("tray_type"),
                         "tray_sub_brands": tray.get("tray_sub_brands"),
@@ -556,7 +583,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
 
             ams_units.append(
                 {
-                    "id": ams_data.get("id", 0),
+                    "id": int(ams_data.get("id", 0)),
                     "humidity": humidity_value,
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
@@ -564,37 +591,40 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                 }
             )
 
-    # Parse virtual tray (external spool)
+    # Parse virtual tray (external spool) — now a list
     if "vt_tray" in raw_data:
-        vt_data = raw_data["vt_tray"]
-        vt_tag_uid = vt_data.get("tag_uid")
-        if vt_tag_uid in ("", "0000000000000000"):
-            vt_tag_uid = None
-        vt_tray_uuid = vt_data.get("tray_uuid")
-        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-            vt_tray_uuid = None
-
-        # Get K value for vt_tray
-        vt_k_value = vt_data.get("k")
-        vt_cali_idx = vt_data.get("cali_idx")
-        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-            vt_k_value = kprofile_map[vt_cali_idx]
-
-        vt_tray = {
-            "id": 254,
-            "tray_color": vt_data.get("tray_color"),
-            "tray_type": vt_data.get("tray_type"),
-            "tray_sub_brands": vt_data.get("tray_sub_brands"),
-            "tray_id_name": vt_data.get("tray_id_name"),
-            "tray_info_idx": vt_data.get("tray_info_idx"),
-            "remain": vt_data.get("remain", 0),
-            "k": vt_k_value,
-            "cali_idx": vt_cali_idx,
-            "tag_uid": vt_tag_uid,
-            "tray_uuid": vt_tray_uuid,
-            "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
-            "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
-        }
+        for vt_data in raw_data["vt_tray"]:
+            vt_tag_uid = vt_data.get("tag_uid")
+            if vt_tag_uid in ("", "0000000000000000"):
+                vt_tag_uid = None
+            vt_tray_uuid = vt_data.get("tray_uuid")
+            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+                vt_tray_uuid = None
+
+            # Get K value for vt_tray
+            vt_k_value = vt_data.get("k")
+            vt_cali_idx = vt_data.get("cali_idx")
+            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+                vt_k_value = kprofile_map[vt_cali_idx]
+
+            tray_id = int(vt_data.get("id", 254))
+            vt_tray.append(
+                {
+                    "id": tray_id,
+                    "tray_color": vt_data.get("tray_color"),
+                    "tray_type": vt_data.get("tray_type"),
+                    "tray_sub_brands": vt_data.get("tray_sub_brands"),
+                    "tray_id_name": vt_data.get("tray_id_name"),
+                    "tray_info_idx": vt_data.get("tray_info_idx"),
+                    "remain": vt_data.get("remain", 0),
+                    "k": vt_k_value,
+                    "cali_idx": vt_cali_idx,
+                    "tag_uid": vt_tag_uid,
+                    "tray_uuid": vt_tray_uuid,
+                    "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
+                    "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
+                }
+            )
 
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
     ams_extruder_map = raw_data.get("ams_extruder_map", {})

+ 310 - 0
backend/app/services/spool_tag_matcher.py

@@ -0,0 +1,310 @@
+"""RFID tag matching and auto-assignment for spool inventory."""
+
+import logging
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+
+logger = logging.getLogger(__name__)
+
+# Zero-value constants for tag validation
+ZERO_TAG_UID = "0000000000000000"
+ZERO_TRAY_UUID = "00000000000000000000000000000000"
+
+
+def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
+    """Check if a tag/UUID pair contains a non-zero, non-empty value."""
+    uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid)
+    uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
+    return uid_valid or uuid_valid
+
+
+def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
+    """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
+    uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
+    has_preset = bool(tray_info_idx)
+    return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
+
+
+async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
+    """Create a new Spool inventory entry from AMS tray MQTT data.
+
+    Extracts material, subtype, color, temps, and tag info from the tray dict.
+    Looks up core_weight from the spool catalog if a Bambu Lab entry matches.
+    """
+    from backend.app.models.color_catalog import ColorCatalogEntry
+    from backend.app.models.spool_catalog import SpoolCatalogEntry
+
+    tray_type = tray_data.get("tray_type", "")  # "PLA"
+    tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
+    tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
+    tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
+    tag_uid = tray_data.get("tag_uid", "")
+    tray_uuid = tray_data.get("tray_uuid", "")
+    tray_info_idx = tray_data.get("tray_info_idx", "")
+    nozzle_min = tray_data.get("nozzle_temp_min", 0)
+    nozzle_max = tray_data.get("nozzle_temp_max", 0)
+    label_weight = int(tray_data.get("tray_weight", 1000))
+
+    # Parse material and subtype from tray_sub_brands ("PLA Basic" → material="PLA", subtype="Basic")
+    material = tray_type or "PLA"
+    subtype = None
+    if tray_sub_brands and " " in tray_sub_brands:
+        parts = tray_sub_brands.split(" ", 1)
+        if parts[0].upper() == material.upper():
+            subtype = parts[1]
+        else:
+            # tray_sub_brands is the full material name (e.g. "PETG-HF")
+            material = tray_sub_brands
+    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
+        material = tray_sub_brands
+
+    # Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
+    from backend.app.core.bambu_colors import resolve_bambu_color_name
+
+    rgba = tray_color if tray_color else None
+    color_name = None
+
+    # 1. Try Bambu color code mapping (e.g. "A06-D0" → "Titan Gray")
+    if tray_id_name:
+        color_name = resolve_bambu_color_name(tray_id_name)
+        logger.info("Color resolve: tray_id_name=%r → resolved=%r", tray_id_name, color_name)
+        # If not a known code, use tray_id_name directly (it may be a readable name)
+        if not color_name and "-" not in tray_id_name:
+            color_name = tray_id_name
+    else:
+        logger.info("Color resolve: tray_id_name is empty, rgba=%r", rgba)
+
+    # 2. Try color catalog lookup by hex color
+    if not color_name and rgba and len(rgba) >= 6:
+        hex_prefix = f"#{rgba[:6].upper()}"
+        cat_result = await db.execute(
+            select(ColorCatalogEntry)
+            .where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
+            .where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB")
+            .limit(1)
+        )
+        entry = cat_result.scalar_one_or_none()
+        if entry:
+            color_name = entry.color_name
+
+    # Look up core weight from spool catalog
+    core_weight = 250  # Default for Bambu Lab plastic spools
+    cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
+    for entry in cat_result.scalars().all():
+        # Pick the best match (prefer exact, fallback to first Bambu Lab entry)
+        core_weight = entry.weight
+        break
+
+    # Resolve slicer filament name from builtin table
+    slicer_filament_name = None
+    if tray_info_idx:
+        try:
+            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+            slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
+        except Exception:
+            pass
+        # Fallback: use tray_sub_brands as the display name
+        if not slicer_filament_name and tray_sub_brands:
+            slicer_filament_name = tray_sub_brands
+
+    # Calculate initial weight_used from AMS remain percentage
+    remain_raw = tray_data.get("remain")
+    try:
+        remain_pct = int(remain_raw) if remain_raw is not None else 100
+    except (TypeError, ValueError):
+        remain_pct = 100
+    # Clamp to valid range: negative means unknown, >100 is invalid
+    if remain_pct < 0 or remain_pct > 100:
+        remain_pct = 100  # Unknown → assume full
+    weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)
+
+    spool = Spool(
+        material=material,
+        subtype=subtype,
+        color_name=color_name,
+        rgba=rgba,
+        brand="Bambu Lab",
+        label_weight=label_weight,
+        core_weight=core_weight,
+        weight_used=weight_used,
+        slicer_filament=tray_info_idx or None,
+        slicer_filament_name=slicer_filament_name,
+        nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
+        nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
+        tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,
+        tray_uuid=tray_uuid if tray_uuid and tray_uuid != ZERO_TRAY_UUID else None,
+        data_origin="rfid_auto",
+        tag_type="bambulab",
+    )
+    db.add(spool)
+    await db.flush()
+
+    logger.info(
+        "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
+        spool.id,
+        material,
+        subtype or "",
+        color_name or "",
+        tag_uid,
+        tray_uuid,
+    )
+    return spool
+
+
+async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
+    """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
+
+    Prefers tray_uuid match over tag_uid (more reliable).
+    """
+    # Try tray_uuid first (Bambu Lab spools — more reliable)
+    if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid):
+        result = await db.execute(
+            select(Spool)
+            .options(selectinload(Spool.k_profiles))
+            .where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None))
+            .limit(1)
+        )
+        spool = result.scalar_one_or_none()
+        if spool:
+            return spool
+
+    # Fall back to tag_uid
+    if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid):
+        result = await db.execute(
+            select(Spool)
+            .options(selectinload(Spool.k_profiles))
+            .where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None))
+            .limit(1)
+        )
+        spool = result.scalar_one_or_none()
+        if spool:
+            return spool
+
+    return None
+
+
+async def auto_assign_spool(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    spool: Spool,
+    printer_manager,
+    db: AsyncSession,
+    tray_info_idx: str = "",
+) -> SpoolAssignment:
+    """Create a SpoolAssignment and auto-configure the AMS slot via MQTT.
+
+    For BL spools (RFID-detected), only K-profile commands are sent.
+    ams_set_filament_setting is NOT sent because the firmware already has
+    filament configuration from the RFID tag, and sending it would destroy
+    the RFID-detected state (eye → pen icon in BambuStudio).
+    """
+    # Get current tray state for fingerprint
+    fingerprint_color = None
+    fingerprint_type = None
+    tray = None
+    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
+
+        ams = state.raw_data.get("ams", [])
+        if isinstance(ams, dict):
+            ams = ams.get("ams", [])
+        tray = _find_tray_in_ams_data(
+            ams,
+            ams_id,
+            tray_id,
+        )
+        if tray:
+            fingerprint_color = tray.get("tray_color", "")
+            fingerprint_type = tray.get("tray_type", "")
+
+    # Upsert: remove old assignment for this slot
+    existing = await db.execute(
+        select(SpoolAssignment).where(
+            SpoolAssignment.printer_id == printer_id,
+            SpoolAssignment.ams_id == ams_id,
+            SpoolAssignment.tray_id == tray_id,
+        )
+    )
+    old = existing.scalar_one_or_none()
+    if old:
+        await db.delete(old)
+        await db.flush()
+
+    assignment = SpoolAssignment(
+        spool_id=spool.id,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        fingerprint_color=fingerprint_color,
+        fingerprint_type=fingerprint_type,
+    )
+    db.add(assignment)
+    await db.flush()
+
+    # Apply K-profile via MQTT (if available)
+    # NOTE: Do NOT send ams_set_filament_setting here. This function is only
+    # called for BL spools (RFID-detected). The firmware already has the filament
+    # configuration from the RFID tag. Sending ams_set_filament_setting would
+    # destroy the RFID-detected state (eye → pen icon in BambuStudio/OrcaSlicer).
+    try:
+        client = printer_manager.get_client(printer_id)
+        if client:
+            # Apply K-profile if available
+            nozzle_diameter = "0.4"
+            if state and state.nozzles:
+                nd = state.nozzles[0].nozzle_diameter
+                if nd:
+                    nozzle_diameter = nd
+
+            matching_kp = None
+            for kp in spool.k_profiles:
+                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
+                    matching_kp = kp
+                    break
+
+            if matching_kp and matching_kp.cali_idx is not None:
+                # The filament_id in extrusion_cali_sel must match the filament preset
+                # under which the K-profile was calibrated. Use spool.slicer_filament
+                # (the preset assigned in inventory), falling back to tray's RFID value.
+                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                client.extrusion_cali_sel(
+                    ams_id=ams_id,
+                    tray_id=tray_id,
+                    cali_idx=matching_kp.cali_idx,
+                    filament_id=cali_filament_id,
+                    nozzle_diameter=nozzle_diameter,
+                )
+
+                # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
+                # selected the correct profile by cali_idx. Sending extrusion_cali_set
+                # with the same cali_idx would MODIFY the existing profile's metadata
+                # (extruder_id, nozzle_id, name), corrupting it.
+
+                logger.info(
+                    "Applied K-profile cali_idx=%d for spool %d on printer %d AMS%d-T%d",
+                    matching_kp.cali_idx,
+                    spool.id,
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                )
+
+            logger.info(
+                "Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)",
+                spool.id,
+                printer_id,
+                ams_id,
+                tray_id,
+            )
+    except Exception as e:
+        logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
+
+    return assignment

+ 108 - 55
backend/app/services/spoolman.py

@@ -468,6 +468,26 @@ class SpoolmanClient:
                         return spool
         return None
 
+    def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
+        """Find a spool by exact location match.
+
+        Used as fallback when RFID tag data is unavailable (e.g., newer firmware
+        that doesn't expose tray_uuid/tag_uid via MQTT).
+
+        Args:
+            location: Exact location string (e.g., "H2D-1 - AMS A1")
+            cached_spools: Pre-fetched list of spools to search
+
+        Returns:
+            Spool dictionary or None if not found.
+        """
+        if not cached_spools:
+            return None
+        for spool in cached_spools:
+            if spool.get("location") == location:
+                return spool
+        return None
+
     async def find_spools_by_location_prefix(
         self, location_prefix: str, cached_spools: list[dict] | None = None
     ) -> list[dict]:
@@ -494,17 +514,21 @@ class SpoolmanClient:
         printer_name: str,
         current_tray_uuids: set[str],
         cached_spools: list[dict] | None = None,
+        synced_spool_ids: set[int] | None = None,
     ) -> int:
         """Clear location for spools that are no longer in the AMS.
 
         When a spool is removed from the AMS, its location should be cleared
         in Spoolman. This method finds all spools with locations for this printer
-        and clears the location for any that are not in the current_tray_uuids set.
+        and clears the location for any that are not in the current_tray_uuids set
+        and were not synced in this cycle (synced_spool_ids).
 
         Args:
             printer_name: The printer name used as location prefix
             current_tray_uuids: Set of tray_uuids currently in the AMS
             cached_spools: Optional pre-fetched list of spools to search (avoids API call)
+            synced_spool_ids: Set of spool IDs that were synced in this cycle
+                (protects location-matched spools when RFID data is unavailable)
 
         Returns:
             Number of spools whose location was cleared.
@@ -514,6 +538,12 @@ class SpoolmanClient:
         cleared_count = 0
 
         for spool in spools_at_printer:
+            spool_id = spool.get("id")
+
+            # Skip spools that were just synced (matched by location or tag)
+            if synced_spool_ids and spool_id in synced_spool_ids:
+                continue
+
             # Get the tray_uuid (stored as "tag" in extra field)
             extra = spool.get("extra", {}) or {}
             stored_tag = extra.get("tag", "")
@@ -526,10 +556,10 @@ class SpoolmanClient:
             # If this spool's UUID is not in the current AMS, clear its location
             if spool_uuid not in current_tray_uuids:
                 logger.info(
-                    f"Clearing location for spool {spool['id']} "
+                    f"Clearing location for spool {spool_id} "
                     f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
                 )
-                result = await self.update_spool(spool_id=spool["id"], clear_location=True)
+                result = await self.update_spool(spool_id=spool_id, clear_location=True)
                 if result:
                     cleared_count += 1
 
@@ -628,8 +658,8 @@ class SpoolmanClient:
         # Get tray_info_idx (Bambu filament preset ID like "GFA00")
         tray_info_idx = tray_data.get("tray_info_idx", "") or ""
 
-        # Get remaining percentage, ensure non-negative
-        remain = max(0, int(tray_data.get("remain", 0)))
+        # Get remaining percentage (-1 means unknown/not read by AMS)
+        remain = int(tray_data.get("remain", -1))
 
         return AMSTray(
             ams_id=ams_id,
@@ -663,31 +693,22 @@ class SpoolmanClient:
     def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
         """Check if a tray has a valid Bambu Lab spool.
 
-        Bambu Lab spools can be identified by:
+        Bambu Lab spools are identified by hardware RFID identifiers only:
         1. tray_uuid: 32-character hex string (preferred, consistent across printers)
         2. tag_uid: 16-character hex string (RFID tag, varies between readers)
-        3. tray_info_idx: Bambu filament preset ID like "GFA00" (most reliable)
 
-        Non-Bambu Lab spools (SpoolEase, third-party) won't have these identifiers.
+        Note: tray_info_idx (e.g. "GFA00") is NOT a reliable indicator — third-party
+        spools using Bambu generic presets also have GF-prefixed tray_info_idx values.
+        The tray_info_idx parameter is kept for API compatibility but ignored.
 
         Args:
             tray_uuid: The tray UUID to check (32 hex chars)
             tag_uid: The RFID tag UID to check as fallback (16 hex chars)
-            tray_info_idx: Bambu filament preset ID like "GFA00", "GFB00"
+            tray_info_idx: Ignored (kept for API compatibility)
 
         Returns:
-            True if the spool has valid Bambu Lab identifiers, False otherwise.
+            True if the spool has valid Bambu Lab RFID identifiers, False otherwise.
         """
-        # Check tray_info_idx first - Bambu filament preset IDs like "GFA00", "GFB00", etc.
-        # This is the most reliable indicator as it's set when the spool is recognized
-        if tray_info_idx:
-            idx = tray_info_idx.strip()
-            # Bambu Lab preset IDs start with "GF" followed by letter and digits
-            # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
-            if idx and len(idx) >= 3 and idx.startswith("GF"):
-                logger.debug("Identified Bambu Lab spool via tray_info_idx: %s", idx)
-                return True
-
         # Check tray_uuid (preferred - consistent across printer models)
         if tray_uuid:
             uuid = tray_uuid.strip()
@@ -730,6 +751,7 @@ class SpoolmanClient:
         printer_name: str,
         disable_weight_sync: bool = False,
         cached_spools: list[dict] | None = None,
+        inventory_remaining: float | None = None,
     ) -> dict | None:
         """Sync a single AMS tray to Spoolman.
 
@@ -747,6 +769,8 @@ class SpoolmanClient:
             cached_spools: Optional pre-fetched list of spools to search (avoids API calls).
                 When provided, this cache is passed to find_spool_by_tag to avoid redundant
                 API calls during batch sync operations.
+            inventory_remaining: Optional fallback remaining weight (grams) from the built-in
+                inventory when AMS MQTT data has invalid remain/tray_weight values.
 
         Returns:
             Synced spool dictionary or None if skipped or failed.
@@ -770,53 +794,82 @@ class SpoolmanClient:
             return None
 
         # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
-        spool_tag = (
-            tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid
-        )
-
-        # If no unique identifier available, we can't sync even if it's a Bambu Lab spool
-        if not spool_tag:
-            logger.warning(
-                f"Bambu Lab spool detected but no unique identifier for Spoolman: "
-                f"{printer_name} AMS {tray.ams_id} tray {tray.tray_id} (tray_info_idx={tray.tray_info_idx})"
-            )
-            return None
+        # Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag
+        zero_uuid = "00000000000000000000000000000000"
+        zero_tag = "0000000000000000"
+        spool_tag = None
+        if tray.tray_uuid and tray.tray_uuid != zero_uuid:
+            spool_tag = tray.tray_uuid
+        elif tray.tag_uid and tray.tag_uid != zero_tag:
+            spool_tag = tray.tag_uid
 
         # Calculate remaining weight
-        remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
+        # Primary: AMS MQTT data (remain percentage + tray_weight)
+        # Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight)
+        if tray.remain >= 0 and tray.tray_weight > 0:
+            remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
+        elif inventory_remaining is not None:
+            remaining = inventory_remaining
+            logger.debug(
+                "Using inventory weight fallback for %s AMS %s tray %s: %.1fg",
+                printer_name,
+                tray.ams_id,
+                tray.tray_id,
+                remaining,
+            )
+        else:
+            remaining = None
         location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
 
-        # Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
-        existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
+        if spool_tag:
+            # Primary path: match by RFID tag
+            existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
+            if existing:
+                logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
+                return await self.update_spool(
+                    spool_id=existing["id"],
+                    remaining_weight=None if disable_weight_sync else remaining,
+                    location=location,
+                )
+
+            # Spool not found by tag - auto-create it
+            logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
+            filament = await self._find_or_create_filament(tray)
+            if not filament:
+                logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
+                return None
+
+            import json
+
+            return await self.create_spool(
+                filament_id=filament["id"],
+                remaining_weight=remaining,
+                location=location,
+                comment="Created by Bambuddy",
+                extra={"tag": json.dumps(spool_tag)},
+            )
+
+        # Fallback path: no RFID tag available (newer firmware may not expose UUIDs)
+        # Only update existing spools matched by location — never create new ones without a tag
+        # to avoid duplicates when old spools exist from previous RFID-based syncs
+        existing = self._find_spool_by_location(location, cached_spools)
         if existing:
-            # Update existing spool
-            logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
+            logger.info(
+                "Updating spool %s by location match '%s' (no RFID tag available)",
+                existing["id"],
+                location,
+            )
             return await self.update_spool(
                 spool_id=existing["id"],
                 remaining_weight=None if disable_weight_sync else remaining,
                 location=location,
             )
 
-        # Spool not found - auto-create it
-        logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
-
-        # First find or create the filament type
-        filament = await self._find_or_create_filament(tray)
-        if not filament:
-            logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
-            return None
-
-        # Create the spool with identifier stored as "tag" in extra field
-        # Note: Spoolman extra field values must be valid JSON, so we encode the string
-        import json
-
-        return await self.create_spool(
-            filament_id=filament["id"],
-            remaining_weight=remaining,
-            location=location,
-            comment="Created by Bambuddy",
-            extra={"tag": json.dumps(spool_tag)},
+        logger.info(
+            "No existing spool found at '%s' — skipping (no RFID tag to create with)",
+            location,
         )
+        return None
 
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
         """Find existing filament or create new one.

+ 9 - 8
backend/app/services/spoolman_tracking.py

@@ -66,14 +66,15 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
                 "tray_type": tray.get("tray_type", ""),
             }
 
-    # External spool (global_tray_id = 254)
-    vt_tray = raw_data.get("vt_tray")
-    if vt_tray and vt_tray.get("tray_type"):
-        lookup[254] = {
-            "tray_uuid": vt_tray.get("tray_uuid", ""),
-            "tag_uid": vt_tray.get("tag_uid", ""),
-            "tray_type": vt_tray.get("tray_type", ""),
-        }
+    # External spool(s) (vt_tray is a list, global_tray_id from each entry's "id")
+    for vt in raw_data.get("vt_tray") or []:
+        if vt.get("tray_type"):
+            tray_id = int(vt.get("id", 254))
+            lookup[tray_id] = {
+                "tray_uuid": vt.get("tray_uuid", ""),
+                "tag_uid": vt.get("tag_uid", ""),
+                "tray_type": vt.get("tray_type", ""),
+            }
 
     return lookup
 

+ 420 - 0
backend/app/services/usage_tracker.py

@@ -0,0 +1,420 @@
+"""Automatic filament consumption tracking.
+
+Captures AMS tray remain% at print start, then computes consumption
+deltas at print complete to update spool weight_used and last_used.
+
+Primary tracking uses 3MF slicer estimates (precise per-filament data).
+AMS remain% delta is the fallback for trays not covered by 3MF data.
+"""
+
+import json
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class PrintSession:
+    printer_id: int
+    print_name: str
+    started_at: datetime
+    tray_remain_start: 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."""
+    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)
+        return
+
+    ams_raw = state.raw_data.get("ams", [])
+    ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    if not ams_data:
+        logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
+        return
+
+    tray_remain_start: dict[tuple[int, int], int] = {}
+    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))
+            remain = tray.get("remain", -1)
+            if isinstance(remain, int) and 0 <= remain <= 100:
+                tray_remain_start[(ams_id, tray_id)] = remain
+
+    print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
+
+    # Always create session (even without valid remain data) so print_name
+    # is available at completion for 3MF-based tracking
+    session = PrintSession(
+        printer_id=printer_id,
+        print_name=print_name,
+        started_at=datetime.now(timezone.utc),
+        tray_remain_start=tray_remain_start,
+    )
+    _active_sessions[printer_id] = session
+
+    if tray_remain_start:
+        logger.info(
+            "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
+            printer_id,
+            len(tray_remain_start),
+            {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
+        )
+    else:
+        logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
+
+
+async def on_print_complete(
+    printer_id: int,
+    data: dict,
+    printer_manager,
+    db: AsyncSession,
+    archive_id: int | None = None,
+) -> list[dict]:
+    """Compute consumption deltas and update spool weight_used/last_used.
+
+    Uses two tracking strategies in priority order:
+    1. 3MF per-filament estimates (primary) — precise slicer data for all spools
+    2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
+
+    Returns a list of dicts describing what was logged (for WebSocket broadcast).
+    """
+    session = _active_sessions.pop(printer_id, None)
+    status = data.get("status", "completed")
+    results = []
+    handled_trays: set[tuple[int, int]] = set()
+
+    # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
+    if archive_id:
+        print_name = (
+            (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
+        )
+        threemf_results = await _track_from_3mf(
+            printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
+        )
+        results.extend(threemf_results)
+
+    # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
+    if session and session.tray_remain_start:
+        state = printer_manager.get_status(printer_id)
+        if state and state.raw_data:
+            ams_raw = state.raw_data.get("ams", [])
+            ams_data = (
+                ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+            )
+
+            for ams_unit in ams_data:
+                ams_id = int(ams_unit.get("id", 0))
+                for tray in ams_unit.get("tray", []):
+                    tray_id = int(tray.get("id", 0))
+                    key = (ams_id, tray_id)
+
+                    if key in handled_trays:
+                        continue  # Already tracked via 3MF
+
+                    if key not in session.tray_remain_start:
+                        continue
+
+                    current_remain = tray.get("remain", -1)
+                    if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
+                        continue
+
+                    start_remain = session.tray_remain_start[key]
+                    delta_pct = start_remain - current_remain
+
+                    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,
+                        )
+                    )
+                    assignment = result.scalar_one_or_none()
+                    if not assignment:
+                        continue
+
+                    # Load spool
+                    spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+                    spool = spool_result.scalar_one_or_none()
+                    if not spool:
+                        continue
+
+                    # Compute weight consumed
+                    weight_grams = (delta_pct / 100.0) * spool.label_weight
+
+                    # Update spool
+                    spool.weight_used = (spool.weight_used or 0) + weight_grams
+                    spool.last_used = datetime.now(timezone.utc)
+
+                    # Insert usage history record
+                    history = SpoolUsageHistory(
+                        spool_id=spool.id,
+                        printer_id=printer_id,
+                        print_name=session.print_name,
+                        weight_used=round(weight_grams, 1),
+                        percent_used=delta_pct,
+                        status=status,
+                    )
+                    db.add(history)
+
+                    handled_trays.add(key)
+                    results.append(
+                        {
+                            "spool_id": spool.id,
+                            "weight_used": round(weight_grams, 1),
+                            "percent_used": delta_pct,
+                            "ams_id": ams_id,
+                            "tray_id": tray_id,
+                            "material": spool.material,
+                        }
+                    )
+
+                    logger.info(
+                        "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
+                        spool.id,
+                        weight_grams,
+                        delta_pct,
+                        printer_id,
+                        ams_id,
+                        tray_id,
+                        status,
+                    )
+
+    if results:
+        await db.commit()
+
+    return results
+
+
+async def _track_from_3mf(
+    printer_id: int,
+    archive_id: int,
+    status: str,
+    print_name: str,
+    handled_trays: set[tuple[int, int]],
+    printer_manager,
+    db: AsyncSession,
+) -> list[dict]:
+    """Track usage from 3MF per-filament slicer data (primary path).
+
+    Uses slicer-estimated filament weight for all spools (BL and non-BL).
+    For partial prints (failed/aborted), tries per-layer gcode data first,
+    then falls back to linear scaling by progress.
+
+    Slot-to-tray mapping priority:
+    1. Queue item ams_mapping (for queue-initiated prints)
+    2. tray_now from printer state (for single-filament non-queue prints)
+    3. 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
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
+
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive or not archive.file_path:
+        return []
+
+    file_path = app_settings.base_dir / archive.file_path
+    if not file_path.exists():
+        return []
+
+    filament_usage = extract_filament_usage_from_3mf(file_path)
+    if not filament_usage:
+        return []
+
+    # --- Resolve slot-to-tray mapping ---
+    # 1. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
+    slot_to_tray = None
+    queue_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.archive_id == archive_id)
+        .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
+    )
+    queue_item = queue_result.scalar_one_or_none()
+    if queue_item and queue_item.ams_mapping:
+        try:
+            slot_to_tray = json.loads(queue_item.ams_mapping)
+        except (json.JSONDecodeError, TypeError):
+            pass
+
+    # 2. For single-filament non-queue prints, use tray_now from printer state
+    nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
+    tray_now_override: int | None = None
+    if not slot_to_tray and len(nonzero_slots) == 1:
+        state = printer_manager.get_status(printer_id)
+        if state and 0 <= state.tray_now <= 254:
+            tray_now_override = state.tray_now
+        elif state and state.tray_now == 255:
+            # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
+            vt_tray = state.raw_data.get("vt_tray") or []
+            if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
+                tray_now_override = state.tray_now
+
+    # Scale factor for partial prints (failed/aborted)
+    if status == "completed":
+        scale = 1.0
+    else:
+        state = printer_manager.get_status(printer_id)
+        progress = state.progress if state else 0
+        scale = max(0.0, min(progress / 100.0, 1.0))
+
+    # Per-layer gcode accuracy for partial prints
+    layer_grams: dict[int, float] | None = None
+    if status != "completed":
+        state = printer_manager.get_status(printer_id)
+        current_layer = state.layer_num if state else 0
+        if current_layer > 0:
+            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,
+                )
+
+                layer_usage = extract_layer_filament_usage_from_3mf(file_path)
+                if layer_usage:
+                    cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
+                    filament_props = extract_filament_properties_from_3mf(file_path)
+                    layer_grams = {}
+                    for filament_id, mm_used in cumulative_mm.items():
+                        slot_id = filament_id + 1  # 0-based to 1-based
+                        props = filament_props.get(slot_id, {})
+                        density = props.get("density", 1.24)
+                        diameter = props.get("diameter", 1.75)
+                        layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
+            except Exception:
+                pass  # Fall back to linear scaling
+
+    results = []
+
+    for usage in filament_usage:
+        slot_id = usage.get("slot_id", 0)
+        used_g = usage.get("used_g", 0)
+        if used_g <= 0:
+            continue
+
+        # 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
+            global_tray_id = tray_now_override
+        else:
+            # Queue mapping or default: slot_id - 1, overridden by ams_mapping
+            global_tray_id = slot_id - 1
+            if slot_to_tray and slot_id <= len(slot_to_tray):
+                mapped = slot_to_tray[slot_id - 1]
+                if isinstance(mapped, int) and mapped >= 0:
+                    global_tray_id = mapped
+
+        if global_tray_id >= 254:
+            # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
+            ams_id = 255
+            tray_id = global_tray_id - 254
+        elif global_tray_id >= 128:
+            ams_id = global_tray_id
+            tray_id = 0
+        else:
+            ams_id = global_tray_id // 4
+            tray_id = global_tray_id % 4
+
+        key = (ams_id, tray_id)
+        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,
+            )
+        )
+        assignment = assign_result.scalar_one_or_none()
+        if not assignment:
+            continue
+
+        # Load spool
+        spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+        spool = spool_result.scalar_one_or_none()
+        if not spool:
+            continue
+
+        # Use per-layer grams if available, otherwise linear scale
+        if layer_grams and slot_id in layer_grams:
+            weight_grams = layer_grams[slot_id]
+        else:
+            weight_grams = used_g * scale
+
+        if weight_grams <= 0:
+            continue
+
+        # Update spool
+        spool.weight_used = (spool.weight_used or 0) + weight_grams
+        spool.last_used = datetime.now(timezone.utc)
+
+        percent = round(weight_grams / (spool.label_weight or 1000) * 100)
+
+        # Insert usage history record
+        history = SpoolUsageHistory(
+            spool_id=spool.id,
+            printer_id=printer_id,
+            print_name=print_name,
+            weight_used=round(weight_grams, 1),
+            percent_used=percent,
+            status=status,
+        )
+        db.add(history)
+
+        handled_trays.add(key)
+        results.append(
+            {
+                "spool_id": spool.id,
+                "weight_used": round(weight_grams, 1),
+                "percent_used": percent,
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+                "material": spool.material,
+            }
+        )
+
+        # Determine mapping source for debug logging
+        if tray_now_override is not None:
+            map_src = ", tray_now"
+        elif slot_to_tray:
+            map_src = ", queue_map"
+        else:
+            map_src = ""
+        logger.info(
+            "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
+            spool.id,
+            weight_grams,
+            " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
+            map_src,
+            printer_id,
+            ams_id,
+            tray_id,
+            status,
+        )
+
+    return results

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

@@ -48,6 +48,69 @@ PRINTER_MODEL_ID_MAP = {
 }
 
 
+# Rod/rail type classification for maintenance tasks.
+# Carbon rods: X1, P1, P2S series (CoreXY with carbon fiber rods)
+# Linear rails: A1, H2 series (linear rail motion system)
+# Values must be uppercase with spaces stripped for normalized comparison.
+CARBON_ROD_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "X1",
+        "X1C",
+        "X1E",
+        "P1P",
+        "P1S",
+        "P2S",
+        # Internal codes
+        "C11",  # X1C
+        "C12",  # X1
+        "C13",  # X1E
+        "N7",  # P2S
+    ]
+)
+
+LINEAR_RAIL_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "A1",
+        "A1MINI",
+        "H2D",
+        "H2DPRO",
+        "H2C",
+        "H2S",
+        # Internal codes
+        "N1",  # A1
+        "N2S",  # A1 Mini
+        "A04",  # A1 Mini (alternate)
+        "A11",  # A1
+        "A12",  # A1 Mini
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro (alternate)
+        "O1C",  # H2C
+        "O1S",  # H2S
+    ]
+)
+
+
+def get_rod_type(model: str | None) -> str | None:
+    """Return the rod/rail type for a printer model.
+
+    Returns:
+        "carbon" for X1/P1/P2S series (carbon fiber rods),
+        "linear_rail" for A1/H2 series (linear rails),
+        None for unknown models.
+    """
+    if not model:
+        return None
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    if normalized in CARBON_ROD_MODELS:
+        return "carbon"
+    if normalized in LINEAR_RAIL_MODELS:
+        return "linear_rail"
+    return None
+
+
 def normalize_printer_model_id(model_id: str | None) -> str | None:
     """Convert printer_model_id (internal code) to normalized short name.
 

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

@@ -264,6 +264,62 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
     return properties
 
 
+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.
+
+    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.
+
+    Translation chain:
+        filament_nozzle_map[slot_id - 1] -> slicer extruder index
+        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+
+    Args:
+        zf: An open ZipFile of the 3MF archive
+
+    Returns:
+        Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
+        or None if single-nozzle, missing data, or parse error.
+    """
+    try:
+        if "Metadata/project_settings.config" not in zf.namelist():
+            return None
+
+        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 filament_nozzle_map or not physical_extruder_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
+            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
+
+        return nozzle_mapping
+    except Exception:
+        return None
+
+
 def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 

+ 1 - 0
backend/tests/conftest.py

@@ -408,6 +408,7 @@ def notification_provider_factory(db_session):
             "on_maintenance_due": False,
             "on_ams_humidity_high": False,
             "on_ams_temperature_high": False,
+            "on_bed_cooled": False,
             "quiet_hours_enabled": False,
             "daily_digest_enabled": False,
         }

+ 43 - 0
backend/tests/integration/test_camera_api.py

@@ -192,6 +192,49 @@ class TestCameraAPI:
         assert response.status_code == 503
         assert "Failed to capture" in response.json()["detail"]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
+        """Verify snapshot uses external camera when configured."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
+
+        with patch(
+            "backend.app.services.external_camera.capture_frame",
+            new_callable=AsyncMock,
+            return_value=fake_jpeg,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "image/jpeg"
+        assert response.content == fake_jpeg
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify 503 when external camera capture fails."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        with patch(
+            "backend.app.services.external_camera.capture_frame",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 503
+        assert "external camera" in response.json()["detail"].lower()
+
     # ========================================================================
     # Camera Stream Endpoint
     # ========================================================================

+ 6 - 6
backend/tests/unit/services/test_bambu_ftp.py

@@ -123,11 +123,10 @@ class TestDisconnectServerGone:
     """Test disconnect behavior when the server has stopped."""
 
     def test_disconnect_after_server_gone(self, ftp_certs, tmp_path):
-        """Disconnect after server has stopped raises EOFError.
+        """Disconnect after server has stopped does not raise.
 
-        Note: The current disconnect() catches (OSError, ftplib.Error) but
-        EOFError is neither. This documents actual behavior — a future fix
-        could add EOFError to the except clause.
+        disconnect() catches OSError, ftplib.Error, and EOFError so that
+        best-effort cleanup never propagates exceptions to the caller.
         """
         from backend.tests.unit.services.mock_ftp_server import (
             MockBambuFTPServer,
@@ -145,8 +144,9 @@ class TestDisconnectServerGone:
         client.connect()
 
         server.stop()
-        with pytest.raises(EOFError):
-            client.disconnect()
+        # Should not raise — disconnect() catches all connection errors
+        client.disconnect()
+        assert client._ftp is None
 
 
 # ---------------------------------------------------------------------------

+ 130 - 0
backend/tests/unit/services/test_notification_service.py

@@ -1191,3 +1191,133 @@ class TestPlateNotEmptyNotifications:
 
             assert captured_variables["printer"] == "X1 Carbon"
             assert captured_variables["difference_percent"] == "3.5"
+
+
+class TestBedCooledNotifications:
+    """Tests for bed cooled (after print) notifications."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider with bed cooled enabled."""
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_bed_cooled = True
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_sends_notification(self, service, mock_provider, mock_db):
+        """Verify bed cooled notification is sent when triggered."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Bed Cooled", "Test Printer: Bed cooled to 30°C")
+
+            await service.on_bed_cooled(
+                printer_id=1,
+                printer_name="Test Printer",
+                bed_temp=30.0,
+                threshold=35.0,
+                filename="benchy.3mf",
+                db=mock_db,
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_skipped_when_no_providers(self, service, mock_db):
+        """Verify notification is skipped when no providers have bed cooled enabled."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            mock_get.return_value = []
+
+            await service.on_bed_cooled(
+                printer_id=1,
+                printer_name="Test Printer",
+                bed_temp=30.0,
+                threshold=35.0,
+                filename="benchy.3mf",
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_includes_correct_variables(self, service, mock_provider, mock_db):
+        """Verify bed temp, threshold, and filename are passed to template variables."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_bed_cooled(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                bed_temp=28.7,
+                threshold=35.0,
+                filename="benchy.gcode.3mf",
+                db=mock_db,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["bed_temp"] == "29"
+            assert captured_variables["threshold"] == "35"
+            assert captured_variables["filename"] == "benchy"
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_handles_none_filename(self, service, mock_provider, mock_db):
+        """Verify None filename is handled gracefully."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_bed_cooled(
+                printer_id=1,
+                printer_name="Test Printer",
+                bed_temp=30.0,
+                threshold=35.0,
+                filename=None,
+                db=mock_db,
+            )
+
+            assert captured_variables["filename"] == "Unknown"

+ 40 - 12
backend/tests/unit/services/test_printer_manager.py

@@ -3,6 +3,7 @@
 Tests printer connection management, status tracking, and print control.
 """
 
+import logging
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -382,6 +383,30 @@ class TestPrinterManager:
         result = manager.start_print(999, "test.gcode")
         assert result is False
 
+    def test_start_print_logs_print_command_with_caller(self, manager, mock_client, caplog):
+        """Verify start_print logs PRINT COMMAND with caller info (#374)."""
+        mock_client.start_print.return_value = True
+        manager._clients[1] = mock_client
+
+        with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
+            manager.start_print(1, "benchy.3mf")
+
+        print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
+        assert len(print_cmd_logs) == 1
+        log_msg = print_cmd_logs[0].message
+        assert "printer=1" in log_msg
+        assert "file=benchy.3mf" in log_msg
+        assert "caller=" in log_msg
+
+    def test_start_print_logs_even_when_printer_unknown(self, manager, caplog):
+        """Verify PRINT COMMAND is logged even for unknown printers (#374)."""
+        with caplog.at_level(logging.INFO, logger="backend.app.services.printer_manager"):
+            result = manager.start_print(999, "ghost.3mf")
+
+        assert result is False
+        print_cmd_logs = [r for r in caplog.records if "PRINT COMMAND" in r.message]
+        assert len(print_cmd_logs) == 1
+
     # ========================================================================
     # Tests for stop_print
     # ========================================================================
@@ -734,23 +759,26 @@ class TestPrinterStateToDict:
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
 
     def test_vt_tray_parsing(self, mock_state):
-        """Verify virtual tray is parsed correctly."""
+        """Verify virtual tray is parsed correctly as a list."""
         mock_state.raw_data = {
-            "vt_tray": {
-                "tray_color": "00FF00",
-                "tray_type": "PETG",
-                "tray_sub_brands": "Generic",
-                "remain": 60,
-                "tag_uid": "VT123",
-            }
+            "vt_tray": [
+                {
+                    "tray_color": "00FF00",
+                    "tray_type": "PETG",
+                    "tray_sub_brands": "Generic",
+                    "remain": 60,
+                    "tag_uid": "VT123",
+                }
+            ]
         }
 
         result = printer_state_to_dict(mock_state)
 
-        assert result["vt_tray"] is not None
-        assert result["vt_tray"]["id"] == 254
-        assert result["vt_tray"]["tray_color"] == "00FF00"
-        assert result["vt_tray"]["tray_type"] == "PETG"
+        assert isinstance(result["vt_tray"], list)
+        assert len(result["vt_tray"]) == 1
+        assert result["vt_tray"][0]["id"] == 254
+        assert result["vt_tray"][0]["tray_color"] == "00FF00"
+        assert result["vt_tray"][0]["tray_type"] == "PETG"
 
     def test_hms_errors_conversion(self, mock_state):
         """Verify HMS errors are converted correctly."""

+ 66 - 0
backend/tests/unit/services/test_spoolman_service.py

@@ -2,6 +2,7 @@
 
 These tests specifically target the sync_ams_tray method's disable_weight_sync
 functionality that controls whether remaining_weight is updated.
+Also includes tests for is_bambu_lab_spool RFID detection.
 """
 
 from unittest.mock import AsyncMock, Mock, patch
@@ -11,6 +12,71 @@ import pytest
 from backend.app.services.spoolman import AMSTray, SpoolmanClient
 
 
+class TestIsBambuLabSpool:
+    """Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only."""
+
+    @pytest.fixture
+    def client(self):
+        return SpoolmanClient("http://localhost:7912")
+
+    def test_valid_tray_uuid_returns_true(self, client):
+        """A non-zero 32-char hex tray_uuid identifies a BL spool."""
+        assert client.is_bambu_lab_spool("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") is True
+
+    def test_valid_tag_uid_returns_true(self, client):
+        """A non-zero 16-char hex tag_uid identifies a BL spool (fallback)."""
+        assert client.is_bambu_lab_spool("", tag_uid="A1B2C3D4E5F6A1B2") is True
+
+    def test_zero_tray_uuid_returns_false(self, client):
+        """All-zero tray_uuid means no RFID tag read."""
+        assert client.is_bambu_lab_spool("00000000000000000000000000000000") is False
+
+    def test_zero_tag_uid_returns_false(self, client):
+        """All-zero tag_uid means no RFID tag read."""
+        assert client.is_bambu_lab_spool("", tag_uid="0000000000000000") is False
+
+    def test_empty_identifiers_returns_false(self, client):
+        """No identifiers means no BL spool."""
+        assert client.is_bambu_lab_spool("") is False
+        assert client.is_bambu_lab_spool("", tag_uid="") is False
+
+    def test_tray_info_idx_ignored(self, client):
+        """tray_info_idx is NOT a reliable BL indicator — third-party spools
+        using Bambu generic presets also have GF-prefixed tray_info_idx values."""
+        # Third-party spool with Bambu preset but no RFID identifiers
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFA00") is False
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFB00") is False
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFSA02_04") is False
+
+    def test_tray_info_idx_with_valid_uuid_returns_true(self, client):
+        """BL spool with both RFID UUID and preset ID — detected by UUID."""
+        assert (
+            client.is_bambu_lab_spool(
+                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                tray_info_idx="GFA00",
+            )
+            is True
+        )
+
+    def test_tray_uuid_preferred_over_tag_uid(self, client):
+        """tray_uuid is checked before tag_uid (both valid)."""
+        assert (
+            client.is_bambu_lab_spool(
+                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                tag_uid="A1B2C3D4E5F6A1B2",
+            )
+            is True
+        )
+
+    def test_short_tray_uuid_returns_false(self, client):
+        """UUID must be exactly 32 hex chars."""
+        assert client.is_bambu_lab_spool("A1B2C3D4") is False
+
+    def test_non_hex_tray_uuid_returns_false(self, client):
+        """UUID must be valid hex."""
+        assert client.is_bambu_lab_spool("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") is False
+
+
 class TestSpoolmanClient:
     """Tests for SpoolmanClient class."""
 

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

@@ -99,14 +99,14 @@ class TestBuildAmsTrayLookup:
     def test_external_spool(self):
         raw = {
             "ams": [],
-            "vt_tray": {"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"},
+            "vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}],
         }
         lookup = build_ams_tray_lookup(raw)
         assert 254 in lookup
         assert lookup[254]["tray_type"] == "TPU"
 
     def test_empty_external_spool_skipped(self):
-        raw = {"ams": [], "vt_tray": {"tray_type": ""}}
+        raw = {"ams": [], "vt_tray": [{"tray_type": ""}]}
         lookup = build_ams_tray_lookup(raw)
         assert 254 not in lookup
 

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

@@ -0,0 +1,401 @@
+"""Unit tests for the filament usage tracker.
+
+Tests 3MF-primary tracking (Path 1) and AMS remain% delta fallback
+(Path 2) for spools not covered by 3MF data.
+"""
+
+from datetime import datetime, timezone
+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,
+    on_print_start,
+)
+
+
+def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
+    """Create a mock Spool object."""
+    spool = MagicMock()
+    spool.id = id
+    spool.label_weight = label_weight
+    spool.weight_used = weight_used
+    spool.tag_uid = tag_uid
+    spool.tray_uuid = tray_uuid
+    spool.last_used = None
+    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_printer_state(ams_data, progress=0, layer_num=0, tray_now=255):
+    """Create a mock printer state with AMS data."""
+    state = MagicMock()
+    state.raw_data = {"ams": ams_data}
+    state.progress = progress
+    state.layer_num = layer_num
+    state.tray_now = tray_now
+    return state
+
+
+def _make_printer_manager(state=None):
+    """Create a mock printer manager."""
+    pm = MagicMock()
+    pm.get_status.return_value = state
+    return pm
+
+
+class TestOnPrintStart:
+    """Tests for on_print_start — capturing AMS remain%."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_creates_session_with_valid_remain(self):
+        """Session created with remain% data for trays reporting 0-100."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        await on_print_start(1, {"subtask_name": "test_print"}, pm)
+
+        assert 1 in _active_sessions
+        session = _active_sessions[1]
+        assert session.print_name == "test_print"
+        assert session.tray_remain_start == {(0, 0): 80}
+
+    @pytest.mark.asyncio
+    async def test_creates_session_even_without_valid_remain(self):
+        """Session still created when remain=-1 (for 3MF fallback path)."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        await on_print_start(1, {"subtask_name": "test_print"}, pm)
+
+        assert 1 in _active_sessions
+        session = _active_sessions[1]
+        assert session.tray_remain_start == {}  # Empty, no valid remain
+
+    @pytest.mark.asyncio
+    async def test_skips_without_ams_data(self):
+        """No session created when no AMS data available."""
+        state = MagicMock()
+        state.raw_data = {"ams": []}
+        pm = _make_printer_manager(state)
+
+        await on_print_start(1, {"subtask_name": "test"}, pm)
+
+        assert 1 not in _active_sessions
+
+
+class TestOnPrintCompleteAMSDelta:
+    """Tests for Path 1: AMS remain% delta tracking."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_computes_delta_and_updates_spool(self):
+        """Spool weight_used updated by remain% delta * label_weight."""
+        # Set up session with start remain = 80%
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        # Current remain = 70% → 10% consumed → 100g on 1000g spool
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        spool = _make_spool(label_weight=1000, weight_used=50)
+        assignment = _make_assignment()
+
+        db = AsyncMock()
+        # First execute → assignment, second → spool
+        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(1, {"status": "completed"}, pm, db)
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 100.0
+        assert results[0]["percent_used"] == 10
+        # weight_used should be old (50) + delta (100)
+        assert spool.weight_used == 150.0
+        db.commit.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_skips_negative_delta(self):
+        """No tracking when remain increased (spool refilled)."""
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 50},
+        )
+
+        # Remain went UP: 50 → 80 (refilled)
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+        db = AsyncMock()
+
+        results = await on_print_complete(1, {"status": "completed"}, pm, db)
+
+        assert results == []
+        db.commit.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_no_session_falls_through_to_3mf(self):
+        """When no session exists, AMS delta path skipped (3MF may still run)."""
+        pm = _make_printer_manager()
+        db = AsyncMock()
+
+        results = await on_print_complete(1, {"status": "completed"}, pm, db)
+
+        assert results == []
+
+
+class TestTrackFrom3MF:
+    """Tests for Path 2: 3MF per-filament fallback tracking."""
+
+    @pytest.mark.asyncio
+    async def test_updates_non_bl_spool_from_3mf(self):
+        """Non-BL spool gets weight_used from 3MF used_g for completed print."""
+        spool = _make_spool(id=5, label_weight=1000, weight_used=100)
+        assignment = _make_assignment(spool_id=5)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
+        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": 25.5, "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_print",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 5
+        assert results[0]["weight_used"] == 25.5
+        # weight_used = old (100) + 3MF (25.5)
+        assert spool.weight_used == 125.5
+
+    @pytest.mark.asyncio
+    async def test_scales_by_progress_for_failed_print(self):
+        """Failed print scales 3MF estimate by progress percentage."""
+        spool = _make_spool(id=1, label_weight=1000, weight_used=0)
+        assignment = _make_assignment()
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
+        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)),
+            ]
+        )
+
+        # Print failed at 50% progress → 50g consumed from 100g estimate
+        pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
+
+        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="failed",
+                print_name="test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 50.0
+        assert spool.weight_used == 50.0
+
+    @pytest.mark.asyncio
+    async def test_tracks_bl_spools_via_3mf(self):
+        """BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking)."""
+        spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
+        assignment = _make_assignment()
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
+        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": 50.0, "type": "PLA", "color": ""}]
+
+        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,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 50.0
+
+    @pytest.mark.asyncio
+    async def test_skips_already_handled_trays(self):
+        """Trays handled by AMS remain% delta are not double-tracked via 3MF."""
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        # archive, queue_item(None)
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
+
+        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={(0, 0)},  # slot_id=1 → ams_id=0, tray_id=0
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert results == []
+
+    @pytest.mark.asyncio
+    async def test_slot_to_tray_mapping(self):
+        """3MF slot_id maps correctly to (ams_id, tray_id) via tray_now."""
+        # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now)
+        spool = _make_spool(id=9)
+        assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
+        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=4))
+        filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
+
+        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,
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 0

+ 1 - 1
backend/tests/unit/test_code_quality.py

@@ -137,7 +137,7 @@ def find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]:
     Returns list of (name, line_number, function_name) tuples.
     """
     try:
-        with open(file_path) as f:
+        with open(file_path, encoding="utf-8") as f:
             source = f.read()
         tree = ast.parse(source)
         visitor = DangerousImportVisitor()

+ 211 - 0
backend/tests/unit/test_phantom_print_hardening.py

@@ -0,0 +1,211 @@
+"""Tests for phantom print investigation hardening (#374).
+
+Tests the tightened archive matching (no ilike) and the
+multiple-printing-items warning logic.
+
+These are pure unit tests that test the changed logic directly,
+NOT by calling the full on_print_start/on_print_complete callbacks
+(which spawn background tasks and require heavy mocking).
+"""
+
+import logging
+
+import pytest
+from sqlalchemy import or_, select
+from sqlalchemy.sql import ClauseElement
+
+from backend.app.models.archive import PrintArchive
+
+
+class TestArchiveMatchQueryShape:
+    """Tests that the archive duplicate lookup query uses exact match, not ilike (#374).
+
+    The old query used `ilike('%{name}%')` which caused "Clip" to match
+    "Cable Clip", "Clip Stand", etc. The new query uses exact print_name
+    match OR exact filename variants (.3mf, .gcode.3mf).
+    """
+
+    def _build_archive_query(self, check_name: str, printer_id: int = 1) -> ClauseElement:
+        """Build the exact query used in on_print_start for archive dedup."""
+        return (
+            select(PrintArchive)
+            .where(PrintArchive.printer_id == printer_id)
+            .where(PrintArchive.status == "printing")
+            .where(
+                or_(
+                    PrintArchive.print_name == check_name,
+                    PrintArchive.filename.in_(
+                        [
+                            f"{check_name}.3mf",
+                            f"{check_name}.gcode.3mf",
+                        ]
+                    ),
+                )
+            )
+            .order_by(PrintArchive.created_at.desc())
+            .limit(1)
+        )
+
+    def test_query_does_not_contain_ilike(self):
+        """Verify the compiled query does NOT use LIKE/ILIKE."""
+        query = self._build_archive_query("Clip")
+        query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
+
+        assert "LIKE" not in query_str.upper(), f"Query should not use LIKE: {query_str}"
+
+    def test_query_uses_exact_equality(self):
+        """Verify the query uses = for print_name comparison."""
+        query = self._build_archive_query("Benchy")
+        query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
+
+        assert "print_name = " in query_str or "print_name ='" in query_str or "print_name =" in query_str
+
+    def test_query_uses_in_for_filename_variants(self):
+        """Verify the query uses IN for filename matching with .3mf variants."""
+        query = self._build_archive_query("MyPrint")
+        query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
+
+        assert "IN" in query_str.upper()
+        assert "MyPrint.3mf" in query_str
+        assert "MyPrint.gcode.3mf" in query_str
+
+    def test_partial_name_not_in_query(self):
+        """Verify 'Clip' does not produce a wildcard pattern."""
+        query = self._build_archive_query("Clip")
+        query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
+
+        # Should NOT contain %Clip% wildcard
+        assert "%Clip%" not in query_str
+
+    def test_check_name_derivation_from_subtask(self):
+        """Verify check_name is derived correctly from subtask_name."""
+        # Simulates: check_name = subtask_name or filename.split("/")[-1].replace(...)
+        subtask_name = "Cable Clip"
+        filename = "/sdcard/Cable Clip.gcode"
+        check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
+        assert check_name == "Cable Clip"
+
+        query = self._build_archive_query(check_name)
+        query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
+
+        # Exact match should contain the full name, not a partial
+        assert "Cable Clip" in query_str
+        assert "%Cable Clip%" not in query_str
+
+    def test_check_name_derivation_from_filename(self):
+        """Verify check_name strips extensions correctly from filename."""
+        subtask_name = None
+        filename = "/sdcard/MyPrint.gcode"
+        check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
+        assert check_name == "MyPrint"
+
+
+class TestMultiplePrintingQueueItemsWarning:
+    """Tests for the multiple-printing-items warning logic (#374).
+
+    The code in on_print_complete now detects when multiple queue items
+    are in 'printing' status for the same printer, which signals a bug.
+    """
+
+    def test_single_item_returns_item_no_warning(self, caplog):
+        """Verify single item is returned without warning."""
+        from unittest.mock import MagicMock
+
+        items = [MagicMock(id=1, archive_id=10, library_file_id=None)]
+
+        # Simulate the exact code from on_print_complete
+        with caplog.at_level(logging.WARNING, logger="backend.app.main"):
+            logger = logging.getLogger("backend.app.main")
+            printer_id = 1
+            printing_items = list(items)
+
+            if len(printing_items) > 1:
+                logger.warning(
+                    "BUG: Multiple queue items in 'printing' status for printer %s: %s",
+                    printer_id,
+                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
+                )
+            queue_item = printing_items[0] if printing_items else None
+
+        assert queue_item is not None
+        assert queue_item.id == 1
+        bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
+        assert len(bug_warnings) == 0
+
+    def test_multiple_items_warns_and_returns_first(self, caplog):
+        """Verify warning is logged and first item is returned when multiple exist."""
+        from unittest.mock import MagicMock
+
+        items = [
+            MagicMock(id=1, archive_id=10, library_file_id=None),
+            MagicMock(id=2, archive_id=20, library_file_id=None),
+        ]
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.main"):
+            logger = logging.getLogger("backend.app.main")
+            printer_id = 1
+            printing_items = list(items)
+
+            if len(printing_items) > 1:
+                logger.warning(
+                    "BUG: Multiple queue items in 'printing' status for printer %s: %s",
+                    printer_id,
+                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
+                )
+            queue_item = printing_items[0] if printing_items else None
+
+        assert queue_item is not None
+        assert queue_item.id == 1  # First item is used
+        bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
+        assert len(bug_warnings) == 1
+        assert "printer 1" in bug_warnings[0].message
+        # Warning should include item details
+        assert "10" in bug_warnings[0].message  # archive_id of item 1
+        assert "20" in bug_warnings[0].message  # archive_id of item 2
+
+    def test_empty_list_returns_none_no_warning(self, caplog):
+        """Verify None is returned and no warning when no items exist."""
+        with caplog.at_level(logging.WARNING, logger="backend.app.main"):
+            logger = logging.getLogger("backend.app.main")
+            printer_id = 1
+            printing_items = []
+
+            if len(printing_items) > 1:
+                logger.warning(
+                    "BUG: Multiple queue items in 'printing' status for printer %s: %s",
+                    printer_id,
+                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
+                )
+            queue_item = printing_items[0] if printing_items else None
+
+        assert queue_item is None
+        bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
+        assert len(bug_warnings) == 0
+
+    def test_three_items_warns_with_all_details(self, caplog):
+        """Verify warning includes all item details when three items found."""
+        from unittest.mock import MagicMock
+
+        items = [
+            MagicMock(id=1, archive_id=10, library_file_id=None),
+            MagicMock(id=2, archive_id=None, library_file_id=5),
+            MagicMock(id=3, archive_id=30, library_file_id=None),
+        ]
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.main"):
+            logger = logging.getLogger("backend.app.main")
+            printer_id = 7
+            printing_items = list(items)
+
+            if len(printing_items) > 1:
+                logger.warning(
+                    "BUG: Multiple queue items in 'printing' status for printer %s: %s",
+                    printer_id,
+                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
+                )
+            queue_item = printing_items[0] if printing_items else None
+
+        assert queue_item.id == 1
+        bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
+        assert len(bug_warnings) == 1
+        assert "printer 7" in bug_warnings[0].message

+ 104 - 0
backend/tests/unit/test_print_log.py

@@ -0,0 +1,104 @@
+"""Unit tests for print log service and schema."""
+
+from datetime import datetime, timedelta
+
+import pytest
+
+from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
+
+
+class TestPrintLogEntrySchema:
+    """Test PrintLogEntrySchema validation."""
+
+    def test_minimal_entry(self):
+        """Schema accepts minimal required fields."""
+        entry = PrintLogEntrySchema(
+            id=1,
+            status="completed",
+            created_at=datetime(2024, 1, 15, 10, 30, 0),
+        )
+        assert entry.id == 1
+        assert entry.status == "completed"
+        assert entry.print_name is None
+        assert entry.printer_name is None
+        assert entry.duration_seconds is None
+
+    def test_full_entry(self):
+        """Schema accepts all fields."""
+        started = datetime(2024, 1, 15, 10, 0, 0)
+        completed = datetime(2024, 1, 15, 12, 30, 0)
+        entry = PrintLogEntrySchema(
+            id=42,
+            print_name="Benchy",
+            printer_name="X1C-01",
+            printer_id=3,
+            status="completed",
+            started_at=started,
+            completed_at=completed,
+            duration_seconds=9000,
+            filament_type="PLA",
+            filament_color="#FF5500",
+            filament_used_grams=15.2,
+            thumbnail_path="archives/3/20240115_benchy/thumbnail.png",
+            created_by_username="admin",
+            created_at=datetime(2024, 1, 15, 12, 30, 0),
+        )
+        assert entry.print_name == "Benchy"
+        assert entry.printer_name == "X1C-01"
+        assert entry.filament_used_grams == 15.2
+        assert entry.created_by_username == "admin"
+
+    def test_failed_status(self):
+        """Schema accepts various status values."""
+        for status in ("completed", "failed", "stopped", "cancelled", "skipped"):
+            entry = PrintLogEntrySchema(id=1, status=status, created_at=datetime.now())
+            assert entry.status == status
+
+
+class TestPrintLogResponse:
+    """Test PrintLogResponse pagination wrapper."""
+
+    def test_empty_response(self):
+        """Empty response with zero total."""
+        resp = PrintLogResponse(items=[], total=0)
+        assert len(resp.items) == 0
+        assert resp.total == 0
+
+    def test_paginated_response(self):
+        """Response with items and total count > items count."""
+        items = [PrintLogEntrySchema(id=i, status="completed", created_at=datetime.now()) for i in range(3)]
+        resp = PrintLogResponse(items=items, total=100)
+        assert len(resp.items) == 3
+        assert resp.total == 100
+
+
+class TestWriteLogEntry:
+    """Test the write_log_entry service function (logic only, no DB)."""
+
+    def test_duration_calculation(self):
+        """Duration is computed from started_at and completed_at."""
+        started = datetime(2024, 1, 15, 10, 0, 0)
+        completed = started + timedelta(hours=2, minutes=30)
+
+        # Simulating the duration calculation from write_log_entry
+        duration = int((completed - started).total_seconds())
+        assert duration == 9000  # 2.5 hours = 9000 seconds
+
+    def test_duration_none_when_missing_times(self):
+        """Duration is None when started_at or completed_at is missing."""
+        started = datetime(2024, 1, 15, 10, 0, 0)
+        completed_at = None
+        started_at = None
+        completed = datetime.now()
+
+        # No completed_at
+        duration = None
+        if started and completed_at:
+            duration = int((completed_at - started).total_seconds())
+        assert duration is None
+
+        # No started_at
+        duration = None
+        if started_at and completed:
+            duration = int((completed - started_at).total_seconds())
+        assert duration is None

+ 190 - 2
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -1,8 +1,13 @@
 """Tests for the AMS mapping computation in the print scheduler."""
 
+import io
+import json
+import zipfile
+
 import pytest
 
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 
 class TestSchedulerAmsMappingHelpers:
@@ -135,7 +140,7 @@ class TestBuildLoadedFilaments:
         """Should include external spool."""
 
         class MockStatus:
-            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
+            raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]}
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
@@ -461,9 +466,192 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         """Should extract tray_info_idx from external spool."""
 
         class MockStatus:
-            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
+            raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}]}
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
         assert result[0]["tray_info_idx"] == "P4d64437"
         assert result[0]["is_external"] is True
+
+
+def _make_3mf_zip(project_settings: dict | 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))
+    buf.seek(0)
+    return zipfile.ZipFile(buf, "r")
+
+
+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."""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1", "0"],
+                "physical_extruder_map": ["0", "1"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 0, 2: 1, 3: 0}
+        zf.close()
+
+    def test_single_nozzle_returns_none(self):
+        """All slots on same extruder should return None (single-nozzle)."""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "0", "0"],
+                "physical_extruder_map": ["0"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_missing_project_settings_returns_none(self):
+        """Missing project_settings.config should return None."""
+        zf = _make_3mf_zip(None)
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_missing_fields_returns_none(self):
+        """Missing filament_nozzle_map or 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
+        zf.close()
+
+    def test_physical_extruder_map_remapping(self):
+        """Should apply physical_extruder_map to remap slicer extruder to MQTT extruder."""
+        # Slicer ext 0 -> MQTT ext 1, slicer ext 1 -> MQTT ext 0
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1"],
+                "physical_extruder_map": ["1", "0"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 1, 2: 0}
+        zf.close()
+
+
+class TestNozzleAwareMapping:
+    """Test nozzle-aware filament matching in the print scheduler."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_dual_nozzle_matching(self, scheduler):
+        """Filaments assigned to different nozzles should match to correct AMS units."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
+            {"slot_id": 2, "type": "PLA", "color": "#00FF00", "nozzle_id": 1},  # Left nozzle
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "extruder_id": 0},  # AMS0 on right
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},  # AMS1 on left
+        ]
+        # Without nozzle filtering, slot 1 (red, right) would match tray 4 (red, left) by color.
+        # With nozzle filtering, slot 1 (right nozzle) can only use tray 0 (right extruder),
+        # and slot 2 (left nozzle) can only use tray 4 (left extruder).
+        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."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
+        ]
+        loaded = [
+            # 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
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [4]
+
+    def test_no_nozzle_id_skips_filtering(self, scheduler):
+        """When nozzle_id is None, no nozzle filtering should be applied."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000"},  # No nozzle_id
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
+        ]
+        # Should match first available (tray 0) regardless of extruder
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]
+
+    def test_extruder_id_in_loaded_filaments(self, scheduler):
+        """_build_loaded_filaments should include extruder_id from ams_extruder_map."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]},
+                    {"id": 1, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "00FF00"}]},
+                ],
+                "ams_extruder_map": {"0": 0, "1": 1},
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+        assert result[0]["extruder_id"] == 0
+        assert result[1]["extruder_id"] == 1
+
+    def test_extruder_id_none_without_map(self, scheduler):
+        """extruder_id should be None when ams_extruder_map is absent."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]},
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        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."""
+
+        class MockStatus:
+            raw_data = {
+                "vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}],
+                "ams_extruder_map": {"0": 0},
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] == 0
+        assert result[0]["is_external"] is True
+
+    def test_external_spool_no_extruder_map(self, scheduler):
+        """External spool extruder_id should be None without ams_extruder_map."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] is None
+
+    def test_dual_nozzle_with_tray_info_idx(self, scheduler):
+        """Nozzle filtering should work together with tray_info_idx matching."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 0},
+            {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01", "nozzle_id": 1},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00", "extruder_id": 0},
+            {"type": "PLA", "color": "#000000", "global_tray_id": 4, "tray_info_idx": "GFA01", "extruder_id": 1},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 4]

+ 186 - 0
backend/tests/unit/test_scheduler_clear_plate.py

@@ -0,0 +1,186 @@
+"""Tests for the clear plate queue flow in the print scheduler."""
+
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.services.printer_manager import PrinterManager
+
+
+class TestPrinterManagerPlateCleared:
+    """Test the plate-cleared flag management in PrinterManager."""
+
+    @pytest.fixture
+    def manager(self):
+        return PrinterManager()
+
+    def test_plate_cleared_initially_false(self, manager):
+        """No printers should have plate cleared by default."""
+        assert not manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(999)
+
+    def test_set_plate_cleared(self, manager):
+        """Setting plate cleared should make is_plate_cleared return True."""
+        manager.set_plate_cleared(1)
+        assert manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(2)
+
+    def test_consume_plate_cleared(self, manager):
+        """Consuming plate cleared should reset the flag."""
+        manager.set_plate_cleared(1)
+        assert manager.is_plate_cleared(1)
+        manager.consume_plate_cleared(1)
+        assert not manager.is_plate_cleared(1)
+
+    def test_consume_plate_cleared_idempotent(self, manager):
+        """Consuming when not set should not raise."""
+        manager.consume_plate_cleared(1)  # Should not raise
+        assert not manager.is_plate_cleared(1)
+
+    def test_set_plate_cleared_multiple_printers(self, manager):
+        """Plate cleared should be tracked per printer."""
+        manager.set_plate_cleared(1)
+        manager.set_plate_cleared(3)
+        assert manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(2)
+        assert manager.is_plate_cleared(3)
+
+    def test_consume_only_affects_target_printer(self, manager):
+        """Consuming plate cleared for one printer should not affect others."""
+        manager.set_plate_cleared(1)
+        manager.set_plate_cleared(2)
+        manager.consume_plate_cleared(1)
+        assert not manager.is_plate_cleared(1)
+        assert manager.is_plate_cleared(2)
+
+
+class TestSchedulerIdleCheckWithPlateCleared:
+    """Test _is_printer_idle with plate-cleared flag interactions."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_idle_state_is_idle(self, mock_pm, scheduler):
+        """Printer in IDLE state should be considered idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="IDLE")
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_running_state_not_idle(self, mock_pm, scheduler):
+        """Printer in RUNNING state should not be idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FINISH state should NOT be idle without plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_idle_with_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FINISH state should be idle when plate is cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = True
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FAILED state should NOT be idle without plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_idle_with_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FAILED state should be idle when plate is cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = True
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
+        """Disconnected printer should never be idle."""
+        mock_pm.is_connected.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_status_not_idle(self, mock_pm, scheduler):
+        """Printer with no status should not be idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = None
+        assert scheduler._is_printer_idle(1) is False
+
+
+class TestSchedulerQueueCheckLogging:
+    """Test queue check logging when pending items are found (#374)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_check_queue_logs_pending_items(self, mock_pm, scheduler, caplog):
+        """Verify pending items are logged when found in check_queue."""
+        mock_item = MagicMock()
+        mock_item.id = 42
+        mock_item.printer_id = 1
+        mock_item.archive_id = 100
+        mock_item.library_file_id = None
+        mock_item.scheduled_time = None
+        mock_item.manual_start = False
+        mock_item.target_model = None
+
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [mock_item]
+
+        with (
+            patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx,
+            caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"),
+        ):
+            mock_db = AsyncMock()
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            await scheduler.check_queue()
+
+        queue_logs = [r for r in caplog.records if "Queue check" in r.message]
+        assert len(queue_logs) == 1
+        assert "1 pending items" in queue_logs[0].message
+        assert "42" in queue_logs[0].message  # item ID
+
+    @pytest.mark.asyncio
+    async def test_check_queue_no_log_when_empty(self, scheduler, caplog):
+        """Verify no queue log when no pending items found."""
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = []
+
+        with (
+            patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx,
+            caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"),
+        ):
+            mock_db = AsyncMock()
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            await scheduler.check_queue()
+
+        queue_logs = [r for r in caplog.records if "Queue check" in r.message]
+        assert len(queue_logs) == 0

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

@@ -12,6 +12,53 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 
+class TestApplyLogLevel:
+    """Tests for _apply_log_level() debug noise suppression."""
+
+    def test_debug_mode_suppresses_sqlalchemy_to_warning(self):
+        """Verify sqlalchemy.engine is set to WARNING (not INFO) in debug mode."""
+        import logging
+
+        from backend.app.api.routes.support import _apply_log_level
+
+        _apply_log_level(True)
+
+        assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
+
+    def test_debug_mode_suppresses_aiosqlite(self):
+        """Verify aiosqlite is set to WARNING in debug mode to prevent cursor noise."""
+        import logging
+
+        from backend.app.api.routes.support import _apply_log_level
+
+        _apply_log_level(True)
+
+        assert logging.getLogger("aiosqlite").level == logging.WARNING
+
+    def test_debug_mode_enables_httpcore_debug(self):
+        """Verify httpcore stays at DEBUG in debug mode."""
+        import logging
+
+        from backend.app.api.routes.support import _apply_log_level
+
+        _apply_log_level(True)
+
+        assert logging.getLogger("httpcore").level == logging.DEBUG
+
+    def test_non_debug_mode_suppresses_all_noisy_loggers(self):
+        """Verify all noisy loggers are set to WARNING in non-debug mode."""
+        import logging
+
+        from backend.app.api.routes.support import _apply_log_level
+
+        _apply_log_level(False)
+
+        assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
+        assert logging.getLogger("httpcore").level == logging.WARNING
+        assert logging.getLogger("httpx").level == logging.WARNING
+        assert logging.getLogger("paho.mqtt").level == logging.WARNING
+
+
 class TestAnonymizeMqttBroker:
     """Tests for _anonymize_mqtt_broker()."""
 

+ 242 - 0
backend/tests/unit/test_sync_ams_weights.py

@@ -0,0 +1,242 @@
+"""Unit tests for the AMS weight sync calculation logic.
+
+Tests the weight_used calculation and remain% validation extracted from
+the POST /inventory/sync-ams-weights endpoint, without requiring a database.
+"""
+
+import pytest
+
+from backend.app.api.routes.inventory import _find_tray_in_ams_data
+
+
+def _calc_weight_used(label_weight: int | None, remain: int) -> float:
+    """Reproduce the weight calculation from sync_weights_from_ams."""
+    lw = label_weight or 1000
+    return round(lw * (100 - remain) / 100.0, 1)
+
+
+def _is_valid_remain(remain_raw) -> tuple[bool, int]:
+    """Reproduce the remain% validation from sync_weights_from_ams.
+
+    Returns (is_valid, parsed_value).  parsed_value is only meaningful
+    when is_valid is True.
+    """
+    if remain_raw is None:
+        return False, 0
+    try:
+        val = int(remain_raw)
+    except (TypeError, ValueError):
+        return False, 0
+    if val < 0 or val > 100:
+        return False, val
+    return True, val
+
+
+class TestWeightCalculation:
+    """Test the weight_used = label_weight * (100 - remain) / 100 formula."""
+
+    def test_remain_100_means_no_usage(self):
+        """A full spool (remain=100) should have weight_used=0."""
+        assert _calc_weight_used(1000, 100) == 0.0
+
+    def test_remain_50_with_1000g_spool(self):
+        """Half-used 1000g spool should have weight_used=500."""
+        assert _calc_weight_used(1000, 50) == 500.0
+
+    def test_remain_0_means_fully_used(self):
+        """An empty spool (remain=0) should have weight_used equal to label_weight.
+
+        Unlike the on_ams_change guard, the sync endpoint processes remain=0
+        since it is a manual recovery tool.
+        """
+        assert _calc_weight_used(1000, 0) == 1000.0
+
+    def test_respects_label_weight_500g(self):
+        """500g spool at remain=50 should have weight_used=250."""
+        assert _calc_weight_used(500, 50) == 250.0
+
+    def test_respects_label_weight_250g(self):
+        """250g spool at remain=75 should have weight_used=62.5."""
+        assert _calc_weight_used(250, 75) == 62.5
+
+    def test_none_label_weight_defaults_to_1000(self):
+        """When label_weight is None, it defaults to 1000g."""
+        assert _calc_weight_used(None, 50) == 500.0
+
+    def test_result_is_rounded_to_one_decimal(self):
+        """Weight used should be rounded to 1 decimal place.
+
+        For a 1000g spool at remain=33, weight_used = 1000 * 67 / 100 = 670.0
+        """
+        assert _calc_weight_used(1000, 33) == 670.0
+
+    def test_odd_fraction_rounds_correctly(self):
+        """750g spool at remain=33 → 750 * 67/100 = 502.5."""
+        assert _calc_weight_used(750, 33) == 502.5
+
+    def test_small_spool_small_remain(self):
+        """200g spool at remain=1 → 200 * 99/100 = 198.0."""
+        assert _calc_weight_used(200, 1) == 198.0
+
+
+class TestRemainValidation:
+    """Test the remain% bounds and type validation."""
+
+    def test_remain_minus_1_is_invalid(self):
+        """remain=-1 (firmware 'unknown') should be skipped."""
+        valid, _ = _is_valid_remain(-1)
+        assert valid is False
+
+    def test_remain_101_is_invalid(self):
+        """remain=101 (out of range) should be skipped."""
+        valid, _ = _is_valid_remain(101)
+        assert valid is False
+
+    def test_remain_negative_large_is_invalid(self):
+        """Large negative remain values should be skipped."""
+        valid, _ = _is_valid_remain(-50)
+        assert valid is False
+
+    def test_remain_200_is_invalid(self):
+        """remain=200 should be skipped."""
+        valid, _ = _is_valid_remain(200)
+        assert valid is False
+
+    def test_remain_none_is_invalid(self):
+        """remain=None (missing from tray data) should be skipped."""
+        valid, _ = _is_valid_remain(None)
+        assert valid is False
+
+    def test_remain_non_numeric_string_is_invalid(self):
+        """Non-numeric string remain should be skipped."""
+        valid, _ = _is_valid_remain("abc")
+        assert valid is False
+
+    def test_remain_0_is_valid(self):
+        """remain=0 should be valid (manual recovery handles empty spools)."""
+        valid, val = _is_valid_remain(0)
+        assert valid is True
+        assert val == 0
+
+    def test_remain_100_is_valid(self):
+        """remain=100 should be valid."""
+        valid, val = _is_valid_remain(100)
+        assert valid is True
+        assert val == 100
+
+    def test_remain_50_is_valid(self):
+        """remain=50 should be valid."""
+        valid, val = _is_valid_remain(50)
+        assert valid is True
+        assert val == 50
+
+    def test_remain_string_number_is_valid(self):
+        """Numeric string remain (e.g. '75') should be parsed as int."""
+        valid, val = _is_valid_remain("75")
+        assert valid is True
+        assert val == 75
+
+
+class TestFindTrayInAmsData:
+    """Test the _find_tray_in_ams_data helper used by the sync endpoint."""
+
+    def test_finds_matching_tray(self):
+        """Should return the matching tray dict."""
+        ams_data = [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0, "remain": 80},
+                    {"id": 1, "remain": 50},
+                ],
+            },
+        ]
+        tray = _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=1)
+        assert tray is not None
+        assert tray["remain"] == 50
+
+    def test_returns_none_for_missing_ams_unit(self):
+        """Should return None when the AMS unit ID is not found."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        assert _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=0) is None
+
+    def test_returns_none_for_missing_tray(self):
+        """Should return None when the tray ID is not found."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        assert _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=3) is None
+
+    def test_returns_none_for_empty_data(self):
+        """Should return None for empty AMS data."""
+        assert _find_tray_in_ams_data([], ams_id=0, tray_id=0) is None
+
+    def test_returns_none_for_none_data(self):
+        """Should return None for None AMS data."""
+        assert _find_tray_in_ams_data(None, ams_id=0, tray_id=0) is None
+
+    def test_multi_ams_unit_lookup(self):
+        """Should find trays across multiple AMS units."""
+        ams_data = [
+            {"id": 0, "tray": [{"id": 0, "remain": 80}]},
+            {"id": 1, "tray": [{"id": 2, "remain": 30}]},
+        ]
+        tray = _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=2)
+        assert tray is not None
+        assert tray["remain"] == 30
+
+    def test_ams_ht_high_id(self):
+        """Should find trays in AMS-HT units (id >= 128)."""
+        ams_data = [{"id": 128, "tray": [{"id": 0, "remain": 65}]}]
+        tray = _find_tray_in_ams_data(ams_data, ams_id=128, tray_id=0)
+        assert tray is not None
+        assert tray["remain"] == 65
+
+
+class TestSyncSkipLogic:
+    """Test combinations that exercise the sync/skip decision path."""
+
+    def test_same_value_is_skipped(self):
+        """When old weight_used matches new, the spool is skipped (no DB write)."""
+        # Simulating the endpoint logic: if round(old_used, 1) == new_used → skip
+        label_weight = 1000
+        remain = 50
+        new_used = _calc_weight_used(label_weight, remain)
+        old_used = 500.0  # Already matches
+        assert round(old_used, 1) == new_used  # → would be skipped
+
+    def test_different_value_is_synced(self):
+        """When old weight_used differs from new, the spool is synced."""
+        label_weight = 1000
+        remain = 50
+        new_used = _calc_weight_used(label_weight, remain)
+        old_used = 300.0  # Different
+        assert round(old_used, 1) != new_used  # → would be synced
+
+    def test_none_old_used_treated_as_zero(self):
+        """When old weight_used is None (new spool), it defaults to 0."""
+        old_used = None
+        effective_old = old_used or 0
+        new_used = _calc_weight_used(1000, 80)  # 200.0
+        assert effective_old == 0
+        assert round(effective_old, 1) != new_used  # → would be synced
+
+    def test_remain_0_synced_not_skipped(self):
+        """remain=0 is valid and produces weight_used=label_weight.
+
+        This is distinct from on_ams_change behavior where remain=0 is
+        ignored.  The sync endpoint processes it as a manual recovery tool.
+        """
+        valid, val = _is_valid_remain(0)
+        assert valid is True
+        new_used = _calc_weight_used(1000, val)
+        assert new_used == 1000.0
+
+    def test_remain_minus_1_never_reaches_calc(self):
+        """remain=-1 fails validation before weight calculation."""
+        valid, _ = _is_valid_remain(-1)
+        assert valid is False
+        # The endpoint would skip += 1 and continue
+
+    def test_remain_101_never_reaches_calc(self):
+        """remain=101 fails validation before weight calculation."""
+        valid, _ = _is_valid_remain(101)
+        assert valid is False

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

@@ -0,0 +1,726 @@
+"""Unit tests for usage_tracker.py — 3MF-primary filament tracking.
+
+Tests the unified tracking logic: 3MF slicer estimates as primary path,
+AMS remain% delta as fallback, per-layer gcode for partial prints,
+slot-to-tray mapping resolution, and notification variable formatting.
+"""
+
+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,
+    on_print_start,
+)
+
+
+def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
+    """Create a mock Spool object."""
+    spool = MagicMock()
+    spool.id = spool_id
+    spool.label_weight = label_weight
+    spool.weight_used = weight_used
+    spool.tag_uid = tag_uid
+    spool.tray_uuid = tray_uuid
+    spool.last_used = None
+    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="archives/1/test.3mf", extra_data=None):
+    """Create a mock PrintArchive object."""
+    archive = MagicMock()
+    archive.id = archive_id
+    archive.file_path = file_path
+    archive.extra_data = extra_data
+    return archive
+
+
+def _make_queue_item(ams_mapping=None, status="printing"):
+    """Create a mock PrintQueueItem object."""
+    item = MagicMock()
+    item.ams_mapping = ams_mapping
+    item.status = status
+    return item
+
+
+def _mock_db_execute(*return_values):
+    """Create a mock db with execute() that returns values in sequence."""
+    db = AsyncMock()
+    results = []
+    for val in return_values:
+        result = MagicMock()
+        result.scalar_one_or_none.return_value = val
+        results.append(result)
+    db.execute = AsyncMock(side_effect=results)
+    return db
+
+
+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 TestOnPrintStart:
+    """Tests for on_print_start()."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_captures_remain_data(self):
+        """Captures AMS remain% at print start."""
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]}
+        )
+
+        await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
+
+        assert 1 in _active_sessions
+        session = _active_sessions[1]
+        assert session.print_name == "Benchy"
+        assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
+
+    @pytest.mark.asyncio
+    async def test_creates_session_without_remain(self):
+        """Creates session even without valid remain data (for 3MF tracking)."""
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
+        )
+
+        await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
+
+        assert 1 in _active_sessions
+        assert _active_sessions[1].tray_remain_start == {}
+
+
+class TestOnPrintComplete:
+    """Tests for on_print_complete() — path ordering and interaction."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_bl_spool_uses_3mf(self):
+        """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
+        spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000)
+        assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)
+        archive = _make_archive(archive_id=10)
+
+        # Setup: session with AMS remain data
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        # Mock printer state: tray_now=0 (AMS0-T0), single filament
+        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": 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_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,
+            )
+
+        # 3MF path should handle it (BL guard removed)
+        assert len(results) >= 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 15.0
+
+    @pytest.mark.asyncio
+    async def test_ams_delta_fallback_no_archive(self):
+        """AMS delta tracks consumption when archive_id is None."""
+        spool = _make_spool(spool_id=2, label_weight=1000)
+        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}]}]},
+        )
+
+        # db returns assignment then spool
+        db = _mock_db_sequential([assignment, spool])
+
+        results = await on_print_complete(
+            printer_id=1,
+            data={"status": "completed"},
+            printer_manager=printer_manager,
+            db=db,
+            archive_id=None,
+        )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 2
+        # 10% of 1000g = 100g
+        assert results[0]["weight_used"] == 100.0
+        assert results[0]["percent_used"] == 10
+
+    @pytest.mark.asyncio
+    async def test_no_double_tracking(self):
+        """When 3MF handles a tray, AMS delta skips it."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        # tray_now=0 matches the single filament slot
+        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": 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_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,
+            )
+
+        # Only 1 result (3MF), NOT 2 (3MF + AMS delta)
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 15.0
+
+
+class TestTrackFrom3mf:
+    """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
+
+    @pytest.mark.asyncio
+    async def test_linear_fallback_for_partial_print(self):
+        """Falls back to linear scaling when gcode layer data unavailable."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=50,
+            layer_num=25,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.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 layer data 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=10,
+                status="failed",
+                print_name="Benchy",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        # 50% of 20g = 10g
+        assert results[0]["weight_used"] == 10.0
+        # Tray should be marked as handled
+        assert (0, 0) in handled_trays
+
+    @pytest.mark.asyncio
+    async def test_per_layer_partial_print(self):
+        """Failed print at layer N uses gcode cumulative data."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=50,
+            layer_num=25,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
+        # Per-layer data: at layer 25, filament 0 used 5000mm
+        layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
+        filament_props = {1: {"density": 1.24, "diameter": 1.75}}
+        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=layer_data,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
+                return_value={0: 5000.0},
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
+                return_value=filament_props,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.mm_to_grams",
+                return_value=12.0,  # 5000mm at 1.75mm/1.24g/cm3
+            ),
+        ):
+            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="failed",
+                print_name="Benchy",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        # Should use per-layer grams (12.0g), not linear scale (10.0g)
+        assert results[0]["weight_used"] == 12.0
+
+    @pytest.mark.asyncio
+    async def test_completed_print_uses_full_weight(self):
+        """Completed print uses full 3MF weight (scale=1.0)."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.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="Benchy",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 20.0
+
+    @pytest.mark.asyncio
+    async def test_tray_now_override_for_single_filament(self):
+        """Single-filament non-queue print uses tray_now instead of slot_id mapping."""
+        # Spool 2 is at AMS1-T3 (global_tray_id=7)
+        spool = _make_spool(spool_id=2, label_weight=1000)
+        assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=7,
+        )
+
+        # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)
+        filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}]
+        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,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 2
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 3
+        assert results[0]["weight_used"] == 10.6
+        assert (1, 3) in handled_trays
+
+    @pytest.mark.asyncio
+    async def test_queue_ams_mapping_overrides_default(self):
+        """Queue item ams_mapping overrides default slot_id mapping."""
+        # Spool at AMS1-T3 (global_tray_id=7)
+        spool = _make_spool(spool_id=5, label_weight=1000)
+        assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)
+        archive = _make_archive(archive_id=20)
+        # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)
+        queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]")
+
+        # db: archive, queue_item, assignment, spool
+        db = _mock_db_sequential([archive, queue_item, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=7,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "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=20,
+                status="completed",
+                print_name="Queue Print",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 5
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 3
+        assert results[0]["weight_used"] == 25.0
+
+    @pytest.mark.asyncio
+    async def test_multi_filament_uses_queue_mapping(self):
+        """Multi-filament queue prints use ams_mapping for each slot."""
+        spool_a = _make_spool(spool_id=1, label_weight=1000)
+        spool_b = _make_spool(spool_id=2, 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=1, tray_id=2)
+        archive = _make_archive(archive_id=30)
+        # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)
+        queue_item = _make_queue_item(ams_mapping="[0, 6]")
+
+        # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b
+        db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=6,
+        )
+
+        filament_usage = [
+            {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
+            {"slot_id": 2, "used_g": 5.0, "type": "PETG", "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=30,
+                status="completed",
+                print_name="Multi",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 2
+        assert results[0]["spool_id"] == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 0
+        assert results[0]["weight_used"] == 10.0
+        assert results[1]["spool_id"] == 2
+        assert results[1]["ams_id"] == 1
+        assert results[1]["tray_id"] == 2
+        assert results[1]["weight_used"] == 5.0
+
+    @pytest.mark.asyncio
+    async def test_no_tray_now_override_for_multi_filament(self):
+        """Multi-filament non-queue prints fall back to default mapping, not tray_now."""
+        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=10)
+
+        # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)
+        db = _mock_db_sequential([archive, None, assignment, spool, None])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=4,  # tray_now won't be used
+        )
+
+        # Two filament slots with usage
+        filament_usage = [
+            {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
+            {"slot_id": 2, "used_g": 5.0, "type": "PETG", "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,
+            )
+
+        # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)
+        assert len(results) == 1  # Only slot 1 has assignment
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 0
+
+
+class TestNotificationVariables:
+    """Tests for filament_details formatting in notifications."""
+
+    def test_filament_details_single_slot(self):
+        """Single slot produces 'PLA: 15.2g' format."""
+        slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}]
+        parts = []
+        for slot in slots:
+            ftype = slot.get("type", "Unknown") or "Unknown"
+            used = slot.get("used_g", 0)
+            parts.append(f"{ftype}: {used:.1f}g")
+        result = " | ".join(parts)
+        assert result == "PLA: 15.2g"
+
+    def test_filament_details_multi_slot(self):
+        """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format."""
+        slots = [
+            {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""},
+            {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""},
+        ]
+        parts = []
+        for slot in slots:
+            ftype = slot.get("type", "Unknown") or "Unknown"
+            used = slot.get("used_g", 0)
+            parts.append(f"{ftype}: {used:.1f}g")
+        result = " | ".join(parts)
+        assert result == "PLA: 10.0g | PETG: 5.0g"
+
+    def test_filament_details_empty_type(self):
+        """Empty type defaults to 'Unknown'."""
+        slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}]
+        parts = []
+        for slot in slots:
+            ftype = slot.get("type", "Unknown") or "Unknown"
+            used = slot.get("used_g", 0)
+            parts.append(f"{ftype}: {used:.1f}g")
+        result = " | ".join(parts)
+        assert result == "Unknown: 5.0g"
+
+    def test_filament_grams_scaled_for_partial(self):
+        """filament_grams is scaled by progress for partial prints."""
+        filament_used_grams = 20.0
+        progress = 50
+        scale = max(0.0, min(progress / 100.0, 1.0))
+        scaled = round(filament_used_grams * scale, 1)
+        assert scaled == 10.0
+
+    def test_filament_grams_zero_progress(self):
+        """Progress=0 at cancellation gives 0.0g."""
+        filament_used_grams = 20.0
+        progress = 0
+        scale = max(0.0, min(progress / 100.0, 1.0))
+        scaled = round(filament_used_grams * scale, 1)
+        assert scaled == 0.0
+
+    def test_slot_scaling_for_partial(self):
+        """Per-slot usage is scaled linearly for partial prints."""
+        slots = [
+            {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""},
+            {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""},
+        ]
+        progress = 30
+        scale = max(0.0, min(progress / 100.0, 1.0))
+        scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
+        assert scaled_slots[0]["used_g"] == 6.0
+        assert scaled_slots[1]["used_g"] == 3.0

+ 193 - 0
docker-publish-beta.sh

@@ -0,0 +1,193 @@
+#!/bin/bash
+# Build and push multi-architecture Docker image to GHCR (private beta)
+#
+# Usage:
+#   ./docker-publish-beta.sh [version] [--parallel]
+#
+# Examples:
+#   ./docker-publish-beta.sh 0.2.0b            # Sequential build
+#   ./docker-publish-beta.sh 0.2.0b --parallel # Build both archs simultaneously
+#
+# All versions are also tagged as 'beta'
+#
+# Prerequisites:
+#   1. Log in to ghcr.io:
+#      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
+#
+#   2. Create a GitHub Personal Access Token with 'write:packages' scope:
+#      https://github.com/settings/tokens/new?scopes=write:packages
+#
+#   3. After first push, set package to Private in GitHub → Packages → Settings
+#      and add beta testers via Manage Access
+#
+# Supported architectures:
+#   - linux/amd64 (x86_64, most servers/desktops)
+#   - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)
+
+set -e
+
+# Configuration
+GHCR_REGISTRY="ghcr.io"
+IMAGE_NAME="maziggy/bambuddy-beta"
+GHCR_IMAGE="${GHCR_REGISTRY}/${IMAGE_NAME}"
+PLATFORMS="linux/amd64,linux/arm64"
+BUILDER_NAME="bambuddy-builder"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Parse arguments
+VERSION=""
+PARALLEL=false
+for arg in "$@"; do
+    case $arg in
+        --parallel)
+            PARALLEL=true
+            ;;
+        *)
+            if [ -z "$VERSION" ]; then
+                VERSION="$arg"
+            fi
+            ;;
+    esac
+done
+
+if [ -z "$VERSION" ]; then
+    echo -e "${YELLOW}Usage: $0 <version> [--parallel]${NC}"
+    echo "Example: $0 0.2.0b"
+    echo "         $0 0.2.0b --parallel  # Build both architectures simultaneously"
+    exit 1
+fi
+
+# Get CPU count
+CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
+
+echo -e "${GREEN}================================================${NC}"
+echo -e "${GREEN}  Building multi-arch BETA image${NC}"
+echo -e "${GREEN}  Version: ${VERSION}${NC}"
+echo -e "${GREEN}  Platforms: ${PLATFORMS}${NC}"
+echo -e "${GREEN}  CPU cores: ${CPU_COUNT}${NC}"
+if [ "$PARALLEL" = true ]; then
+    echo -e "${GREEN}  Mode: PARALLEL (both archs simultaneously)${NC}"
+else
+    echo -e "${GREEN}  Mode: Sequential (amd64 → arm64)${NC}"
+fi
+echo -e "${GREEN}  Registry: ${GHCR_IMAGE}${NC}"
+echo -e "${GREEN}================================================${NC}"
+echo ""
+
+# Check registry login
+if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
+    echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
+    echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
+    echo ""
+fi
+
+# Setup buildx builder if not exists
+echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
+if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
+    echo "Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)"
+    docker buildx create \
+        --name "$BUILDER_NAME" \
+        --driver docker-container \
+        --driver-opt network=host \
+        --driver-opt "env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000" \
+        --buildkitd-flags "--allow-insecure-entitlement network.host --oci-worker-gc=false" \
+        --config /dev/stdin <<EOF
+[worker.oci]
+  max-parallelism = ${CPU_COUNT}
+EOF
+    docker buildx inspect --bootstrap "$BUILDER_NAME"
+fi
+docker buildx use "$BUILDER_NAME"
+
+# Verify builder supports multi-platform
+echo -e "${BLUE}[2/4] Verifying multi-platform support...${NC}"
+if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
+    echo -e "${YELLOW}Installing QEMU for cross-platform builds...${NC}"
+    docker run --privileged --rm tonistiigi/binfmt --install all
+fi
+
+# Build tags — version + beta (not latest)
+TAGS="-t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:beta"
+
+echo -e "${BLUE}[3/4] Building and pushing...${NC}"
+
+# Common build args (no cache to ensure clean builds)
+BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
+
+if [ "$PARALLEL" = true ]; then
+    # Parallel build: Build each architecture separately then combine manifests
+    echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}"
+
+    # Build amd64 in background
+    (
+        echo -e "${BLUE}[amd64] Starting build...${NC}"
+        docker buildx build \
+            --platform linux/amd64 \
+            -t "${GHCR_IMAGE}:${VERSION}-amd64" \
+            ${BUILD_ARGS} \
+            --push \
+            . 2>&1 | sed 's/^/[amd64] /'
+        echo -e "${GREEN}[amd64] Complete!${NC}"
+    ) &
+    PID_AMD64=$!
+
+    # Build arm64 in background
+    (
+        echo -e "${BLUE}[arm64] Starting build...${NC}"
+        docker buildx build \
+            --platform linux/arm64 \
+            -t "${GHCR_IMAGE}:${VERSION}-arm64" \
+            ${BUILD_ARGS} \
+            --push \
+            . 2>&1 | sed 's/^/[arm64] /'
+        echo -e "${GREEN}[arm64] Complete!${NC}"
+    ) &
+    PID_ARM64=$!
+
+    # Wait for both builds
+    echo "Waiting for parallel builds to complete..."
+    wait $PID_AMD64
+    wait $PID_ARM64
+
+    # Create multi-arch manifest
+    echo -e "${BLUE}Creating multi-arch manifest...${NC}"
+    docker buildx imagetools create \
+        -t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:beta" \
+        "${GHCR_IMAGE}:${VERSION}-amd64" \
+        "${GHCR_IMAGE}:${VERSION}-arm64"
+else
+    # Sequential build (default): Build both platforms in one command
+    echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
+    DOCKER_BUILDKIT=1 docker buildx build \
+        --platform "$PLATFORMS" \
+        ${BUILD_ARGS} \
+        $TAGS \
+        --push \
+        .
+fi
+
+echo -e "${BLUE}[4/4] Verifying manifest...${NC}"
+docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}"
+
+echo ""
+echo -e "${GREEN}================================================${NC}"
+echo -e "${GREEN}  Successfully pushed multi-arch BETA image:${NC}"
+echo -e "${GREEN}================================================${NC}"
+echo "  ${GHCR_IMAGE}:${VERSION}"
+echo "  ${GHCR_IMAGE}:beta"
+echo ""
+echo -e "${BLUE}Supported platforms:${NC}"
+echo "  - linux/amd64 (Intel/AMD servers, desktops)"
+echo "  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
+echo ""
+echo -e "${GREEN}Beta testers can run:${NC}"
+echo "  docker pull ${GHCR_IMAGE}:${VERSION}"
+echo "  docker pull ${GHCR_IMAGE}:beta"
+echo ""
+echo -e "${YELLOW}Reminder: Set package to Private in GitHub → Packages → Settings${NC}"

+ 2 - 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 InventoryPage from './pages/InventoryPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
@@ -122,6 +123,7 @@ function App() {
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
+                  <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />

+ 1 - 0
frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx

@@ -38,6 +38,7 @@ const mockPrinterStatus = {
   remaining_time: 0,
   filename: null,
   wifi_signal: -50,
+  vt_tray: [],
 };
 
 describe('AddPrinterModal Discovery', () => {

+ 134 - 0
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -0,0 +1,134 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { AssignSpoolModal } from '../../components/AssignSpoolModal';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn(),
+    getAssignments: vi.fn(),
+    assignSpool: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+}));
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  printerId: 1,
+  amsId: 0,
+  trayId: 0,
+  trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },
+};
+
+const manualSpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  weight_used: 0,
+  tag_uid: null,
+  tray_uuid: null,
+};
+
+const blSpool = {
+  id: 2,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Bambu',
+  color_name: 'Jade White',
+  rgba: 'FFFFFFFE',
+  label_weight: 1000,
+  weight_used: 50,
+  tag_uid: '05CC1E0F00000100',
+  tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+};
+
+const anotherManualSpool = {
+  id: 3,
+  material: 'PETG',
+  subtype: 'HF',
+  brand: 'Overture',
+  color_name: 'Black',
+  rgba: '000000FF',
+  label_weight: 1000,
+  weight_used: 200,
+  tag_uid: null,
+  tray_uuid: null,
+};
+
+describe('AssignSpoolModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);
+    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+  });
+
+  it('renders nothing when closed', () => {
+    render(<AssignSpoolModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
+  });
+
+  it('filters out Bambu Lab spools (with tag_uid/tray_uuid)', async () => {
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+
+    // Manual spools should be visible
+    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    expect(screen.getByText(/Overture/)).toBeInTheDocument();
+
+    // BL spool should NOT be visible
+    expect(screen.queryByText(/Jade White/)).not.toBeInTheDocument();
+  });
+
+  it('filters out spools already assigned to other slots', async () => {
+    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
+    ]);
+
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+
+    // Spool 1 (not assigned) should be visible
+    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+
+    // Spool 3 (assigned to another slot) should NOT be visible
+    expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
+  });
+
+  it('keeps spool visible if assigned to the current slot', async () => {
+    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
+    ]);
+
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+
+    // Spool 1 (assigned to current slot) should still be visible for re-assignment
+    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+  });
+
+  it('shows noManualSpools message when all spools are BL or assigned', async () => {
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([blSpool]);
+
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/No manually added spools/i)).toBeInTheDocument();
+    });
+  });
+});

+ 77 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -18,6 +18,11 @@ vi.mock('../../api/client', () => ({
     saveSlotPreset: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    getLocalPresets: vi.fn(),
+    getBuiltinFilaments: vi.fn(),
+    searchColors: vi.fn(),
+    getColorCatalog: vi.fn(),
+    resetAmsSlot: vi.fn(),
   },
 }));
 
@@ -69,10 +74,17 @@ const defaultProps = {
 describe('ConfigureAmsSlotModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    // Mock scrollIntoView which is not available in jsdom
+    Element.prototype.scrollIntoView = vi.fn();
     (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
     (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);
     (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
     (api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
+    (api.getLocalPresets as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: [] });
+    (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.searchColors as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.getColorCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.resetAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, message: 'ok' });
   });
 
   it('renders nothing visible when closed', () => {
@@ -204,4 +216,69 @@ describe('ConfigureAmsSlotModal', () => {
     const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
     expect(configureButton).toBeInTheDocument();
   });
+
+  it('filters presets by printer model', async () => {
+    // Render with printerModel="H2D"
+    render(<ConfigureAmsSlotModal {...defaultProps} printerModel="H2D" />);
+    // Wait for presets to load - the H2D preset should be visible
+    await waitFor(() => {
+      expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();
+    });
+    // The X1C preset should NOT be visible (filtered out by model)
+    expect(screen.queryByText(/Bambu PLA Basic @BBL X1C/)).not.toBeInTheDocument();
+  });
+
+  it('shows current preset even when it does not match model filter', async () => {
+    // Render with printerModel="H2D" but savedPresetId pointing to the X1C preset
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'GFSL05_09',  // X1C preset
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} printerModel="H2D" />);
+    await waitFor(() => {
+      // Both should be visible - H2D matches model, X1C is saved preset
+      // Use the full preset name to match the list item (not the "Filtering for" label)
+      expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
+      expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();
+    });
+  });
+
+  it('pre-selects saved preset when opening configured slot', async () => {
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'GFSL05_09',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+    await waitFor(() => {
+      // The saved preset should have the selected style (green border)
+      // Use the full preset name to avoid matching the "Filtering for" label
+      const presetButton = screen.getByText('Bambu PLA Basic @BBL X1C').closest('button');
+      expect(presetButton).toHaveClass('bg-bambu-green/20');
+    });
+  });
+
+  it('pre-populates color from trayColor', async () => {
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      trayColor: 'FF0000FF',  // Red with alpha
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+    // The hex display should show the pre-populated color
+    expect(screen.getByText('Hex: #FF0000', { exact: false })).toBeInTheDocument();
+  });
+
+  it('uses translated text for modal elements', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByText('Configure AMS Slot')).toBeInTheDocument();
+      expect(screen.getByText('Filament Profile')).toBeInTheDocument();
+    });
+    // Check footer buttons
+    expect(screen.getByRole('button', { name: /Configure Slot/i })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument();
+  });
 });

+ 77 - 141
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -1,11 +1,11 @@
 /**
  * Tests for the LinkSpoolModal component.
  *
- * Tests the Spoolman link spool modal including:
- * - Displaying unlinked spools
- * - Selecting a spool to link
- * - Link success with toast notification
- * - Link error with toast notification
+ * Tests the inventory link-to-spool modal including:
+ * - Rendering modal with tag/tray info
+ * - Displaying untagged spools
+ * - Linking a spool via click
+ * - Search filtering
  */
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -16,10 +16,10 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
 // Mock the API client
 vi.mock('../../api/client', () => ({
   api: {
-    getUnlinkedSpools: vi.fn(),
-    linkSpool: vi.fn(),
+    getSpools: vi.fn(),
+    linkTagToSpool: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
-    getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
 }));
 
@@ -40,37 +40,56 @@ describe('LinkSpoolModal', () => {
   const defaultProps = {
     isOpen: true,
     onClose: vi.fn(),
+    tagUid: 'ABCD1234',
     trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
-    trayInfo: {
-      type: 'PLA Basic',
-      color: 'FF0000',
-      location: 'AMS A1',
-    },
+    printerId: 1,
+    amsId: 0,
+    trayId: 0,
   };
 
-  const mockUnlinkedSpools = [
+  const mockSpools = [
     {
       id: 1,
-      filament_name: 'PLA Red',
-      filament_material: 'PLA',
-      filament_color_hex: 'FF0000',
-      remaining_weight: 800,
-      location: 'Shelf A',
+      material: 'PLA',
+      brand: 'Generic',
+      subtype: '',
+      color_name: 'Red',
+      rgba: 'FF0000FF',
+      label_weight: 1000,
+      weight_used: 200,
+      tag_uid: null,
+      tray_uuid: null,
     },
     {
       id: 2,
-      filament_name: 'PETG Blue',
-      filament_material: 'PETG',
-      filament_color_hex: '0000FF',
-      remaining_weight: 500,
-      location: null,
+      material: 'PETG',
+      brand: 'Bambu',
+      subtype: 'Basic',
+      color_name: 'Blue',
+      rgba: '0000FFFF',
+      label_weight: 1000,
+      weight_used: 500,
+      tag_uid: null,
+      tray_uuid: null,
+    },
+    {
+      id: 3,
+      material: 'ABS',
+      brand: 'Brand',
+      subtype: '',
+      color_name: 'White',
+      rgba: 'FFFFFFFF',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: 'EXISTING_TAG',
+      tray_uuid: 'EXISTING_UUID',
     },
   ];
 
   beforeEach(() => {
     vi.clearAllMocks();
-    vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
-    vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
+    vi.mocked(api.getSpools).mockResolvedValue(mockSpools);
+    vi.mocked(api.linkTagToSpool).mockResolvedValue({});
   });
 
   describe('rendering', () => {
@@ -78,33 +97,21 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        // Look for the title in h2 element
-        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
-      });
-    });
-
-    it('displays tray info', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Basic')).toBeInTheDocument();
-        expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
       });
     });
 
-    it('displays tray UUID', async () => {
+    it('displays printer and tray info', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
+        expect(screen.getByText(/AMS 0 T0/)).toBeInTheDocument();
+        expect(screen.getByText(/Printer #1/)).toBeInTheDocument();
       });
     });
 
     it('shows loading state while fetching spools', async () => {
-      // Delay the response
-      vi.mocked(api.getUnlinkedSpools).mockImplementation(
-        () => new Promise(() => {})
-      );
+      vi.mocked(api.getSpools).mockImplementation(() => new Promise(() => {}));
 
       render(<LinkSpoolModal {...defaultProps} />);
 
@@ -113,132 +120,74 @@ describe('LinkSpoolModal', () => {
       });
     });
 
-    it('displays unlinked spools list', async () => {
+    it('displays untagged spools only', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-        expect(screen.getByText('PETG Blue')).toBeInTheDocument();
+        // Spools 1 and 2 have no tag_uid/tray_uuid — should be shown
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
+        expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
       });
-    });
-
-    it('shows message when no unlinked spools', async () => {
-      vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
 
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
-      });
+      // Spool 3 has tag_uid — should be filtered out
+      expect(screen.queryByText(/Brand ABS/)).not.toBeInTheDocument();
     });
 
     it('does not render when isOpen is false', () => {
       render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
-      expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
-    });
-  });
-
-  describe('spool selection', () => {
-    it('allows selecting a spool', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-      });
-
-      // Click to select spool
-      fireEvent.click(screen.getByText('PLA Red'));
-
-      // Should show check mark (via visual styling)
-      const selectedButton = screen.getByText('PLA Red').closest('button');
-      expect(selectedButton).toHaveClass('border-bambu-green');
-    });
-
-    it('link button is disabled until spool is selected', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-      });
-
-      const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
-      expect(linkButton).toBeDisabled();
-
-      // Select a spool
-      fireEvent.click(screen.getByText('PLA Red'));
-
-      expect(linkButton).not.toBeDisabled();
+      expect(screen.queryByRole('heading', { name: /link to spool/i })).not.toBeInTheDocument();
     });
   });
 
   describe('linking', () => {
-    it('calls linkSpool API on submit', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-      });
-
-      // Select a spool
-      fireEvent.click(screen.getByText('PLA Red'));
-
-      // Click link button
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
-
-      await waitFor(() => {
-        expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
-      });
-    });
-
-    it('shows success toast on successful link', async () => {
+    it('calls linkTagToSpool on spool click', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
       });
 
-      fireEvent.click(screen.getByText('PLA Red'));
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
 
       await waitFor(() => {
-        expect(mockShowToast).toHaveBeenCalledWith(
-          'Spool linked to Spoolman successfully',
-          'success'
-        );
+        expect(api.linkTagToSpool).toHaveBeenCalledWith(1, {
+          tag_uid: 'ABCD1234',
+          tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+          tag_type: 'bambulab',
+          data_origin: 'nfc_link',
+        });
       });
     });
 
-    it('calls onClose after successful link', async () => {
+    it('shows success toast and calls onClose', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
       });
 
-      fireEvent.click(screen.getByText('PLA Red'));
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
 
       await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalled();
         expect(defaultProps.onClose).toHaveBeenCalled();
       });
     });
 
-    it('shows error toast on link failure', async () => {
-      const errorMessage = 'Failed to update spool';
-      vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
+    it('shows error toast on failure', async () => {
+      vi.mocked(api.linkTagToSpool).mockRejectedValue(new Error('Link failed'));
 
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
       });
 
-      fireEvent.click(screen.getByText('PLA Red'));
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
 
       await waitFor(() => {
         expect(mockShowToast).toHaveBeenCalledWith(
-          `Failed to link spool: ${errorMessage}`,
+          expect.stringContaining('Link failed'),
           'error'
         );
       });
@@ -246,25 +195,13 @@ describe('LinkSpoolModal', () => {
   });
 
   describe('modal actions', () => {
-    it('calls onClose when cancel button is clicked', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Cancel')).toBeInTheDocument();
-      });
-
-      fireEvent.click(screen.getByText('Cancel'));
-      expect(defaultProps.onClose).toHaveBeenCalled();
-    });
-
     it('calls onClose when backdrop is clicked', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
       });
 
-      // Click the backdrop (the element with bg-black/60)
       const backdrop = document.querySelector('.bg-black\\/60');
       if (backdrop) {
         fireEvent.click(backdrop);
@@ -276,10 +213,9 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
       });
 
-      // Find and click the X button in the header
       const closeButtons = screen.getAllByRole('button');
       const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
       if (xButton) {

+ 32 - 0
frontend/src/__tests__/components/NotificationProviderCard.test.tsx

@@ -64,6 +64,7 @@ const createMockProvider = (
   on_ams_ht_humidity_high: false,
   on_ams_ht_temperature_high: false,
   on_plate_not_empty: true,
+  on_bed_cooled: false,
   on_queue_job_added: false,
   on_queue_job_assigned: false,
   on_queue_job_started: false,
@@ -370,3 +371,34 @@ describe('NotificationProviderCard Queue notifications', () => {
     });
   });
 });
+
+describe('NotificationProviderCard Bed Cooled notifications', () => {
+  describe('bed cooled toggle', () => {
+    it('includes on_bed_cooled in provider data when enabled', () => {
+      const provider = createMockProvider({ on_bed_cooled: true });
+      expect(provider.on_bed_cooled).toBe(true);
+    });
+
+    it('includes on_bed_cooled in provider data when disabled', () => {
+      const provider = createMockProvider({ on_bed_cooled: false });
+      expect(provider.on_bed_cooled).toBe(false);
+    });
+
+    it('defaults on_bed_cooled to false', () => {
+      const provider = createMockProvider();
+      expect(provider.on_bed_cooled).toBe(false);
+    });
+
+    it('bed cooled is independent from other print event toggles', () => {
+      const provider = createMockProvider({
+        on_print_complete: true,
+        on_bed_cooled: true,
+        on_plate_not_empty: false,
+      });
+
+      expect(provider.on_print_complete).toBe(true);
+      expect(provider.on_bed_cooled).toBe(true);
+      expect(provider.on_plate_not_empty).toBe(false);
+    });
+  });
+});

+ 1 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -67,7 +67,7 @@ describe('PrintModal', () => {
         return HttpResponse.json({ filaments: [] });
       }),
       http.get('/api/v1/printers/:id/status', () => {
-        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
       }),
       http.post('/api/v1/archives/:id/reprint', () => {
         return HttpResponse.json({ success: true });

+ 177 - 0
frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx

@@ -0,0 +1,177 @@
+/**
+ * Tests for the PrinterQueueWidget clear plate behavior.
+ *
+ * When the printer is in FINISH or FAILED state and has pending queue items,
+ * the widget shows a "Clear Plate & Start Next" button instead of the
+ * passive queue link. After clicking, it shows a confirmation state.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockQueueItems = [
+  {
+    id: 1,
+    printer_id: 1,
+    archive_id: 1,
+    position: 1,
+    status: 'pending',
+    archive_name: 'First Print',
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 3600,
+    scheduled_time: null,
+  },
+  {
+    id: 2,
+    printer_id: 1,
+    archive_id: 2,
+    position: 2,
+    status: 'pending',
+    archive_name: 'Second Print',
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 7200,
+    scheduled_time: null,
+  },
+];
+
+describe('PrinterQueueWidget - Clear Plate', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/queue/', ({ request }) => {
+        const url = new URL(request.url);
+        const printerId = url.searchParams.get('printer_id');
+        if (printerId === '1') {
+          return HttpResponse.json(mockQueueItems);
+        }
+        return HttpResponse.json([]);
+      }),
+      http.post('/api/v1/printers/:id/clear-plate', () => {
+        return HttpResponse.json({ success: true, message: 'Plate cleared' });
+      })
+    );
+  });
+
+  describe('clear plate button visibility', () => {
+    it('shows clear plate button when printer state is FINISH', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows clear plate button when printer state is FAILED', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows passive link when printer state is IDLE', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
+    });
+
+    it('shows passive link when printer state is RUNNING', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+    });
+
+    it('shows passive link when printerState is not provided', async () => {
+      render(<PrinterQueueWidget printerId={1} />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+    });
+  });
+
+  describe('clear plate button shows queue info', () => {
+    it('shows next item name in clear plate mode', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('First Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows additional items badge in clear plate mode', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('+1')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('clear plate action', () => {
+    it('shows confirmation state after clicking clear plate', async () => {
+      const user = userEvent.setup();
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Clear Plate & Start Next'));
+
+      await waitFor(() => {
+        // Both the widget confirmation and the toast show this text
+        const elements = screen.getAllByText('Plate cleared — ready for next print');
+        expect(elements.length).toBeGreaterThanOrEqual(1);
+      });
+    });
+
+    it('shows error toast on API failure', async () => {
+      server.use(
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          return HttpResponse.json(
+            { detail: 'Printer not connected' },
+            { status: 400 }
+          );
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Clear Plate & Start Next'));
+
+      // Button should remain visible (not transition to success state)
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('empty queue', () => {
+    it('renders nothing in FINISH state with no queue items', async () => {
+      const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(container.querySelector('button')).not.toBeInTheDocument();
+      });
+    });
+  });
+});

+ 186 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -0,0 +1,186 @@
+/**
+ * Tests for the SpoolFormModal weightTouched behavior.
+ *
+ * Verifies that weight_used is only included in the PATCH payload when the user
+ * explicitly changes the remaining weight field. This prevents stale React Query
+ * cache values from overwriting usage-tracked weight data on the backend.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import type { InventorySpool } from '../../api/client';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getColorCatalog: vi.fn().mockResolvedValue([]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+// Mock validateForm so we can bypass validation for the create-mode test
+// (editing tests pass validation naturally since the spool has material + slicer_filament)
+vi.mock('../../components/spool-form/types', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
+  return {
+    ...actual,
+    validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
+  };
+});
+
+// Mock the toast context
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { api } from '../../api/client';
+
+const existingSpool: InventorySpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 300,
+  slicer_filament: 'GFL99',
+  slicer_filament_name: 'Generic PLA',
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+};
+
+describe('SpoolFormModal weightTouched', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('excludes weight_used from PATCH when editing without changing weight', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+      />
+    );
+
+    // Wait for the modal to render with the edit title
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Click Save without touching the weight field
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // weight_used must NOT be present in the payload
+    expect(payload).not.toHaveProperty('weight_used');
+    // Other fields should still be present
+    expect(payload).toHaveProperty('material', 'PLA');
+    expect(payload).toHaveProperty('label_weight', 1000);
+  });
+
+  it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
+    // The input is a number input displaying 700. Find it by its displayed value.
+    const remainingInput = screen.getByDisplayValue('700');
+    expect(remainingInput).toBeInTheDocument();
+
+    // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
+    fireEvent.change(remainingInput, { target: { value: '500' } });
+
+    // Click Save
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // weight_used MUST be present since the user changed the weight
+    expect(payload).toHaveProperty('weight_used', 500);
+  });
+
+  it('includes weight_used when creating a new spool', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+      />
+    );
+
+    // Wait for the modal to render with the create title
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Click the submit button (validation is mocked to always pass).
+    // The default form data has weight_used=0, and for create mode the condition
+    //   if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
+    // always includes weight_used since isEditing is false.
+    // The submit button also says "Add Spool" — use getAllByText and pick the button.
+    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
+    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
+    expect(submitButton).toBeTruthy();
+    fireEvent.click(submitButton!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
+    // weight_used MUST be included for new spools (default value 0)
+    expect(payload).toHaveProperty('weight_used', 0);
+  });
+});

+ 58 - 120
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -1,15 +1,16 @@
 /**
  * Tests for the SpoolmanSettings component.
  *
- * Tests the Spoolman integration UI including:
- * - Enable/disable toggle
- * - URL configuration
- * - Connection status
- * - Sync functionality
+ * Tests the filament tracking mode selector and Spoolman integration UI:
+ * - Mode selector (Built-in Inventory vs Spoolman)
+ * - Built-in Inventory info panel
+ * - Spoolman URL, sync mode, connection status
+ * - Weight sync and partial usage toggles
  */
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { SpoolmanSettings } from '../../components/SpoolmanSettings';
 
@@ -26,6 +27,7 @@ vi.mock('../../api/client', () => ({
     syncAllPrintersAms: vi.fn(),
     syncPrinterAms: vi.fn(),
     getPrinters: vi.fn(),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
 }));
 
@@ -36,7 +38,7 @@ describe('SpoolmanSettings', () => {
   beforeEach(() => {
     vi.clearAllMocks();
 
-    // Default API mocks
+    // Default API mocks — Spoolman disabled (Built-in Inventory mode)
     vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
       spoolman_enabled: 'false',
       spoolman_url: '',
@@ -70,90 +72,61 @@ describe('SpoolmanSettings', () => {
 
   describe('rendering', () => {
     it('renders loading state initially', () => {
-      // Delay the API response to catch loading state
       vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
       render(<SpoolmanSettings />);
 
-      // Should show loading spinner
       expect(document.querySelector('.animate-spin')).toBeInTheDocument();
     });
 
-    it('renders component title', async () => {
+    it('renders filament tracking title', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
     });
 
-    it('renders enable toggle', async () => {
+    it('renders mode selector cards', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Enable Spoolman')).toBeInTheDocument();
-      });
-    });
-
-    it('renders URL input', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Spoolman URL')).toBeInTheDocument();
-        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
-      });
-    });
-
-    it('renders sync mode selector', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Sync Mode')).toBeInTheDocument();
-      });
-    });
-
-    it('renders info banner about sync', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('How Sync Works')).toBeInTheDocument();
-        expect(screen.getByText(/Only official Bambu Lab spools/)).toBeInTheDocument();
+        expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
+        expect(screen.getByText('Spoolman')).toBeInTheDocument();
       });
     });
   });
 
-  describe('disabled state', () => {
-    it('URL input is disabled when Spoolman is disabled', async () => {
+  describe('built-in inventory mode (default)', () => {
+    it('shows built-in inventory as selected by default', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
-        expect(urlInput).toBeDisabled();
+        // Built-in Inventory card should have the active border
+        const builtInBtn = screen.getByText('Built-in Inventory').closest('button');
+        expect(builtInBtn).toHaveClass('border-bambu-green');
       });
     });
 
-    it('sync mode selector is disabled when Spoolman is disabled', async () => {
+    it('shows built-in info panel when selected', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        // Find the select by its display value
-        const selectElement = screen.getByDisplayValue('Automatic');
-        expect(selectElement).toBeDisabled();
+        expect(screen.getByText(/Automatically detects Bambu Lab RFID spools/)).toBeInTheDocument();
       });
     });
 
-    it('does not show connection status when disabled', async () => {
+    it('does not show Spoolman URL input', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
 
-      // Status section should not be visible when disabled
-      expect(screen.queryByText('Status:')).not.toBeInTheDocument();
+      expect(screen.queryByPlaceholderText('http://192.168.1.100:7912')).not.toBeInTheDocument();
     });
   });
 
-  describe('enabled state', () => {
+  describe('spoolman mode', () => {
     beforeEach(() => {
       vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
         spoolman_enabled: 'true',
@@ -171,67 +144,62 @@ describe('SpoolmanSettings', () => {
       });
     });
 
-    it('URL input is enabled when Spoolman is enabled', async () => {
+    it('shows Spoolman card as selected', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
-        expect(urlInput).not.toBeDisabled();
+        const spoolmanBtn = screen.getByText('Spoolman').closest('button');
+        expect(spoolmanBtn).toHaveClass('border-bambu-green');
       });
     });
 
-    it('shows connection status section when enabled', async () => {
+    it('shows URL input when Spoolman is selected', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Status:')).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
       });
     });
 
-    it('shows Disconnected when not connected', async () => {
-      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
-        enabled: true,
-        connected: false,
-        url: 'http://localhost:7912',
-      });
-
+    it('shows sync mode selector', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Disconnected')).toBeInTheDocument();
+        expect(screen.getByText('Sync Mode')).toBeInTheDocument();
       });
     });
 
-    it('shows Connect button when disconnected', async () => {
-      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
-        enabled: true,
-        connected: false,
-        url: 'http://localhost:7912',
+    it('shows how sync works info', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('How Sync Works')).toBeInTheDocument();
       });
+    });
 
+    it('shows connection status section', async () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Connect')).toBeInTheDocument();
+        expect(screen.getByText('Status:')).toBeInTheDocument();
       });
     });
 
-    it('shows Connected and Disconnect button when connected', async () => {
+    it('shows Disconnected when not connected', async () => {
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
         enabled: true,
-        connected: true,
+        connected: false,
         url: 'http://localhost:7912',
       });
 
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Connected')).toBeInTheDocument();
-        expect(screen.getByText('Disconnect')).toBeInTheDocument();
+        expect(screen.getByText('Disconnected')).toBeInTheDocument();
       });
     });
 
-    it('shows sync section when connected', async () => {
+    it('shows Connected and Disconnect button when connected', async () => {
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
         enabled: true,
         connected: true,
@@ -241,12 +209,12 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
-        expect(screen.getByText('Sync')).toBeInTheDocument();
+        expect(screen.getByText('Connected')).toBeInTheDocument();
+        expect(screen.getByText('Disconnect')).toBeInTheDocument();
       });
     });
 
-    it('shows All Printers option in sync dropdown', async () => {
+    it('shows sync section when connected', async () => {
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
         enabled: true,
         connected: true,
@@ -256,13 +224,13 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument();
+        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
       });
     });
   });
 
   describe('weight sync toggle', () => {
-    it('shows weight sync toggle when sync mode is auto and enabled', async () => {
+    it('shows weight sync toggle when Spoolman enabled and sync mode is auto', async () => {
       vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
         spoolman_enabled: 'true',
         spoolman_url: 'http://localhost:7912',
@@ -290,20 +258,11 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
 
       expect(screen.queryByText('Disable AMS Estimated Weight Sync')).not.toBeInTheDocument();
     });
-
-    it('shows weight sync toggle in disabled state when sync mode is auto', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        // Toggle label is visible since sync mode defaults to auto
-        expect(screen.getByText('Disable AMS Estimated Weight Sync')).toBeInTheDocument();
-      });
-    });
   });
 
   describe('partial usage toggle', () => {
@@ -335,49 +294,28 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
 
       expect(screen.queryByText('Report Partial Usage for Failed Prints')).not.toBeInTheDocument();
     });
   });
 
-  describe('sync mode options', () => {
-    it('shows Automatic option', async () => {
+  describe('mode switching', () => {
+    it('can switch to Spoolman mode', async () => {
+      const user = userEvent.setup();
       render(<SpoolmanSettings />);
 
       await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument();
+        expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
       });
-    });
 
-    it('shows Manual Only option', async () => {
-      render(<SpoolmanSettings />);
+      // Click Spoolman card
+      await user.click(screen.getByText('Spoolman').closest('button')!);
 
+      // Spoolman settings should now be visible
       await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('info text', () => {
-    it('shows URL help text', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(
-          screen.getByText('URL of your Spoolman server (e.g., http://localhost:7912)')
-        ).toBeInTheDocument();
-      });
-    });
-
-    it('shows sync mode description for auto mode', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(
-          screen.getByText('AMS data syncs automatically when changes are detected')
-        ).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
       });
     });
   });

+ 182 - 3
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -13,7 +13,7 @@ import {
 import type { PrinterStatus } from '../../api/client';
 
 // Helper to create a minimal printer status with AMS data
-function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
+function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {
   return {
     ams,
     vt_tray,
@@ -89,7 +89,7 @@ describe('buildLoadedFilaments', () => {
   it('extracts external spool with tray_info_idx', () => {
     const status = createPrinterStatus(
       [],
-      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
     );
 
     const result = buildLoadedFilaments(status);
@@ -339,7 +339,7 @@ describe('computeAmsMapping', () => {
     };
     const status = createPrinterStatus(
       [],
-      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
     );
 
     const result = computeAmsMapping(reqs, status);
@@ -347,3 +347,182 @@ describe('computeAmsMapping', () => {
     expect(result).toEqual([254]);  // External spool global ID
   });
 });
+
+describe('buildLoadedFilaments - nozzle awareness', () => {
+  it('sets extruderId from ams_extruder_map', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].extruderId).toBe(1);  // AMS 0 → left nozzle
+    expect(result[1].extruderId).toBe(0);  // AMS 1 → right nozzle
+  });
+
+  it('leaves extruderId undefined when no ams_extruder_map', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].extruderId).toBeUndefined();
+  });
+});
+
+describe('computeAmsMapping - nozzle filtering', () => {
+  it('filters candidates by nozzle_id when set', () => {
+    // Filament requires left nozzle (extruder 1), only AMS 0 is on left
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // AMS 0, tray 0 (on left nozzle)
+  });
+
+  it('filters to right nozzle when nozzle_id=0', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
+  });
+
+  it('falls back to all trays when target nozzle has no trays at all', () => {
+    // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Right nozzle only
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 0 };  // AMS 0 → right nozzle, none on left
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Falls back to unfiltered (right nozzle PLA)
+  });
+
+  it('stays restricted when target nozzle has trays but wrong type', () => {
+    // Left nozzle has PETG, right has PLA — but requires PLA on left
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle - only PETG
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+      {
+        id: 1,  // Right nozzle - has PLA
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([-1]);  // No PLA on left nozzle, stays restricted
+  });
+
+  it('skips nozzle filtering when nozzle_id is undefined', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },  // No nozzle_id
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+      {
+        id: 1,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([4]);  // Picks best match regardless of nozzle
+  });
+
+  it('handles dual-nozzle multi-slot mapping', () => {
+    // Two filaments: one for left, one for right
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },  // Left
+        { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+        ],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [
+          { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
+        ],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
+  });
+});

+ 1 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -56,6 +56,7 @@ const mockPrinterStatus = {
   remaining_time: 0,
   filename: null,
   wifi_signal: -50,
+  vt_tray: [],
 };
 
 describe('PrintersPage', () => {

+ 4 - 3
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -32,6 +32,7 @@ const mockSettings = {
   ha_token: '',
   check_updates: false,
   check_printer_firmware: false,
+  bed_cooled_threshold: 35,
 };
 
 describe('SettingsPage', () => {
@@ -86,7 +87,7 @@ describe('SettingsPage', () => {
         expect(screen.getAllByText('General').length).toBeGreaterThan(0);
         expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
         expect(screen.getByText('Notifications')).toBeInTheDocument();
-        expect(screen.getByText('Filament')).toBeInTheDocument();
+        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
         expect(screen.getByText('Network')).toBeInTheDocument();
         expect(screen.getByText('API Keys')).toBeInTheDocument();
       });
@@ -207,10 +208,10 @@ describe('SettingsPage', () => {
       render(<SettingsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Filament')).toBeInTheDocument();
+        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
       });
 
-      await user.click(screen.getByText('Filament'));
+      await user.click(screen.getAllByText('Filament')[0]);
 
       await waitFor(() => {
         expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();

+ 1 - 1
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -46,7 +46,7 @@ const mockArchives = [
 ];
 
 const mockSettings = {
-  currency: '$',
+  currency: 'USD',
   check_updates: false,
   check_printer_firmware: false,
 };

+ 43 - 0
frontend/src/__tests__/utils/currency.test.ts

@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../../utils/currency';
+
+describe('getCurrencySymbol', () => {
+  it('returns $ for USD', () => {
+    expect(getCurrencySymbol('USD')).toBe('$');
+  });
+
+  it('returns € for EUR', () => {
+    expect(getCurrencySymbol('EUR')).toBe('€');
+  });
+
+  it('returns £ for GBP', () => {
+    expect(getCurrencySymbol('GBP')).toBe('£');
+  });
+
+  it('returns ₹ for INR', () => {
+    expect(getCurrencySymbol('INR')).toBe('₹');
+  });
+
+  it('returns HK$ for HKD', () => {
+    expect(getCurrencySymbol('HKD')).toBe('HK$');
+  });
+
+  it('returns the code itself for unknown currencies', () => {
+    expect(getCurrencySymbol('XYZ')).toBe('XYZ');
+  });
+
+  it('is case-insensitive', () => {
+    expect(getCurrencySymbol('usd')).toBe('$');
+    expect(getCurrencySymbol('eur')).toBe('€');
+  });
+});
+
+describe('SUPPORTED_CURRENCIES', () => {
+  it('contains INR', () => {
+    expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
+  });
+
+  it('has 25 entries', () => {
+    expect(SUPPORTED_CURRENCIES).toHaveLength(25);
+  });
+});

+ 316 - 16
frontend/src/api/client.ts

@@ -18,6 +18,18 @@ export function getAuthToken(): string | null {
   return authToken;
 }
 
+function parseContentDispositionFilename(header: string | null): string | null {
+  if (!header) return null;
+  // RFC 5987: filename*=utf-8''percent-encoded-name
+  const rfc5987Match = header.match(/filename\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/);
+  if (rfc5987Match) {
+    try { return decodeURIComponent(rfc5987Match[1]); } catch { /* fall through */ }
+  }
+  // Standard: filename="name" or filename=name
+  const standardMatch = header.match(/filename="?([^";\n]+)"?/);
+  return standardMatch?.[1] || null;
+}
+
 async function request<T>(
   endpoint: string,
   options: RequestInit = {}
@@ -192,7 +204,7 @@ export interface PrinterStatus {
   hms_errors: HMSError[];
   ams: AMSUnit[];
   ams_exists: boolean;
-  vt_tray: AMSTray | null;  // Virtual tray / external spool
+  vt_tray: AMSTray[];  // Virtual tray / external spool(s)
   sdcard: boolean;  // SD card inserted
   store_to_sdcard: boolean;  // Store sent files on SD card
   timelapse: boolean;  // Timelapse recording active
@@ -237,6 +249,7 @@ export interface PrinterStatus {
   big_fan1_speed: number | null;     // Auxiliary fan
   big_fan2_speed: number | null;     // Chamber/exhaust fan
   heatbreak_fan_speed: number | null; // Hotend heatbreak fan
+  firmware_version: string | null;   // Firmware version from MQTT
 }
 
 export interface PrinterCreate {
@@ -356,6 +369,28 @@ export interface Archive {
   created_by_username: string | null;
 }
 
+export interface PrintLogEntry {
+  id: number;
+  print_name: string | null;
+  printer_name: string | null;
+  printer_id: number | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  duration_seconds: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  filament_used_grams: number | null;
+  thumbnail_path: string | null;
+  created_by_username: string | null;
+  created_at: string;
+}
+
+export interface PrintLogResponse {
+  items: PrintLogEntry[];
+  total: number;
+}
+
 export interface ArchiveStats {
   total_prints: number;
   successful_prints: number;
@@ -778,6 +813,8 @@ export interface AppSettings {
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_token: string;
+  // Bed cooled threshold
+  bed_cooled_threshold: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -812,6 +849,29 @@ export interface SlicerSetting {
   version: string | null;
   user_id: string | null;
   updated_time: string | null;
+  is_custom: boolean;
+}
+
+export interface SpoolCatalogEntry {
+  id: number;
+  name: string;
+  weight: number;
+  is_default: boolean;
+}
+
+export interface ColorCatalogEntry {
+  id: number;
+  manufacturer: string;
+  color_name: string;
+  hex_color: string;
+  material: string | null;
+  is_default: boolean;
+}
+
+export interface ColorLookupResult {
+  found: boolean;
+  hex_color: string | null;
+  material: string | null;
 }
 
 export interface SlicerSettingsResponse {
@@ -853,6 +913,12 @@ export interface SlicerSettingDeleteResponse {
   message: string;
 }
 
+// Built-in filament fallback (static table from backend)
+export interface BuiltinFilament {
+  filament_id: string;
+  name: string;
+}
+
 // Local preset types (OrcaSlicer imports)
 export interface LocalPreset {
   id: number;
@@ -1360,6 +1426,8 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   on_plate_not_empty: boolean;
+  // Bed cooled
+  on_bed_cooled: boolean;
   // Print queue events
   on_queue_job_added: boolean;
   on_queue_job_assigned: boolean;
@@ -1410,6 +1478,8 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  // Bed cooled
+  on_bed_cooled?: boolean;
   // Print queue events
   on_queue_job_added?: boolean;
   on_queue_job_assigned?: boolean;
@@ -1453,6 +1523,8 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  // Bed cooled
+  on_bed_cooled?: boolean;
   // Print queue events
   on_queue_job_added?: boolean;
   on_queue_job_assigned?: boolean;
@@ -1686,6 +1758,85 @@ export interface LinkedSpoolsMap {
   linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
 }
 
+// Inventory types
+export interface InventorySpool {
+  id: number;
+  material: string;
+  subtype: string | null;
+  color_name: string | null;
+  rgba: string | null;
+  brand: string | null;
+  label_weight: number;
+  core_weight: number;
+  weight_used: number;
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+  nozzle_temp_min: number | null;
+  nozzle_temp_max: number | null;
+  note: string | null;
+  added_full: boolean | null;
+  last_used: string | null;
+  encode_time: string | null;
+  tag_uid: string | null;
+  tray_uuid: string | null;
+  data_origin: string | null;
+  tag_type: string | null;
+  archived_at: string | null;
+  created_at: string;
+  updated_at: string;
+  k_profiles?: SpoolKProfile[];
+}
+
+export interface SpoolUsageRecord {
+  id: number;
+  spool_id: number;
+  printer_id: number | null;
+  print_name: string | null;
+  weight_used: number;
+  percent_used: number;
+  status: string;
+  created_at: string;
+}
+
+export interface SpoolKProfile {
+  id: number;
+  spool_id: number;
+  printer_id: number;
+  extruder: number;
+  nozzle_diameter: string;
+  nozzle_type: string | null;
+  k_value: number;
+  name: string | null;
+  cali_idx: number | null;
+  setting_id: string | null;
+  created_at: string;
+}
+
+export interface SpoolKProfileInput {
+  printer_id: number;
+  extruder?: number;
+  nozzle_diameter?: string;
+  nozzle_type?: string | null;
+  k_value: number;
+  name?: string | null;
+  cali_idx?: number | null;
+  setting_id?: string | null;
+}
+
+export interface SpoolAssignment {
+  id: number;
+  spool_id: number;
+  printer_id: number;
+  printer_name: string | null;
+  ams_id: number;
+  tray_id: number;
+  fingerprint_color: string | null;
+  fingerprint_type: string | null;
+  spool?: InventorySpool | null;
+  configured: boolean;
+  created_at: string;
+}
+
 // Update types
 export interface VersionInfo {
   version: string;
@@ -1792,6 +1943,7 @@ export interface ExternalLink {
   name: string;
   url: string;
   icon: string;
+  open_in_new_tab: boolean;
   custom_icon: string | null;
   sort_order: number;
   created_at: string;
@@ -1802,12 +1954,14 @@ export interface ExternalLinkCreate {
   name: string;
   url: string;
   icon: string;
+  open_in_new_tab?: boolean;
 }
 
 export interface ExternalLinkUpdate {
   name?: string;
   url?: string;
   icon?: string;
+  open_in_new_tab?: boolean;
 }
 
 // Permission type - all available permissions
@@ -2019,7 +2173,7 @@ export const api = {
     request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
       method: 'POST',
     }),
-  
+
   // Advanced Authentication
   testSMTP: (data: TestSMTPRequest) =>
     request<TestSMTPResponse>('/auth/smtp/test', {
@@ -2155,6 +2309,10 @@ export const api = {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
       method: 'POST',
     }),
+  clearPlate: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
+      method: 'POST',
+    }),
 
   // Get current print user (for reprint tracking - Issue #206)
   getCurrentPrintUser: (printerId: number) =>
@@ -2263,8 +2421,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const filename = filenameMatch?.[1] || path.split('/').pop() || 'download';
+    const filename = parseContentDispositionFilename(disposition) || path.split('/').pop() || 'download';
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2464,8 +2621,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const downloadFilename = filenameMatch?.[1] || filename || `archive_${id}.3mf`;
+    const downloadFilename = parseContentDispositionFilename(disposition) || filename || `archive_${id}.3mf`;
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2605,8 +2761,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const filename = filenameMatch?.[1] || `source_${archiveId}.3mf`;
+    const filename = parseContentDispositionFilename(disposition) || `source_${archiveId}.3mf`;
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2655,8 +2810,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const filename = filenameMatch?.[1] || `archive_${archiveId}.f3d`;
+    const filename = parseContentDispositionFilename(disposition) || `archive_${archiveId}.f3d`;
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2822,6 +2976,32 @@ export const api = {
     return response.json();
   },
 
+  // Print Log
+  getPrintLog: (params?: {
+    search?: string;
+    printerId?: number;
+    username?: string;
+    status?: string;
+    dateFrom?: string;
+    dateTo?: string;
+    limit?: number;
+    offset?: number;
+  }) => {
+    const searchParams = new URLSearchParams();
+    if (params?.search) searchParams.set('search', params.search);
+    if (params?.printerId) searchParams.set('printer_id', String(params.printerId));
+    if (params?.username) searchParams.set('created_by_username', params.username);
+    if (params?.status) searchParams.set('status', params.status);
+    if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
+    if (params?.dateTo) searchParams.set('date_to', params.dateTo);
+    if (params?.limit) searchParams.set('limit', String(params.limit));
+    if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
+    return request<PrintLogResponse>(`/print-log/?${searchParams}`);
+  },
+  getPrintLogThumbnail: (id: number) => `${API_BASE}/print-log/${id}/thumbnail`,
+  clearPrintLog: () =>
+    request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
+
   // Settings
   getSettings: () => request<AppSettings>('/settings/'),
   updateSettings: (data: AppSettingsUpdate) =>
@@ -2903,6 +3083,10 @@ export const api = {
     request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
   getCloudSettings: (version = '02.04.00.70') =>
     request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
+  getBuiltinFilaments: () =>
+    request<BuiltinFilament[]>('/cloud/builtin-filaments'),
+  getFilamentIdMap: () =>
+    request<Record<string, string>>('/cloud/filament-id-map'),
   getCloudSettingDetail: (settingId: string) =>
     request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
   createCloudSetting: (data: SlicerSettingCreate) =>
@@ -3242,6 +3426,82 @@ export const api = {
       body: JSON.stringify(data),
     }),
 
+  // Inventory
+  getSpools: (includeArchived = false) =>
+    request<InventorySpool[]>(`/inventory/spools?include_archived=${includeArchived}`),
+  getSpool: (id: number) => request<InventorySpool>(`/inventory/spools/${id}`),
+  createSpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
+    request<InventorySpool>('/inventory/spools', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
+    request<InventorySpool>(`/inventory/spools/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteSpool: (id: number) =>
+    request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }),
+  archiveSpool: (id: number) =>
+    request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
+  restoreSpool: (id: number) =>
+    request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
+  getSpoolKProfiles: (spoolId: number) =>
+    request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
+  saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
+    request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`, {
+      method: 'PUT',
+      body: JSON.stringify(profiles),
+    }),
+  getAssignments: (printerId?: number) =>
+    request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
+  assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
+    request<SpoolAssignment>('/inventory/assignments', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  unassignSpool: (printerId: number, amsId: number, trayId: number) =>
+    request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
+  getSpoolCatalog: () =>
+    request<SpoolCatalogEntry[]>('/inventory/catalog'),
+  addCatalogEntry: (data: { name: string; weight: number }) =>
+    request<SpoolCatalogEntry>('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }),
+  updateCatalogEntry: (id: number, data: { name: string; weight: number }) =>
+    request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+  deleteCatalogEntry: (id: number) =>
+    request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),
+  resetSpoolCatalog: () =>
+    request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
+  getColorCatalog: () =>
+    request<ColorCatalogEntry[]>('/inventory/colors'),
+  addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
+    request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
+  updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
+    request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+  deleteColorEntry: (id: number) =>
+    request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
+  resetColorCatalog: () =>
+    request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
+  lookupColor: (manufacturer: string, colorName: string, material?: string) =>
+    request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),
+  searchColors: (manufacturer?: string, material?: string) =>
+    request<ColorCatalogEntry[]>(`/inventory/colors/search?${manufacturer ? `manufacturer=${encodeURIComponent(manufacturer)}` : ''}${manufacturer && material ? '&' : ''}${material ? `material=${encodeURIComponent(material)}` : ''}`),
+  linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>
+    request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  getSpoolUsageHistory: (spoolId: number, limit = 50) =>
+    request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
+  getAllUsageHistory: (limit = 100, printerId?: number) =>
+    request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
+  clearSpoolUsageHistory: (spoolId: number) =>
+    request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
+  syncWeightsFromAms: () =>
+    request<{ synced: number; skipped: number }>('/inventory/sync-ams-weights', { method: 'POST' }),
+  getFilamentPresets: () =>
+    request<SlicerSetting[]>('/cloud/filaments'),
+
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
@@ -3265,6 +3525,8 @@ export const api = {
     }),
   deleteMaintenanceType: (id: number) =>
     request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),
+  restoreDefaultMaintenanceTypes: () =>
+    request<{ restored: number }>(`/maintenance/types/restore-defaults`, { method: 'POST' }),
   getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
   getPrinterMaintenance: (printerId: number) =>
     request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
@@ -3539,8 +3801,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     const contentDisposition = response.headers.get('Content-Disposition');
-    const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
-    const filename = filenameMatch?.[1] || `project_${projectId}.zip`;
+    const filename = parseContentDispositionFilename(contentDisposition) || `project_${projectId}.zip`;
     const blob = await response.blob();
     return { blob, filename };
   },
@@ -3566,6 +3827,14 @@ export const api = {
 
   // System Info
   getSystemInfo: () => request<SystemInfo>('/system/info'),
+  getStorageUsage: (options?: { refresh?: boolean }) => {
+    const params = new URLSearchParams();
+    if (options?.refresh) {
+      params.set('refresh', 'true');
+    }
+    const query = params.toString();
+    return request<StorageUsageResponse>(`/system/storage-usage${query ? `?${query}` : ''}`);
+  },
 
   // Library (File Manager)
   getLibraryFolders: () => request<LibraryFolderTree[]>('/library/folders'),
@@ -3668,8 +3937,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const downloadFilename = filenameMatch?.[1] || filename || `file_${id}`;
+    const downloadFilename = parseContentDispositionFilename(disposition) || filename || `file_${id}`;
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -3909,6 +4177,39 @@ export interface SystemInfo {
   };
 }
 
+export interface StorageUsageCategory {
+  key: string;
+  label: string;
+  bytes: number;
+  formatted: string;
+  percent_of_total: number;
+}
+
+export interface StorageUsageOtherItem {
+  bucket: string;
+  label: string;
+  kind: 'system' | 'data';
+  deletable: boolean;
+  bytes: number;
+  formatted: string;
+  percent_of_total: number;
+}
+
+export interface StorageUsageResponse {
+  roots: string[];
+  total_bytes: number;
+  total_formatted: string;
+  categories: StorageUsageCategory[];
+  other_breakdown: StorageUsageOtherItem[];
+  scan_errors: number;
+  generated_at: string;
+  cache: {
+    hit: boolean;
+    age_seconds: number;
+    max_age_seconds: number;
+  };
+}
+
 // Library (File Manager) types
 export interface LibraryFolderTree {
   id: number;
@@ -4348,8 +4649,7 @@ export const supportApi = {
     }
     // Get filename from Content-Disposition header or use default
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename=(.+)/);
-    const filename = filenameMatch ? filenameMatch[1] : 'bambuddy-support.zip';
+    const filename = parseContentDispositionFilename(disposition) || 'bambuddy-support.zip';
 
     // Download the blob
     const blob = await response.blob();

+ 12 - 12
frontend/src/components/AMSHistoryModal.tsx

@@ -187,8 +187,8 @@ export function AMSHistoryModal({
         {/* Content */}
         <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
           {/* Time Range & Mode Selector */}
-          <div className="flex items-center justify-between">
-            <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
+          <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3">
+            <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
               <button
                 onClick={() => setMode('humidity')}
                 className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
@@ -211,7 +211,7 @@ export function AMSHistoryModal({
               </button>
             </div>
 
-            <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
+            <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
               {TIME_RANGES.map(range => (
                 <button
                   key={range.value}
@@ -228,10 +228,10 @@ export function AMSHistoryModal({
           </div>
 
           {/* Stats Cards */}
-          <div className="grid grid-cols-4 gap-4">
+          <div className="grid grid-cols-4 gap-4 max-[550px]:grid-cols-2">
             {mode === 'humidity' ? (
               <>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-2" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
                   <div className="flex items-center gap-2">
                     <p className="text-2xl font-bold" style={{ color: getHumidityColor(currentHumidity) }}>
@@ -240,19 +240,19 @@ export function AMSHistoryModal({
                     <TrendIcon trend={humidityTrend} />
                   </div>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-4" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
                   <p className="text-2xl font-bold" style={{ color: textPrimary }}>
                     {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-1" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
                   <p className="text-2xl font-bold text-green-500">
                     {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-3" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
                   <p className="text-2xl font-bold text-red-500">
                     {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}
@@ -261,7 +261,7 @@ export function AMSHistoryModal({
               </>
             ) : (
               <>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-2" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
                   <div className="flex items-center gap-2">
                     <p className="text-2xl font-bold" style={{ color: getTempColor(currentTemp) }}>
@@ -270,19 +270,19 @@ export function AMSHistoryModal({
                     <TrendIcon trend={tempTrend} />
                   </div>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-4" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
                   <p className="text-2xl font-bold" style={{ color: textPrimary }}>
                     {data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-1" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
                   <p className="text-2xl font-bold text-blue-500">
                     {data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-3" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
                   <p className="text-2xl font-bold text-red-500">
                     {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}

+ 22 - 0
frontend/src/components/AddExternalLinkModal.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
 import { Button } from './Button';
@@ -11,6 +12,7 @@ interface AddExternalLinkModalProps {
 }
 
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const isEditing = !!link;
   const fileInputRef = useRef<HTMLInputElement>(null);
@@ -18,6 +20,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
   const [name, setName] = useState(link?.name || '');
   const [url, setUrl] = useState(link?.url || '');
   const [icon, setIcon] = useState(link?.icon || 'link');
+  const [openInNewTab, setOpenInNewTab] = useState(link?.open_in_new_tab || false);
   const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
   const [customIconPreview, setCustomIconPreview] = useState<string | null>(
     link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
@@ -137,6 +140,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
       name: name.trim(),
       url: url.trim(),
       icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
+      open_in_new_tab: openInNewTab,
     };
 
     if (isEditing) {
@@ -213,6 +217,24 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
             />
           </div>
 
+          {/* Open in New Tab */}
+          <div className="flex items-center justify-between">
+            <label className="text-sm text-bambu-gray">{t('externalLinks.openInNewTab')}</label>
+            <button
+              type="button"
+              onClick={() => setOpenInNewTab(!openInNewTab)}
+              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+                openInNewTab ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+            >
+              <span
+                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                  openInNewTab ? 'translate-x-6' : 'translate-x-1'
+                }`}
+              />
+            </button>
+          </div>
+
           {/* Icon Section */}
           <div className="space-y-3">
             <label className="block text-sm text-bambu-gray">Icon</label>

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott