Browse Source

Merge branch '0.2.0b' into 0.2.0b

MartinNYHC 3 months ago
parent
commit
fa3372f402
88 changed files with 4504 additions and 761 deletions
  1. 23 0
      CHANGELOG.md
  2. 4 1
      README.md
  3. 13 5
      backend/app/api/routes/archives.py
  4. 61 0
      backend/app/api/routes/cloud.py
  5. 38 14
      backend/app/api/routes/inventory.py
  6. 3 0
      backend/app/api/routes/kprofiles.py
  7. 2 0
      backend/app/api/routes/notifications.py
  8. 129 0
      backend/app/api/routes/print_log.py
  9. 216 56
      backend/app/api/routes/printers.py
  10. 11 2
      backend/app/api/routes/settings.py
  11. 5 2
      backend/app/api/routes/support.py
  12. 340 0
      backend/app/api/routes/system.py
  13. 13 0
      backend/app/api/routes/updates.py
  14. 482 11
      backend/app/core/catalog_defaults.py
  15. 7 0
      backend/app/core/database.py
  16. 155 6
      backend/app/main.py
  17. 3 0
      backend/app/models/notification.py
  18. 6 0
      backend/app/models/notification_template.py
  19. 31 0
      backend/app/models/print_log.py
  20. 6 0
      backend/app/schemas/notification.py
  21. 10 0
      backend/app/schemas/notification_template.py
  22. 25 0
      backend/app/schemas/print_log.py
  23. 1 1
      backend/app/schemas/printer.py
  24. 6 0
      backend/app/schemas/settings.py
  25. 14 4
      backend/app/services/archive.py
  26. 210 114
      backend/app/services/bambu_mqtt.py
  27. 10 26
      backend/app/services/firmware_check.py
  28. 24 0
      backend/app/services/notification_service.py
  29. 52 0
      backend/app/services/print_log.py
  30. 27 21
      backend/app/services/print_scheduler.py
  31. 47 31
      backend/app/services/printer_manager.py
  32. 34 34
      backend/app/services/spool_tag_matcher.py
  33. 9 8
      backend/app/services/spoolman_tracking.py
  34. 11 2
      backend/app/services/usage_tracker.py
  35. 1 0
      backend/tests/conftest.py
  36. 130 0
      backend/tests/unit/services/test_notification_service.py
  37. 40 12
      backend/tests/unit/services/test_printer_manager.py
  38. 2 2
      backend/tests/unit/services/test_spoolman_tracking.py
  39. 211 0
      backend/tests/unit/test_phantom_print_hardening.py
  40. 104 0
      backend/tests/unit/test_print_log.py
  41. 4 4
      backend/tests/unit/test_scheduler_ams_mapping.py
  42. 65 1
      backend/tests/unit/test_scheduler_clear_plate.py
  43. 47 0
      backend/tests/unit/test_support_helpers.py
  44. 1 0
      frontend/package-lock.json
  45. 1 0
      frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx
  46. 77 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  47. 32 0
      frontend/src/__tests__/components/NotificationProviderCard.test.tsx
  48. 1 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  49. 3 3
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  50. 1 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  51. 1 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  52. 103 1
      frontend/src/api/client.ts
  53. 12 12
      frontend/src/components/AMSHistoryModal.tsx
  54. 9 0
      frontend/src/components/AddNotificationModal.tsx
  55. 5 3
      frontend/src/components/AssignSpoolModal.tsx
  56. 1 1
      frontend/src/components/ColorCatalogSettings.tsx
  57. 180 39
      frontend/src/components/ConfigureAmsSlotModal.tsx
  58. 48 15
      frontend/src/components/Dashboard.tsx
  59. 18 12
      frontend/src/components/FilamentTrends.tsx
  60. 66 5
      frontend/src/components/KProfilesView.tsx
  61. 30 30
      frontend/src/components/Layout.tsx
  62. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  63. 1 1
      frontend/src/components/SpoolCatalogSettings.tsx
  64. 2 0
      frontend/src/components/SpoolmanSettings.tsx
  65. 20 16
      frontend/src/hooks/useFilamentMapping.ts
  66. 1 1
      frontend/src/hooks/useIsMobile.ts
  67. 24 0
      frontend/src/hooks/useIsSidebarCompact.ts
  68. 66 0
      frontend/src/i18n/locales/de.ts
  69. 66 0
      frontend/src/i18n/locales/en.ts
  70. 66 0
      frontend/src/i18n/locales/fr.ts
  71. 77 0
      frontend/src/i18n/locales/it.ts
  72. 66 0
      frontend/src/i18n/locales/ja.ts
  73. 16 0
      frontend/src/index.css
  74. 375 39
      frontend/src/pages/ArchivesPage.tsx
  75. 23 6
      frontend/src/pages/InventoryPage.tsx
  76. 3 3
      frontend/src/pages/MaintenancePage.tsx
  77. 249 201
      frontend/src/pages/PrintersPage.tsx
  78. 9 9
      frontend/src/pages/ProfilesPage.tsx
  79. 190 2
      frontend/src/pages/SettingsPage.tsx
  80. 1 0
      frontend/src/pages/StatsPage.tsx
  81. 1 1
      frontend/src/utils/amsHelpers.ts
  82. 10 1
      install/start_bambuddy.bat
  83. 0 0
      static/assets/index-BOd5pCVD.js
  84. 0 0
      static/assets/index-BpibLMBb.js
  85. 0 0
      static/assets/index-DZOdnZuT.css
  86. 0 0
      static/assets/index-OqmBOPoC.css
  87. 2 2
      static/index.html
  88. 1 0
      update_website_wiki.sh

+ 23 - 0
CHANGELOG.md

@@ -5,21 +5,40 @@ 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.
 
 ### 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.
+- **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.
@@ -36,6 +55,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **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
 

+ 4 - 1
README.md

@@ -76,6 +76,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - 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
@@ -145,6 +147,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - 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

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

@@ -134,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
 
@@ -2738,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

+ 61 - 0
backend/app/api/routes/cloud.py

@@ -889,6 +889,67 @@ async def get_builtin_filaments(
     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"],

+ 38 - 14
backend/app/api/routes/inventory.py

@@ -645,18 +645,32 @@ async def assign_spool(
     fingerprint_type = None
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
-        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", "")
+        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(
@@ -715,16 +729,27 @@ async def assign_spool(
                 setting_id=setting_id,
             )
 
-            # b. Look up K-profile for this spool + printer + nozzle
+            # 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_id is not None and kp.extruder_id != slot_extruder:
+                        continue
                     matching_kp = kp
                     break
 
@@ -735,7 +760,6 @@ async def assign_spool(
                     cali_idx=matching_kp.cali_idx,
                     filament_id=tray_info_idx,
                     nozzle_diameter=nozzle_diameter,
-                    setting_id=matching_kp.setting_id,
                 )
 
             configured = True

+ 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"}
 
 

+ 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}

+ 216 - 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
@@ -2109,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:

+ 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,

+ 482 - 11
backend/app/core/catalog_defaults.py

@@ -330,17 +330,95 @@ DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
     ("Prusament", "Azure Blue", "#007FFF", "PLA"),
     ("Prusament", "Royal Blue", "#4169E1", "PLA"),
     ("Prusament", "Mystic Purple", "#7B68EE", "PLA"),
-    # eSUN PLA+
-    ("eSUN", "White", "#FFFFFF", "PLA+"),
-    ("eSUN", "Black", "#000000", "PLA+"),
-    ("eSUN", "Grey", "#808080", "PLA+"),
-    ("eSUN", "Red", "#FF0000", "PLA+"),
-    ("eSUN", "Blue", "#0000FF", "PLA+"),
-    ("eSUN", "Green", "#00FF00", "PLA+"),
-    ("eSUN", "Yellow", "#FFFF00", "PLA+"),
-    ("eSUN", "Orange", "#FFA500", "PLA+"),
-    ("eSUN", "Purple", "#800080", "PLA+"),
-    ("eSUN", "Pink", "#FFC0CB", "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"),
@@ -354,4 +432,397 @@ DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
     ("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"),
 ]

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

@@ -89,6 +89,7 @@ async def init_db():
         notification_template,
         orca_base_cache,
         pending_upload,
+        print_log,
         print_queue,
         printer,
         project,
@@ -1209,6 +1210,12 @@ async def run_migrations(conn):
     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."""

+ 155 - 6
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
@@ -193,6 +194,7 @@ from backend.app.api.routes import (
     notification_templates,
     notifications,
     pending_uploads,
+    print_log,
     print_queue,
     printers,
     projects,
@@ -259,6 +261,9 @@ _notified_hms_errors: dict[int, set[str]] = {}
 # 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).
@@ -335,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)
@@ -673,7 +680,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         )
                         existing_assignment = existing.scalar_one_or_none()
                         if existing_assignment:
-                            # Sync spool weight_used from AMS remain if valid
+                            # 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:
@@ -683,11 +692,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 if 0 <= remain_val <= 100:
                                     lw = existing_assignment.spool.label_weight or 1000
                                     new_used = round(lw * (100 - remain_val) / 100.0, 1)
-                                    if abs((existing_assignment.spool.weight_used or 0) - new_used) > 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,
-                                            existing_assignment.spool.weight_used,
+                                            current_used,
                                             new_used,
                                             remain_val,
                                         )
@@ -707,6 +717,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 spool,
                                 printer_manager,
                                 db,
+                                tray_info_idx=tray_info_idx,
                             )
                             await db.commit()
                             await ws_manager.broadcast(
@@ -984,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
 
@@ -1258,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)
         )
@@ -1932,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)
 
@@ -2132,6 +2162,36 @@ 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:
@@ -2512,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
@@ -2536,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
@@ -3255,6 +3403,7 @@ 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)

+ 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())

+ 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

+ 10 - 0
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"
 
 
@@ -66,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"],
@@ -172,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

+ 14 - 4
backend/app/services/archive.py

@@ -727,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
 
@@ -740,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,

+ 210 - 114
backend/app/services/bambu_mqtt.py

@@ -128,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
@@ -372,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(
@@ -400,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:
@@ -409,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)
@@ -434,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')}"
                 )
@@ -451,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
@@ -486,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)
 
@@ -506,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.
@@ -602,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
@@ -610,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}"
                     )
@@ -626,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}"
                         )
@@ -637,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
@@ -653,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
@@ -663,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
@@ -679,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
@@ -689,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
@@ -705,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
@@ -811,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}"
                             )
@@ -840,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})"
                                     )
@@ -868,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}"
                                 )
@@ -941,13 +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",
@@ -955,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)
@@ -1004,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)"
                             )
@@ -1066,7 +1098,7 @@ 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)
+                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)
@@ -1132,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:
@@ -1149,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
@@ -1161,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)
@@ -1179,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)
@@ -1191,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
@@ -1206,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:
@@ -1413,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}"
                                         )
@@ -1423,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'}"
                                         )
@@ -1432,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}"
                                         )
@@ -1457,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
@@ -1565,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:
@@ -1609,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}"
                 )
 
@@ -1644,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
@@ -1668,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):
@@ -1696,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)
@@ -1707,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
@@ -1719,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
@@ -1784,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),
@@ -1836,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
 
@@ -1858,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
 
@@ -1974,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.
@@ -2131,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,
                 )
@@ -2140,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:
@@ -2219,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)
@@ -2298,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}"
@@ -2424,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
@@ -2432,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
@@ -2658,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
 
@@ -2726,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
 
@@ -2795,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
@@ -3305,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(
@@ -3341,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,
@@ -3390,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": "",
@@ -3425,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
@@ -3447,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 = {
@@ -3462,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)
@@ -3485,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
@@ -3512,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),
             }
         }
 

+ 10 - 26
backend/app/services/firmware_check.py

@@ -324,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.
@@ -374,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}"
 
@@ -407,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 final path, preserving original filename
+            temp_path.rename(cached_path)
 
-            # Move temp to both cached path and original filename path
-            import shutil
-
-            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)

+ 24 - 0
backend/app/services/notification_service.py

@@ -1016,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

+ 27 - 21
backend/app/services/print_scheduler.py

@@ -69,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()
 
@@ -333,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())
 
@@ -538,23 +543,24 @@ class PrintScheduler:
                         }
                     )
 
-        # 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,
-                    "extruder_id": 0 if ams_extruder_map else None,
-                }
-            )
+        # 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
 

+ 47 - 31
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
@@ -304,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,
@@ -502,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
@@ -578,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", {})

+ 34 - 34
backend/app/services/spool_tag_matcher.py

@@ -196,16 +196,19 @@ async def auto_assign_spool(
     spool: Spool,
     printer_manager,
     db: AsyncSession,
+    tray_info_idx: str = "",
 ) -> SpoolAssignment:
     """Create a SpoolAssignment and auto-configure the AMS slot via MQTT.
 
-    Reuses the same MQTT configuration logic as the manual assign endpoint.
+    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).
     """
-    from backend.app.api.routes.inventory import MATERIAL_TEMPS
-
     # 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
@@ -246,34 +249,14 @@ async def auto_assign_spool(
     db.add(assignment)
     await db.flush()
 
-    # Auto-configure AMS slot via MQTT
+    # 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:
-            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 = ""
-
-            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
-
-            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=temp_min,
-                nozzle_temp_max=temp_max,
-                setting_id=setting_id,
-            )
-
             # Apply K-profile if available
             nozzle_diameter = "0.4"
             if state and state.nozzles:
@@ -288,23 +271,40 @@ async def auto_assign_spool(
                     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=tray_info_idx,
+                    filament_id=cali_filament_id,
                     nozzle_diameter=nozzle_diameter,
-                    setting_id=matching_kp.setting_id,
+                )
+
+                # 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-configured AMS slot ams=%d tray=%d for spool %d on printer %d (RFID match)",
-                ams_id,
-                tray_id,
+                "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("MQTT auto-configure failed for spool %d (RFID match): %s", spool.id, e)
+        logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
 
     return assignment

+ 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
 

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

@@ -263,8 +263,13 @@ async def _track_from_3mf(
     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 state.tray_now < 255:
+        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":
@@ -322,7 +327,11 @@ async def _track_from_3mf(
                 if isinstance(mapped, int) and mapped >= 0:
                     global_tray_id = mapped
 
-        if global_tray_id >= 128:
+        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:

+ 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,
         }

+ 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."""

+ 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
 

+ 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

+ 4 - 4
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -140,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
@@ -466,7 +466,7 @@ 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
@@ -624,7 +624,7 @@ class TestNozzleAwareMapping:
 
         class MockStatus:
             raw_data = {
-                "vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"},
+                "vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}],
                 "ams_extruder_map": {"0": 0},
             }
 
@@ -637,7 +637,7 @@ class TestNozzleAwareMapping:
         """External spool extruder_id should be None without ams_extruder_map."""
 
         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

+ 65 - 1
backend/tests/unit/test_scheduler_clear_plate.py

@@ -1,6 +1,7 @@
 """Tests for the clear plate queue flow in the print scheduler."""
 
-from unittest.mock import MagicMock, patch
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
@@ -120,3 +121,66 @@ class TestSchedulerIdleCheckWithPlateCleared:
         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()."""
 

+ 1 - 0
frontend/package-lock.json

@@ -6049,6 +6049,7 @@
       "version": "14.1.1",
       "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
       "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+      "license": "MIT",
       "dependencies": {
         "argparse": "^2.0.1",
         "entities": "^4.4.0",

+ 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', () => {

+ 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();
+  });
 });

+ 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 });

+ 3 - 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);

+ 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', () => {

+ 1 - 0
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', () => {

+ 103 - 1
frontend/src/api/client.ts

@@ -204,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
@@ -249,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 {
@@ -368,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;
@@ -790,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>;
@@ -1402,6 +1427,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;
@@ -1452,6 +1479,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;
@@ -1495,6 +1524,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;
@@ -2946,6 +2977,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) =>
@@ -3029,6 +3086,8 @@ export const api = {
     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) =>
@@ -3426,6 +3485,8 @@ export const api = {
     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',
@@ -3765,6 +3826,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'),
@@ -4107,6 +4176,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;

+ 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` : '—'}

+ 9 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -46,6 +46,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
+  const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
 
   // Provider-specific config
   const [config, setConfig] = useState<Record<string, string>>(
@@ -145,6 +146,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_printer_error: onPrinterError,
       on_filament_low: onFilamentLow,
       on_maintenance_due: onMaintenanceDue,
+      on_bed_cooled: onBedCooled,
     };
 
     if (isEditing) {
@@ -486,6 +488,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">Bed Cooled</span>
+                    <span className="text-xs text-bambu-gray ml-1">(after print completes)</span>
+                  </div>
+                  <Toggle checked={onBedCooled} onChange={setOnBedCooled} />
+                </div>
               </div>
             </div>
 

+ 5 - 3
frontend/src/components/AssignSpoolModal.tsx

@@ -62,15 +62,17 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
 
   if (!isOpen) return null;
 
-  // Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
-  // and spools already assigned to other slots
+  // Filter out spools already assigned to other slots
   const assignedSpoolIds = new Set(
     (assignments || [])
       .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
       .map(a => a.spool_id)
   );
+  // External slots (amsId 254 or 255) have no RFID reader, so show all spools.
+  // AMS slots only show manual spools (no tag_uid or tray_uuid).
+  const isExternalSlot = amsId === 254 || amsId === 255;
   const manualSpools = spools?.filter((spool: InventorySpool) =>
-    !spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
+    !assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))
   );
 
   const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {

+ 1 - 1
frontend/src/components/ColorCatalogSettings.tsx

@@ -436,7 +436,7 @@ export function ColorCatalogSettings() {
             {t('common.loading')}
           </div>
         ) : (
-          <div className="max-h-[400px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
+          <div className="max-h-[600px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
             <table className="w-full text-sm">
               <thead className="bg-bambu-dark sticky top-0">
                 <tr>

+ 180 - 39
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -14,6 +14,9 @@ interface SlotInfo {
   trayColor?: string;
   traySubBrands?: string;
   trayInfoIdx?: string;
+  extruderId?: number;
+  caliIdx?: number | null;
+  savedPresetId?: string;
 }
 
 // Get proper AMS label (handles HT AMS with ID 128+)
@@ -72,6 +75,7 @@ interface ConfigureAmsSlotModalProps {
   printerId: number;
   slotInfo: SlotInfo;
   nozzleDiameter?: string;
+  printerModel?: string;
   onSuccess?: () => void;
 }
 
@@ -209,12 +213,23 @@ function colorNameToHex(name: string): string | null {
   return COLOR_NAME_MAP[normalized] || null;
 }
 
+// Extract printer model from preset name suffix "@BBL X1C 0.4 nozzle" → "X1C"
+function extractPresetModel(name: string): string | null {
+  const atIdx = name.indexOf('@');
+  if (atIdx < 0) return null;
+  const suffix = name.slice(atIdx + 1).trim();
+  const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+  if (bblMatch) return bblMatch[1].trim();
+  return null;
+}
+
 export function ConfigureAmsSlotModal({
   isOpen,
   onClose,
   printerId,
   slotInfo,
   nozzleDiameter = '0.4',
+  printerModel,
   onSuccess,
 }: ConfigureAmsSlotModalProps) {
   const { t } = useTranslation();
@@ -256,6 +271,14 @@ export function ConfigureAmsSlotModal({
     enabled: isOpen && !!printerId,
   });
 
+  // Fetch color catalog
+  const { data: colorCatalog } = useQuery({
+    queryKey: ['colorCatalog'],
+    queryFn: () => api.getColorCatalog(),
+    enabled: isOpen,
+    staleTime: Infinity,
+  });
+
   // Configure slot mutation
   const configureMutation = useMutation({
     mutationFn: async () => {
@@ -435,22 +458,44 @@ export function ConfigureAmsSlotModal({
     // Collect IDs already covered by cloud and local to avoid duplicates in fallback
     const coveredIds = new Set<string>();
 
+    // Currently-configured preset should always be shown (bypass model filter)
+    const savedId = slotInfo.savedPresetId;
+    const trayIdx = slotInfo.trayInfoIdx;
+
     // 1. Cloud presets
     if (cloudSettings?.filament) {
       for (const cp of cloudSettings.filament) {
         coveredIds.add(cp.setting_id);
-        if (!query || cp.name.toLowerCase().includes(query)) {
-          items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
+        // Keep preset if it matches the slot's saved mapping or current tray_info_idx
+        const isCurrentPreset = savedId === cp.setting_id
+          || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
+        if (!isCurrentPreset && query && !cp.name.toLowerCase().includes(query)) continue;
+        // Filter by printer model if set (skip for current preset)
+        if (!isCurrentPreset && printerModel) {
+          const presetModel = extractPresetModel(cp.name);
+          if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
         }
+        items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
       }
     }
 
     // 2. Local presets
     if (localPresets?.filament) {
       for (const lp of localPresets.filament) {
-        if (!query || lp.name.toLowerCase().includes(query)) {
-          items.push({ id: `local_${lp.id}`, name: lp.name, source: 'local', isUser: false });
+        const localId = `local_${lp.id}`;
+        const isSaved = savedId === localId;
+        if (!isSaved && query && !lp.name.toLowerCase().includes(query)) continue;
+        // Filter by compatible_printers if set (skip for saved preset)
+        if (!isSaved && printerModel && lp.compatible_printers) {
+          const compatModels = lp.compatible_printers.split(';').map(p => {
+            // Extract model from "BBL X1C" → "X1C"
+            const trimmed = p.trim();
+            const bblMatch = trimmed.match(/^BBL\s+(.+)/i);
+            return bblMatch ? bblMatch[1].trim().toUpperCase() : trimmed.toUpperCase();
+          }).filter(Boolean);
+          if (compatModels.length > 0 && !compatModels.includes(printerModel.toUpperCase())) continue;
         }
+        items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
       }
     }
 
@@ -478,7 +523,7 @@ export function ConfigureAmsSlotModal({
       if (!a.isUser && b.isUser) return 1;
       return a.name.localeCompare(b.name);
     });
-  }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]);
+  }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
 
   // Get full preset name for K profile filtering (brand + material, without printer suffix)
   const selectedPresetInfo = useMemo(() => {
@@ -518,6 +563,41 @@ export function ConfigureAmsSlotModal({
   // For backwards compatibility with the label
   const selectedMaterial = selectedPresetInfo?.fullName || '';
 
+  // Filter color catalog entries matching the selected preset's brand + material
+  const catalogColors = useMemo(() => {
+    if (!colorCatalog || !selectedPresetInfo) return [];
+
+    const { fullName, brand } = selectedPresetInfo;
+
+    // Try to find colors matching the full preset name (e.g., "PLA Metal")
+    // The catalog uses the variant as part of the material field (e.g., material="PLA Metal")
+    // Extract the full material+variant from the preset name
+    const materialVariant = fullName.replace(/^(Bambu\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\s*/i, '').trim();
+
+    return colorCatalog.filter(entry => {
+      const entryMaterial = (entry.material || '').toUpperCase();
+      const entryManufacturer = entry.manufacturer.toUpperCase();
+
+      // Match material: try full material+variant first, then just material type
+      const materialMatch = entryMaterial === materialVariant.toUpperCase()
+        || entryMaterial.includes(materialVariant.toUpperCase())
+        || materialVariant.toUpperCase().includes(entryMaterial);
+
+      if (!materialMatch) return false;
+
+      // If brand is present, also match manufacturer
+      if (brand) {
+        const upperBrand = brand.toUpperCase();
+        // Fuzzy match: "Bambu" matches "Bambu Lab", etc.
+        if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) {
+          return false;
+        }
+      }
+
+      return true;
+    });
+  }, [colorCatalog, selectedPresetInfo]);
+
   const matchingKProfiles = useMemo(() => {
     if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
 
@@ -575,34 +655,52 @@ export function ConfigureAmsSlotModal({
     });
 
     // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
-    // Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D
+    // Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)
     const seen = new Map<string, KProfile>();
     for (const profile of filtered) {
       const key = `${profile.name}|${profile.k_value}`;
       const existing = seen.get(key);
       if (!existing) {
         seen.set(key, profile);
-      } else if (profile.extruder_id === 1 && existing.extruder_id === 0) {
-        // Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile
+      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
+        // Replace with profile matching slot's extruder
         seen.set(key, profile);
       }
     }
     return Array.from(seen.values());
-  }, [kprofilesData?.profiles, selectedPresetInfo]);
+  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]);
 
   // Pre-select current profile when modal opens, reset when closes
   useEffect(() => {
-    if (isOpen && cloudSettings?.filament) {
-      // Try to pre-select current profile based on trayInfoIdx
-      if (slotInfo.trayInfoIdx) {
-        const currentPreset = cloudSettings.filament.find(
+    if (isOpen) {
+      // Pre-populate from saved preset mapping (most reliable)
+      if (slotInfo.savedPresetId) {
+        setSelectedPresetId(slotInfo.savedPresetId);
+      } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
+        // Fallback: try to match by tray_info_idx in cloud presets
+        // First try exact match on setting_id
+        let currentPreset = cloudSettings.filament.find(
           p => p.setting_id === slotInfo.trayInfoIdx
         );
+        // Then try matching by converting setting_id → filament_id format
+        if (!currentPreset) {
+          currentPreset = cloudSettings.filament.find(
+            p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx
+          );
+        }
         if (currentPreset) {
           setSelectedPresetId(currentPreset.setting_id);
         }
       }
-    } else if (!isOpen) {
+
+      // Pre-populate color from current slot (black is valid — empty slots don't pass trayColor)
+      if (slotInfo.trayColor) {
+        const hex = slotInfo.trayColor.slice(0, 6);
+        if (hex) {
+          setColorHex(hex);
+        }
+      }
+    } else {
       // Reset when modal closes
       setSelectedPresetId('');
       setSelectedKProfile(null);
@@ -611,17 +709,25 @@ export function ConfigureAmsSlotModal({
       setSearchQuery('');
       setShowSuccess(false);
     }
-  }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
+  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament]);
 
   // Auto-select best matching K profile when preset changes
   useEffect(() => {
     if (matchingKProfiles.length > 0) {
-      // Auto-select first matching profile
+      // Prefer the currently-active K-profile (by cali_idx) if available
+      if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
+        const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);
+        if (active) {
+          setSelectedKProfile(active);
+          return;
+        }
+      }
+      // Fallback: first matching profile
       setSelectedKProfile(matchingKProfiles[0]);
     } else {
       setSelectedKProfile(null);
     }
-  }, [selectedPresetId, matchingKProfiles]);
+  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);
 
   // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -659,7 +765,7 @@ export function ConfigureAmsSlotModal({
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
             <Settings2 className="w-5 h-5 text-bambu-blue" />
-            <h2 className="text-lg font-semibold text-white">Configure AMS Slot</h2>
+            <h2 className="text-lg font-semibold text-white">{t('configureAmsSlot.title')}</h2>
           </div>
           <button
             onClick={onClose}
@@ -676,7 +782,7 @@ export function ConfigureAmsSlotModal({
             <div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
               <div className="text-center space-y-3">
                 <CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
-                <p className="text-lg font-semibold text-white">Slot Configured!</p>
+                <p className="text-lg font-semibold text-white">{t('configureAmsSlot.slotConfigured')}</p>
                 <p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
               </div>
             </div>
@@ -684,7 +790,7 @@ export function ConfigureAmsSlotModal({
 
           {/* Slot info */}
           <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
+            <p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
             <div className="flex items-center gap-2">
               {slotInfo.trayColor && (
                 <span
@@ -693,7 +799,7 @@ export function ConfigureAmsSlotModal({
                 />
               )}
               <span className="text-white font-medium">
-                {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
+                {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
               </span>
               {slotInfo.traySubBrands && (
                 <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
@@ -710,7 +816,7 @@ export function ConfigureAmsSlotModal({
               {/* Filament Profile Select */}
               <div>
                 <label className="block text-sm text-bambu-gray mb-2">
-                  Filament Profile <span className="text-red-400">*</span>
+                  {t('configureAmsSlot.filamentProfile')} <span className="text-red-400">*</span>
                 </label>
                 <div className="relative">
                   <input
@@ -724,13 +830,16 @@ export function ConfigureAmsSlotModal({
                     {filteredPresets.length === 0 ? (
                       <p className="text-center py-4 text-bambu-gray">
                         {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
-                          ? 'No presets available. Login to Bambu Cloud or import local profiles.'
-                          : 'No matching presets found.'}
+                          ? t('configureAmsSlot.noPresetsAvailable')
+                          : t('configureAmsSlot.noMatchingPresets')}
                       </p>
                     ) : (
                       filteredPresets.map((preset) => (
                         <button
                           key={preset.id}
+                          ref={selectedPresetId === preset.id ? (el) => {
+                            el?.scrollIntoView({ block: 'nearest' });
+                          } : undefined}
                           onClick={() => setSelectedPresetId(preset.id)}
                           className={`w-full p-2 rounded-lg border text-left transition-colors ${
                             selectedPresetId === preset.id
@@ -768,10 +877,10 @@ export function ConfigureAmsSlotModal({
               {/* K Profile Select */}
               <div>
                 <label className="block text-sm text-bambu-gray mb-2">
-                  K Profile (Pressure Advance)
+                  {t('configureAmsSlot.kProfileLabel')}
                   {selectedMaterial && (
                     <span className="ml-2 text-xs text-bambu-blue">
-                      Filtering for: {selectedMaterial}
+                      {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
                     </span>
                   )}
                 </label>
@@ -785,7 +894,7 @@ export function ConfigureAmsSlotModal({
                       }}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
                     >
-                      <option value="">No K profile (use default 0.020)</option>
+                      <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       {matchingKProfiles.map((profile) => (
                         <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
                           {profile.name} (K={profile.k_value})
@@ -796,16 +905,16 @@ export function ConfigureAmsSlotModal({
                   </div>
                 ) : selectedPresetId ? (
                   <p className="text-sm text-bambu-gray italic py-2">
-                    No matching K profiles found. Default K=0.020 will be used.
+                    {t('configureAmsSlot.noMatchingKProfiles')}
                   </p>
                 ) : (
                   <span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
-                    Select a filament profile first
+                    {t('configureAmsSlot.selectFilamentFirst')}
                   </span>
                 )}
                 {selectedKProfile && (
                   <p className="text-xs text-bambu-green mt-1">
-                    K={selectedKProfile.k_value} from printer calibration
+                    {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
                   </p>
                 )}
               </div>
@@ -813,8 +922,40 @@ export function ConfigureAmsSlotModal({
               {/* Optional: Custom color */}
               <div>
                 <label className="block text-sm text-bambu-gray mb-2">
-                  Custom Color (optional)
+                  {t('configureAmsSlot.customColorLabel')}
                 </label>
+                {/* Catalog colors matching selected preset */}
+                {catalogColors.length > 0 && (
+                  <div className="mb-3">
+                    <p className="text-xs text-bambu-gray mb-1.5">
+                      {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
+                    </p>
+                    <div className="flex flex-wrap gap-1.5">
+                      {catalogColors.map((entry) => (
+                        <button
+                          key={entry.id}
+                          onClick={() => {
+                            const hex = entry.hex_color.replace('#', '').toUpperCase();
+                            setColorHex(hex);
+                            setColorInput(entry.color_name);
+                          }}
+                          className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
+                            colorHex === entry.hex_color.replace('#', '').toUpperCase()
+                              ? 'border-bambu-green scale-105'
+                              : 'border-white/20 hover:border-white/40'
+                          }`}
+                          title={entry.color_name}
+                        >
+                          <span
+                            className="w-4 h-4 rounded-full border border-white/30 flex-shrink-0"
+                            style={{ backgroundColor: entry.hex_color }}
+                          />
+                          <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
+                        </button>
+                      ))}
+                    </div>
+                  </div>
+                )}
                 {/* Quick color buttons */}
                 <div className="flex flex-wrap gap-1.5 mb-2">
                   {QUICK_COLORS_BASIC.map((color) => (
@@ -836,7 +977,7 @@ export function ConfigureAmsSlotModal({
                   <button
                     onClick={() => setShowExtendedColors(!showExtendedColors)}
                     className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
-                    title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
+                    title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
                   >
                     {showExtendedColors ? '−' : '+'}
                   </button>
@@ -902,13 +1043,13 @@ export function ConfigureAmsSlotModal({
                       className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
                       title={t('configureAmsSlot.clearCustomColor')}
                     >
-                      Clear
+                      {t('configureAmsSlot.clear')}
                     </button>
                   )}
                 </div>
                 {colorHex && (
                   <p className="text-xs text-bambu-gray mt-1.5">
-                    Hex: #{colorHex}
+                    {t('configureAmsSlot.hexLabel', { hex: colorHex })}
                   </p>
                 )}
               </div>
@@ -928,19 +1069,19 @@ export function ConfigureAmsSlotModal({
             {resetMutation.isPending ? (
               <>
                 <Loader2 className="w-4 h-4 animate-spin" />
-                Resetting...
+                {t('configureAmsSlot.resetting')}
               </>
             ) : (
               <>
                 <RotateCcw className="w-4 h-4" />
-                Reset Slot
+                {t('configureAmsSlot.resetSlot')}
               </>
             )}
           </Button>
           {/* Cancel and Configure buttons on the right */}
           <div className="flex gap-2">
             <Button variant="secondary" onClick={onClose}>
-              Cancel
+              {t('configureAmsSlot.cancel')}
             </Button>
             <Button
               onClick={() => configureMutation.mutate()}
@@ -949,12 +1090,12 @@ export function ConfigureAmsSlotModal({
               {configureMutation.isPending ? (
                 <>
                   <Loader2 className="w-4 h-4 animate-spin" />
-                  Configuring...
+                  {t('configureAmsSlot.configuring')}
                 </>
               ) : (
                 <>
                   <Settings2 className="w-4 h-4" />
-                  Configure Slot
+                  {t('configureAmsSlot.configureSlot')}
                 </>
               )}
             </Button>

+ 48 - 15
frontend/src/components/Dashboard.tsx

@@ -32,6 +32,7 @@ interface DashboardProps {
   widgets: DashboardWidget[];
   storageKey: string;
   columns?: number;
+  stackBelow?: number;
   hideControls?: boolean;
   onResetLayout?: () => void;
   renderControls?: (controls: {
@@ -54,6 +55,7 @@ function SortableWidget({
   component,
   isHidden,
   size,
+  columnSpan,
   onToggleVisibility,
   onToggleSize,
 }: {
@@ -62,6 +64,7 @@ function SortableWidget({
   component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
   isHidden: boolean;
   size: 1 | 2 | 4;
+  columnSpan: number;
   onToggleVisibility: () => void;
   onToggleSize: () => void;
 }) {
@@ -87,7 +90,7 @@ function SortableWidget({
       ref={setNodeRef}
       style={{
         ...style,
-        gridColumn: `span ${size}`,
+        gridColumn: `span ${columnSpan}`,
       }}
       className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${
         isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''
@@ -135,7 +138,7 @@ function SortableWidget({
   );
 }
 
-export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout, renderControls }: DashboardProps) {
+export function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideControls = false, onResetLayout, renderControls }: DashboardProps) {
   // Build default sizes from widget definitions
   const getDefaultSizes = () => {
     const sizes: Record<string, 1 | 2 | 4> = {};
@@ -169,6 +172,31 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
   });
 
   const [showHiddenPanel, setShowHiddenPanel] = useState(false);
+  const [isStacked, setIsStacked] = useState(false);
+
+  useEffect(() => {
+    if (!stackBelow) return undefined;
+    const mediaQuery = window.matchMedia(`(max-width: ${stackBelow}px)`);
+    const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
+      setIsStacked(event.matches);
+    };
+    handleChange(mediaQuery);
+    const onChange = (event: MediaQueryListEvent) => handleChange(event);
+    if (mediaQuery.addEventListener) {
+      mediaQuery.addEventListener('change', onChange);
+    } else {
+      mediaQuery.addListener(onChange);
+    }
+    return () => {
+      if (mediaQuery.removeEventListener) {
+        mediaQuery.removeEventListener('change', onChange);
+      } else {
+        mediaQuery.removeListener(onChange);
+      }
+    };
+  }, [stackBelow]);
+
+  const effectiveColumns = stackBelow && isStacked ? 1 : columns;
 
   // Listen for toggle-hidden-panel event from parent
   useEffect(() => {
@@ -324,21 +352,26 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
           <div
             className="grid gap-6"
             style={{
-              gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
+              gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
             }}
           >
-            {visibleWidgets.map((widget) => (
-              <SortableWidget
-                key={widget.id}
-                id={widget.id}
-                title={widget.title}
-                component={widget.component}
-                isHidden={layout.hidden.includes(widget.id)}
-                size={layout.sizes[widget.id] || 2}
-                onToggleVisibility={() => toggleVisibility(widget.id)}
-                onToggleSize={() => toggleSize(widget.id)}
-              />
-            ))}
+            {visibleWidgets.map((widget) => {
+              const size = layout.sizes[widget.id] || 2;
+              const columnSpan = Math.min(size, effectiveColumns);
+              return (
+                <SortableWidget
+                  key={widget.id}
+                  id={widget.id}
+                  title={widget.title}
+                  component={widget.component}
+                  isHidden={layout.hidden.includes(widget.id)}
+                  size={size}
+                  columnSpan={columnSpan}
+                  onToggleVisibility={() => toggleVisibility(widget.id)}
+                  onToggleSize={() => toggleSize(widget.id)}
+                />
+              );
+            })}
           </div>
         </SortableContext>
       </DndContext>

+ 18 - 12
frontend/src/components/FilamentTrends.tsx

@@ -157,7 +157,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   return (
     <div className="space-y-6">
       {/* Time Range Selector */}
-      <div className="flex items-center justify-between">
+      <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-2">
         <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
         <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
           {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
@@ -177,24 +177,30 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       </div>
 
       {/* Summary Cards */}
-      <div className="grid grid-cols-3 gap-4">
+      <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
         <div className="bg-bambu-dark rounded-lg p-4">
-          <p className="text-sm text-bambu-gray">Period Filament</p>
-          <p className="text-2xl font-bold text-white">{(totalFilament / 1000).toFixed(2)}kg</p>
+          <div className="flex items-center justify-between gap-3">
+            <p className="text-sm text-bambu-gray leading-none">Period Filament</p>
+            <p className="text-2xl font-bold text-white leading-none">{(totalFilament / 1000).toFixed(2)}kg</p>
+          </div>
           <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <p className="text-sm text-bambu-gray">Period Cost</p>
-          <p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
+          <div className="flex items-center justify-between gap-3">
+            <p className="text-sm text-bambu-gray leading-none">Period Cost</p>
+            <p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
+          </div>
           <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <p className="text-sm text-bambu-gray">Avg per Print</p>
-          <p className="text-2xl font-bold text-white">
-            {totalPrints > 0
-              ? (totalFilament / totalPrints).toFixed(0)
-              : 0}g
-          </p>
+          <div className="flex items-center justify-between gap-3">
+            <p className="text-sm text-bambu-gray leading-none">Avg per Print</p>
+            <p className="text-2xl font-bold text-white leading-none">
+              {totalPrints > 0
+                ? (totalFilament / totalPrints).toFixed(0)
+                : 0}g
+            </p>
+          </div>
           <p className="text-xs text-bambu-gray">
             {currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg
           </p>

+ 66 - 5
frontend/src/components/KProfilesView.tsx

@@ -150,6 +150,7 @@ interface KProfileModalProps {
   printerId: number;
   nozzleDiameter: string;
   existingProfiles?: KProfile[];  // Existing profiles for filament selection
+  builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
   initialNote?: string;  // Initial note value for the profile
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
@@ -164,6 +165,7 @@ function KProfileModal({
   printerId,
   nozzleDiameter,
   existingProfiles = [],
+  builtinFilaments = [],
   isDualNozzle = false,
   initialNote = '',
   initialNoteKey = null,
@@ -197,12 +199,21 @@ function KProfileModal({
   const [note, setNote] = useState(initialNote);
 
   // Extract unique filaments from existing K-profiles on the printer
-  // These have valid filament_ids that the printer recognizes
+  // Use builtin filament table for accurate name resolution (filament_id → name)
+  // Falls back to extracting from profile name for custom/unknown presets
   const knownFilaments = React.useMemo(() => {
+    // Build lookup map from builtin filament names (includes cloud presets from parent)
+    const builtinMap = new Map<string, string>();
+    for (const bf of builtinFilaments) {
+      builtinMap.set(bf.filament_id, bf.name);
+    }
+
     const filamentMap = new Map<string, { id: string; name: string }>();
     for (const p of existingProfiles) {
       if (p.filament_id && !filamentMap.has(p.filament_id)) {
-        const filamentName = extractFilamentName(p.name || '');
+        // Prefer builtin name (accurate), fall back to extracting from profile name
+        const builtinName = builtinMap.get(p.filament_id);
+        const filamentName = builtinName || extractFilamentName(p.name || '');
         filamentMap.set(p.filament_id, {
           id: p.filament_id,
           name: filamentName || p.filament_id,
@@ -212,7 +223,7 @@ function KProfileModal({
     return Array.from(filamentMap.values()).sort((a, b) =>
       a.name.localeCompare(b.name)
     );
-  }, [existingProfiles]);
+  }, [existingProfiles, builtinFilaments]);
 
   const saveMutation = useMutation({
     mutationFn: (data: KProfileCreate) => {
@@ -769,6 +780,20 @@ export function KProfilesView() {
     staleTime: 60000,  // Cache for 1 minute
   });
 
+  // Fetch builtin filament names for accurate filament_id → name resolution
+  const { data: builtinFilaments } = useQuery({
+    queryKey: ['builtinFilaments'],
+    queryFn: () => api.getBuiltinFilaments(),
+    staleTime: 300000,  // Cache for 5 minutes (static data)
+  });
+
+  // Fetch filament_id → name mapping for user cloud presets (P* IDs)
+  const { data: filamentIdMap } = useQuery({
+    queryKey: ['filamentIdMap'],
+    queryFn: () => api.getFilamentIdMap(),
+    staleTime: 300000,  // Cache for 5 minutes
+  });
+
   // Fetch K-profile notes (stored locally)
   const {
     data: notesData,
@@ -807,6 +832,39 @@ export function KProfilesView() {
   // Get connected printers for display
   const connectedPrinters = printers?.filter((p) => p.is_active) || [];
 
+  // Build filament lookup for name resolution (builtin + user cloud presets)
+  const builtinFilamentMap = React.useMemo(() => {
+    const map = new Map<string, string>();
+    if (builtinFilaments) {
+      for (const bf of builtinFilaments) {
+        map.set(bf.filament_id, bf.name);
+      }
+    }
+    // Also add user cloud presets (P* filament_ids resolved from cloud details)
+    if (filamentIdMap) {
+      for (const [fid, name] of Object.entries(filamentIdMap)) {
+        if (!map.has(fid)) {
+          map.set(fid, name);
+        }
+      }
+    }
+    return map;
+  }, [builtinFilaments, filamentIdMap]);
+
+  // Enriched builtin filaments array (builtin + cloud presets merged)
+  // Pass this to modals so they have the full filament name lookup
+  const enrichedBuiltinFilaments = React.useMemo(() => {
+    return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({
+      filament_id: fid,
+      name,
+    }));
+  }, [builtinFilamentMap]);
+
+  // Resolve filament name: builtin table first, then extract from profile name
+  const resolveFilamentName = React.useCallback((profile: KProfile) => {
+    return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
+  }, [builtinFilamentMap]);
+
   // Filter and sort profiles
   // Note: nozzle diameter filtering is done server-side via MQTT request
   const filteredProfiles = React.useMemo(() => {
@@ -841,13 +899,13 @@ export function KProfilesView() {
         case 'k_value':
           return parseFloat(a.k_value) - parseFloat(b.k_value);
         case 'filament':
-          return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
+          return resolveFilamentName(a).localeCompare(resolveFilamentName(b));
         case 'name':
         default:
           return a.name.localeCompare(b.name);
       }
     });
-  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
+  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]);
 
   // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
@@ -1394,6 +1452,7 @@ export function KProfilesView() {
             printerId={selectedPrinter}
             nozzleDiameter={nozzleDiameter}
             existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+            builtinFilaments={enrichedBuiltinFilaments}
             isDualNozzle={isDualNozzle}
             initialNote={note}
             initialNoteKey={key}
@@ -1418,6 +1477,7 @@ export function KProfilesView() {
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          builtinFilaments={enrichedBuiltinFilaments}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
@@ -1438,6 +1498,7 @@ export function KProfilesView() {
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          builtinFilaments={enrichedBuiltinFilaments}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}

+ 30 - 30
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -8,7 +8,7 @@ import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
-import { useIsMobile } from '../hooks/useIsMobile';
+import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
@@ -29,7 +29,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
-  { id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' },
+  { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
@@ -72,7 +72,7 @@ export function Layout() {
   const location = useLocation();
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
-  const isMobile = useIsMobile();
+  const isSidebarCompact = useIsSidebarCompact();
   const { user, authEnabled, logout, hasPermission } = useAuth();
   const { showToast } = useToast();
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
@@ -311,12 +311,12 @@ export function Layout() {
     localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
   }, [sidebarExpanded]);
 
-  // Close mobile drawer on navigation
+  // Close compact drawer on navigation
   useEffect(() => {
-    if (isMobile) {
+    if (isSidebarCompact) {
       setMobileDrawerOpen(false);
     }
-  }, [location.pathname, isMobile]);
+  }, [location.pathname, isSidebarCompact]);
 
   // Listen for plate detection warnings (objects on plate, print paused)
   // Only show to users with printers:control permission
@@ -390,8 +390,8 @@ export function Layout() {
 
   return (
     <div className="flex min-h-screen">
-      {/* Mobile Header */}
-      {isMobile && (
+      {/* Compact Header */}
+      {isSidebarCompact && (
         <header className="fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4">
           <button
             onClick={() => setMobileDrawerOpen(true)}
@@ -408,8 +408,8 @@ export function Layout() {
         </header>
       )}
 
-      {/* Mobile Drawer Backdrop */}
-      {isMobile && mobileDrawerOpen && (
+      {/* Compact Drawer Backdrop */}
+      {isSidebarCompact && mobileDrawerOpen && (
         <div
           className="fixed inset-0 bg-black/60 z-40 transition-opacity"
           onClick={() => setMobileDrawerOpen(false)}
@@ -419,17 +419,17 @@ export function Layout() {
       {/* Sidebar / Mobile Drawer */}
       <aside
         className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
-          isMobile
+          isSidebarCompact
             ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
             : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
         }`}
       >
         {/* Logo */}
-        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
+        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
           <img
             src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
-            className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
+            className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
           />
         </div>
 
@@ -467,10 +467,10 @@ export function Layout() {
                         href={link.url}
                         target="_blank"
                         rel="noopener noreferrer"
-                        className={`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
-                        title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                        className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
+                        title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {sidebarExpanded && !isMobile && (
+                        {sidebarExpanded && !isSidebarCompact && (
                           <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                         )}
                         {link.custom_icon ? (
@@ -482,21 +482,21 @@ export function Layout() {
                         ) : (
                           LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
                         )}
-                        {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                        {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </a>
                     ) : (
                       <NavLink
                         to={`/external/${link.id}`}
                         className={({ isActive }) =>
-                          `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                          `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
                             isActive
                               ? 'bg-bambu-green text-white'
                               : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
                           }`
                         }
-                        title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                        title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {sidebarExpanded && !isMobile && (
+                        {sidebarExpanded && !isSidebarCompact && (
                           <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                         )}
                         {link.custom_icon ? (
@@ -508,7 +508,7 @@ export function Layout() {
                         ) : (
                           LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
                         )}
-                        {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                        {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </NavLink>
                     )}
                   </li>
@@ -544,15 +544,15 @@ export function Layout() {
                     <NavLink
                       to={to}
                       className={({ isActive }) =>
-                        `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                        `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
                           isActive
                             ? 'bg-bambu-green text-white'
                             : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
                         }`
                       }
-                      title={!isMobile && !sidebarExpanded ? t(labelKey) : undefined}
+                      title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
                     >
-                      {sidebarExpanded && !isMobile && (
+                      {sidebarExpanded && !isSidebarCompact && (
                         <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                       )}
                       <div className="relative">
@@ -565,7 +565,7 @@ export function Layout() {
                           </span>
                         )}
                       </div>
-                      {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
+                      {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
                     </NavLink>
                   </li>
                 );
@@ -574,8 +574,8 @@ export function Layout() {
           </ul>
         </nav>
 
-        {/* Collapse toggle - hide on mobile */}
-        {!isMobile && (
+        {/* Collapse toggle - hide on compact sidebar */}
+        {!isSidebarCompact && (
           <button
             onClick={() => setSidebarExpanded(!sidebarExpanded)}
             className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
@@ -591,7 +591,7 @@ export function Layout() {
 
         {/* Footer */}
         <div className="p-2 border-t border-bambu-dark-tertiary">
-          {isMobile || sidebarExpanded ? (
+          {isSidebarCompact || sidebarExpanded ? (
             <div className="flex flex-col gap-2 px-2">
               {/* Top row: icons */}
               <div className="flex items-center justify-center gap-1">
@@ -783,7 +783,7 @@ export function Layout() {
 
       {/* Main content */}
       <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
-        isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
+        isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
       }`}>
         {/* Debug logging indicator */}
         {debugLoggingState?.enabled && (

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -163,6 +163,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_ams_ht_temperature_high && (
               <span className="px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded">AMS-HT Temp</span>
             )}
+            {provider.on_bed_cooled && (
+              <span className="px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded">Bed Cooled</span>
+            )}
             {provider.quiet_hours_enabled && (
               <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
                 <Moon className="w-3 h-3" />
@@ -276,6 +279,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Bed Cooled</p>
+                    <p className="text-xs text-bambu-gray">Bed cooled below threshold after print</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_bed_cooled ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_bed_cooled: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Failed</p>
                   <Toggle

+ 1 - 1
frontend/src/components/SpoolCatalogSettings.tsx

@@ -271,7 +271,7 @@ export function SpoolCatalogSettings() {
             {t('common.loading')}
           </div>
         ) : (
-          <div className="max-h-[400px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
+          <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
             <table className="w-full text-sm">
               <thead className="bg-bambu-dark sticky top-0">
                 <tr>

+ 2 - 0
frontend/src/components/SpoolmanSettings.tsx

@@ -86,6 +86,7 @@ export function SpoolmanSettings() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       showToast(t('settings.toast.settingsSaved'));
     },
   });
@@ -243,6 +244,7 @@ export function SpoolmanSettings() {
                   <li>{t('settings.builtInFeatureRfid')}</li>
                   <li>{t('settings.builtInFeatureUsage')}</li>
                   <li>{t('settings.builtInFeatureCatalog')}</li>
+                  <li>{t('settings.builtInFeatureThirdParty')}</li>
                 </ul>
               </div>
             </div>

+ 20 - 16
frontend/src/hooks/useFilamentMapping.ts

@@ -41,22 +41,26 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
     });
   });
 
-  // Add external spool if loaded
-  if (printerStatus?.vt_tray?.tray_type) {
-    const color = normalizeColor(printerStatus.vt_tray.tray_color);
-    filaments.push({
-      type: printerStatus.vt_tray.tray_type,
-      color,
-      colorName: getColorName(color),
-      amsId: -1,
-      trayId: 0,
-      isHt: false,
-      isExternal: true,
-      label: 'External',
-      globalTrayId: 254,
-      trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
-      extruderId: hasDualNozzle ? 0 : undefined,
-    });
+  // Add external spool(s) if loaded
+  for (const extTray of printerStatus?.vt_tray ?? []) {
+    if (extTray.tray_type) {
+      const color = normalizeColor(extTray.tray_color);
+      const trayId = extTray.id ?? 254;
+      const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
+      filaments.push({
+        type: extTray.tray_type,
+        color,
+        colorName: getColorName(color),
+        amsId: -1,
+        trayId: trayId - 254,
+        isHt: false,
+        isExternal: true,
+        label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
+        globalTrayId: trayId,
+        trayInfoIdx: extTray.tray_info_idx || '',
+        extruderId: hasDualNozzle ? (trayId - 254) : undefined,
+      });
+    }
   }
 
   return filaments;

+ 1 - 1
frontend/src/hooks/useIsMobile.ts

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 
-const MOBILE_BREAKPOINT = 768; // md breakpoint
+const MOBILE_BREAKPOINT = 768;
 
 export function useIsMobile(): boolean {
   const [isMobile, setIsMobile] = useState(() =>

+ 24 - 0
frontend/src/hooks/useIsSidebarCompact.ts

@@ -0,0 +1,24 @@
+import { useState, useEffect } from 'react';
+
+const SIDEBAR_COMPACT_BREAKPOINT = 1144;
+
+export function useIsSidebarCompact(): boolean {
+  const [isCompact, setIsCompact] = useState(() =>
+    typeof window !== 'undefined' ? window.innerWidth < SIDEBAR_COMPACT_BREAKPOINT : false
+  );
+
+  useEffect(() => {
+    const mediaQuery = window.matchMedia(`(max-width: ${SIDEBAR_COMPACT_BREAKPOINT - 1}px)`);
+
+    const handleChange = (e: MediaQueryListEvent) => {
+      setIsCompact(e.matches);
+    };
+
+    setIsCompact(mediaQuery.matches);
+
+    mediaQuery.addEventListener('change', handleChange);
+    return () => mediaQuery.removeEventListener('change', handleChange);
+  }, []);
+
+  return isCompact;
+}

+ 66 - 0
frontend/src/i18n/locales/de.ts

@@ -171,6 +171,8 @@ export default {
     // Printer card
     readyToPrint: 'Druckbereit',
     external: 'Extern',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Druckarchive löschen',
     noLabel: 'Keine Bezeichnung',
     printPreview: 'Druckvorschau',
@@ -482,6 +484,7 @@ export default {
     gridView: 'Rasteransicht',
     listView: 'Listenansicht',
     calendarView: 'Kalenderansicht',
+    logView: 'Druckprotokoll',
     manageTags: 'Tags verwalten',
     showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
     hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
@@ -608,6 +611,7 @@ export default {
       slicedFor: 'Geslict für {{model}}',
       uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
+      noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       reprint: 'Drucken',
@@ -669,6 +673,34 @@ export default {
       actions: 'Aktionen',
       hasTimelapse: 'Hat Zeitraffer',
     },
+    log: {
+      date: 'Datum',
+      printName: 'Druckname',
+      printer: 'Drucker',
+      user: 'Benutzer',
+      status: 'Status',
+      duration: 'Dauer',
+      filament: 'Filament',
+      allPrinters: 'Alle Drucker',
+      allUsers: 'Alle Benutzer',
+      allStatuses: 'Alle Status',
+      cancelled: 'Abgebrochen',
+      skipped: 'Übersprungen',
+      dateFrom: 'Von',
+      dateTo: 'Bis',
+      noEntries: 'Keine Druckprotokolleinträge gefunden',
+      showing: '{{count}} von {{total}} Einträgen',
+      rowsPerPage: 'Zeilen',
+      page: 'Seite',
+      prev: 'Zurück',
+      next: 'Weiter',
+      clearLog: 'Protokoll löschen',
+      clearLogTitle: 'Druckprotokoll löschen',
+      clearLogConfirm: 'Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?',
+      clearLogButton: 'Alle löschen',
+      cleared: '{{count}} Protokolleinträge gelöscht',
+      clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',
+    },
   },
 
   // Queue page
@@ -1116,6 +1148,8 @@ export default {
     // Notifications
     notificationLanguage: 'Benachrichtigungssprache',
     notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
+    bedCooledThreshold: 'Bett-Abkühlung Schwellenwert',
+    bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt',
     notificationProviders: 'Benachrichtigungsanbieter',
     addProvider: 'Anbieter hinzufügen',
     editProvider: 'Anbieter bearbeiten',
@@ -1169,6 +1203,7 @@ export default {
     builtInFeatureRfid: 'Erkennt automatisch Bambu Lab RFID-Spulen im AMS',
     builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
     builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
+    builtInFeatureThirdParty: 'Drittanbieter-Spulen können Inventarspulen zugewiesen werden',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
@@ -1372,6 +1407,14 @@ export default {
     printsOnly: 'Nur Drucke',
     totalConsumption: 'Gesamtverbrauch',
     dataManagement: 'Datenverwaltung',
+    storageUsage: 'Speichernutzung',
+    storageUsageDescription: 'Aufschlüsselung der Datennutzung nach Kategorie',
+    storageUsageTotal: 'Gesamt',
+    storageUsageErrors: 'Fehler',
+    storageUsageOtherBreakdown: 'Sonstiges (enthält statische Assets, Skripte und Konfigurationsdateien)',
+    storageUsageSystem: 'System',
+    storageUsageData: 'Daten',
+    storageUsageUnavailable: 'Speichernutzungsinformationen nicht verfügbar',
     clearNotificationLogsDescription: 'Benachrichtigungsprotokolle älter als 30 Tage löschen',
     resetUiPreferencesDescription: 'Seitenleisten-Reihenfolge, Theme, Ansichtsmodi und Layout-Einstellungen zurücksetzen. Drucker, Archive und Einstellungen werden nicht beeinflusst.',
     enableHomeAssistant: 'Home Assistant aktivieren',
@@ -2497,6 +2540,7 @@ export default {
     spoolArchived: 'Spule archiviert',
     spoolRestored: 'Spule wiederhergestellt',
     deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
+    archiveConfirm: 'Möchten Sie diese Spule wirklich archivieren?',
     advancedSettings: 'Erweiterte Einstellungen',
     filamentInfoTab: 'Filament-Info',
     paProfileTab: 'PA-Profil',
@@ -3205,15 +3249,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'AMS-Slot konfigurieren',
+    slotConfigured: 'Slot konfiguriert!',
+    configuringSlot: 'Slot wird konfiguriert:',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Voreinstellungen suchen...',
     colorPlaceholder: 'Farbname oder Hex (z.B. braun, FF8800)',
     clearCustomColor: 'Benutzerdefinierte Farbe löschen',
     noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',
+    noPresetsAvailable: 'Keine Voreinstellungen verfügbar. Melden Sie sich bei Bambu Cloud an oder importieren Sie lokale Profile.',
     noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
     custom: 'Benutzerdefiniert',
     builtin: 'Integriert',
     settingsSentToPrinter: 'Einstellungen an Drucker gesendet',
     filamentProfile: 'Filamentprofil',
+    kProfileLabel: 'K-Profil (Pressure Advance)',
+    filteringFor: 'Filtern nach: {{material}}',
+    noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',
+    noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',
+    selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',
+    kFromCalibration: 'K={{value}} aus Druckerkalibrierung',
+    customColorLabel: 'Benutzerdefinierte Farbe (optional)',
+    presetColors: '{{name}} Farben:',
+    showLessColors: 'Weniger Farben anzeigen',
+    showMoreColors: 'Mehr Farben anzeigen',
+    clear: 'Löschen',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Wird zurückgesetzt...',
+    resetSlot: 'Slot zurücksetzen',
+    cancel: 'Abbrechen',
+    configuring: 'Wird konfiguriert...',
+    configureSlot: 'Slot konfigurieren',
   },
 
   // GitHub Backup Settings

+ 66 - 0
frontend/src/i18n/locales/en.ts

@@ -171,6 +171,8 @@ export default {
     // Printer card
     readyToPrint: 'Ready to print',
     external: 'External',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Delete print archives',
     noLabel: 'No label',
     printPreview: 'Print preview',
@@ -482,6 +484,7 @@ export default {
     gridView: 'Grid view',
     listView: 'List view',
     calendarView: 'Calendar view',
+    logView: 'Print Log',
     manageTags: 'Manage Tags',
     showFailedPrints: 'Show failed prints',
     hideFailedPrints: 'Hide failed prints',
@@ -608,6 +611,7 @@ export default {
       slicedFor: 'Sliced for {{model}}',
       uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
+      noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       reprint: 'Reprint',
@@ -669,6 +673,34 @@ export default {
       actions: 'Actions',
       hasTimelapse: 'Has timelapse',
     },
+    log: {
+      date: 'Date',
+      printName: 'Print Name',
+      printer: 'Printer',
+      user: 'User',
+      status: 'Status',
+      duration: 'Duration',
+      filament: 'Filament',
+      allPrinters: 'All Printers',
+      allUsers: 'All Users',
+      allStatuses: 'All Statuses',
+      cancelled: 'Cancelled',
+      skipped: 'Skipped',
+      dateFrom: 'From',
+      dateTo: 'To',
+      noEntries: 'No print log entries found',
+      showing: 'Showing {{count}} of {{total}} entries',
+      rowsPerPage: 'Rows',
+      page: 'Page',
+      prev: 'Prev',
+      next: 'Next',
+      clearLog: 'Clear Log',
+      clearLogTitle: 'Clear Print Log',
+      clearLogConfirm: 'All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?',
+      clearLogButton: 'Clear All',
+      cleared: '{{count}} log entries cleared',
+      clearFailed: 'Failed to clear print log',
+    },
   },
 
   // Queue page
@@ -1116,6 +1148,8 @@ export default {
     // Notifications
     notificationLanguage: 'Notification Language',
     notificationLanguageDescription: 'Language for push notifications',
+    bedCooledThreshold: 'Bed Cooled Threshold',
+    bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print',
     notificationProviders: 'Notification Providers',
     addProvider: 'Add Provider',
     editProvider: 'Edit Provider',
@@ -1169,6 +1203,7 @@ export default {
     builtInFeatureRfid: 'Automatically detects Bambu Lab RFID spools in AMS',
     builtInFeatureUsage: 'Tracks filament consumption per print',
     builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
+    builtInFeatureThirdParty: 'Third-party spools can be assigned to inventory spools',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
@@ -1372,6 +1407,14 @@ export default {
     printsOnly: 'Prints Only',
     totalConsumption: 'Total Consumption',
     dataManagement: 'Data Management',
+    storageUsage: 'Storage Usage',
+    storageUsageDescription: 'Breakdown of data usage by category',
+    storageUsageTotal: 'Total',
+    storageUsageErrors: 'Errors',
+    storageUsageOtherBreakdown: 'Other (includes static assets, scripts, and configuration files)',
+    storageUsageSystem: 'System',
+    storageUsageData: 'Data',
+    storageUsageUnavailable: 'Storage usage information unavailable',
     clearNotificationLogsDescription: 'Delete notification logs older than 30 days',
     resetUiPreferencesDescription: 'Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.',
     enableHomeAssistant: 'Enable Home Assistant',
@@ -2497,6 +2540,7 @@ export default {
     spoolArchived: 'Spool archived',
     spoolRestored: 'Spool restored',
     deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
+    archiveConfirm: 'Are you sure you want to archive this spool?',
     advancedSettings: 'Advanced Settings',
     // Tabs
     filamentInfoTab: 'Filament Info',
@@ -3210,15 +3254,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'Configure AMS Slot',
+    slotConfigured: 'Slot Configured!',
+    configuringSlot: 'Configuring slot:',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Search presets...',
     colorPlaceholder: 'Color name or hex (e.g., brown, FF8800)',
     clearCustomColor: 'Clear custom color',
     noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',
+    noPresetsAvailable: 'No presets available. Login to Bambu Cloud or import local profiles.',
     noMatchingPresets: 'No matching presets found.',
     custom: 'Custom',
     builtin: 'Built-in',
     settingsSentToPrinter: 'Settings sent to printer',
     filamentProfile: 'Filament Profile',
+    kProfileLabel: 'K Profile (Pressure Advance)',
+    filteringFor: 'Filtering for: {{material}}',
+    noKProfile: 'No K profile (use default 0.020)',
+    noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',
+    selectFilamentFirst: 'Select a filament profile first',
+    kFromCalibration: 'K={{value}} from printer calibration',
+    customColorLabel: 'Custom Color (optional)',
+    presetColors: '{{name}} colors:',
+    showLessColors: 'Show less colors',
+    showMoreColors: 'Show more colors',
+    clear: 'Clear',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Resetting...',
+    resetSlot: 'Reset Slot',
+    cancel: 'Cancel',
+    configuring: 'Configuring...',
+    configureSlot: 'Configure Slot',
   },
 
   // GitHub Backup Settings

+ 66 - 0
frontend/src/i18n/locales/fr.ts

@@ -171,6 +171,8 @@ export default {
     // Printer card
     readyToPrint: 'Prête à imprimer',
     external: 'Externe',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Supprimer les archives d\'impression',
     noLabel: 'Pas d\'étiquette',
     printPreview: 'Aperçu avant impression',
@@ -482,6 +484,7 @@ export default {
     gridView: 'Grille',
     listView: 'Liste',
     calendarView: 'Calendrier',
+    logView: 'Journal d\'impression',
     manageTags: 'Gérer les tags',
     showFailedPrints: 'Afficher les échecs',
     hideFailedPrints: 'Masquer les échecs',
@@ -608,6 +611,7 @@ export default {
       slicedFor: 'Découpé pour {{model}}',
       uploadedBy: 'Téléversé par',
       noPermissionReprint: 'Pas d\'autorisation de réimpression',
+      noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\'a pas pu être téléchargé depuis l\'imprimante lors de l\'enregistrement',
       noPermissionEdit: 'Pas d\'autorisation de modification',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
       reprint: 'Réimprimer',
@@ -669,6 +673,34 @@ export default {
       actions: 'Actions',
       hasTimelapse: 'A un timelapse',
     },
+    log: {
+      date: 'Date',
+      printName: 'Nom de l\'impression',
+      printer: 'Imprimante',
+      user: 'Utilisateur',
+      status: 'Statut',
+      duration: 'Durée',
+      filament: 'Filament',
+      allPrinters: 'Toutes les imprimantes',
+      allUsers: 'Tous les utilisateurs',
+      allStatuses: 'Tous les statuts',
+      cancelled: 'Annulé',
+      skipped: 'Ignoré',
+      dateFrom: 'Du',
+      dateTo: 'Au',
+      noEntries: 'Aucune entrée de journal trouvée',
+      showing: '{{count}} sur {{total}} entrées',
+      rowsPerPage: 'Lignes',
+      page: 'Page',
+      prev: 'Préc.',
+      next: 'Suiv.',
+      clearLog: 'Effacer le journal',
+      clearLogTitle: 'Effacer le journal d\'impression',
+      clearLogConfirm: 'Toutes les entrées du journal d\'impression seront supprimées définitivement. Les archives et les éléments de file d\'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?',
+      clearLogButton: 'Tout effacer',
+      cleared: '{{count}} entrées de journal effacées',
+      clearFailed: 'Échec de l\'effacement du journal d\'impression',
+    },
   },
 
   // Queue page
@@ -1112,6 +1144,8 @@ export default {
     // Notifications
     notificationLanguage: 'Langue des notifications',
     notificationLanguageDescription: 'Langue pour les notifications push',
+    bedCooledThreshold: 'Seuil de refroidissement du plateau',
+    bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi',
     notificationProviders: 'Fournisseurs de notifications',
     addProvider: 'Ajouter un fournisseur',
     editProvider: 'Modifier le fournisseur',
@@ -1165,6 +1199,7 @@ export default {
     builtInFeatureRfid: 'Détecte auto les bobines RFID Bambu Lab dans l\'AMS',
     builtInFeatureUsage: 'Suit la consommation par impression',
     builtInFeatureCatalog: 'Gère bobines, couleurs et profils facteur K',
+    builtInFeatureThirdParty: 'Les bobines tierces peuvent être assignées aux bobines d\'inventaire',
     // Spoolman settings
     spoolmanUrl: 'URL Spoolman',
     spoolmanUrlHint: 'URL de votre serveur Spoolman (ex: http://localhost:7912)',
@@ -1368,6 +1403,14 @@ export default {
     printsOnly: 'Impressions uniquement',
     totalConsumption: 'Consommation totale',
     dataManagement: 'Gestion des données',
+    storageUsage: 'Utilisation du stockage',
+    storageUsageDescription: 'Répartition de l\'utilisation des données par catégorie',
+    storageUsageTotal: 'Total',
+    storageUsageErrors: 'Erreurs',
+    storageUsageOtherBreakdown: 'Autre (inclut ressources statiques, scripts et fichiers de configuration)',
+    storageUsageSystem: 'Système',
+    storageUsageData: 'Données',
+    storageUsageUnavailable: 'Informations d\'utilisation du stockage non disponibles',
     clearNotificationLogsDescription: 'Supprimer logs de plus de 30 jours',
     resetUiPreferencesDescription: 'Réinitialise thèmes et affichage sans toucher aux données.',
     enableHomeAssistant: 'Activer Home Assistant',
@@ -2493,6 +2536,7 @@ export default {
     spoolArchived: 'Bobine archivée',
     spoolRestored: 'Bobine restaurée',
     deleteConfirm: 'Supprimer définitivement cette bobine ?',
+    archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',
     advancedSettings: 'Paramètres Avancés',
     // Tabs
     filamentInfoTab: 'Infos Filament',
@@ -3206,15 +3250,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'Configurer le slot AMS',
+    slotConfigured: 'Slot configuré !',
+    configuringSlot: 'Configuration du slot :',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Chercher presets...',
     colorPlaceholder: 'Nom couleur ou hex (ex: brown, FF8800)',
     clearCustomColor: 'Effacer couleur perso',
     noCloudPresets: 'Profils Cloud absents. Connectez-vous.',
+    noPresetsAvailable: 'Aucun preset disponible. Connectez-vous à Bambu Cloud ou importez des profils locaux.',
     noMatchingPresets: 'Aucun profil trouvé.',
     custom: 'Perso',
     builtin: 'Inclus',
     settingsSentToPrinter: 'Réglages envoyés',
     filamentProfile: 'Profil Filament',
+    kProfileLabel: 'Profil K (Pressure Advance)',
+    filteringFor: 'Filtrage pour : {{material}}',
+    noKProfile: 'Pas de profil K (utiliser défaut 0.020)',
+    noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',
+    selectFilamentFirst: 'Sélectionnez d\'abord un profil filament',
+    kFromCalibration: 'K={{value}} de la calibration imprimante',
+    customColorLabel: 'Couleur personnalisée (optionnel)',
+    presetColors: 'Couleurs {{name}} :',
+    showLessColors: 'Moins de couleurs',
+    showMoreColors: 'Plus de couleurs',
+    clear: 'Effacer',
+    hexLabel: 'Hex : #{{hex}}',
+    resetting: 'Réinitialisation...',
+    resetSlot: 'Réinitialiser le slot',
+    cancel: 'Annuler',
+    configuring: 'Configuration...',
+    configureSlot: 'Configurer le slot',
   },
 
   // GitHub Backup Settings

+ 77 - 0
frontend/src/i18n/locales/it.ts

@@ -168,6 +168,8 @@ export default {
     // Printer card
     readyToPrint: 'Pronta a stampare',
     external: 'Esterna',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Elimina archivi stampa',
     noLabel: 'Nessuna etichetta',
     printPreview: 'Anteprima stampa',
@@ -473,6 +475,7 @@ export default {
     gridView: 'Vista griglia',
     listView: 'Vista elenco',
     calendarView: 'Vista calendario',
+    logView: 'Registro stampe',
     manageTags: 'Gestisci tag',
     showFailedPrints: 'Mostra stampe fallite',
     hideFailedPrints: 'Nascondi stampe fallite',
@@ -599,6 +602,7 @@ export default {
       slicedFor: 'Sliced per {{model}}',
       uploadedBy: 'Caricato da',
       noPermissionReprint: 'Non hai il permesso di ristampare',
+      noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
       noPermissionEdit: 'Non hai il permesso di modificare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
       reprint: 'Ristampa',
@@ -660,6 +664,34 @@ export default {
       actions: 'Azioni',
       hasTimelapse: 'Ha timelapse',
     },
+    log: {
+      date: 'Data',
+      printName: 'Nome stampa',
+      printer: 'Stampante',
+      user: 'Utente',
+      status: 'Stato',
+      duration: 'Durata',
+      filament: 'Filamento',
+      allPrinters: 'Tutte le stampanti',
+      allUsers: 'Tutti gli utenti',
+      allStatuses: 'Tutti gli stati',
+      cancelled: 'Annullato',
+      skipped: 'Saltato',
+      dateFrom: 'Dal',
+      dateTo: 'Al',
+      noEntries: 'Nessuna voce di registro trovata',
+      showing: '{{count}} di {{total}} voci',
+      rowsPerPage: 'Righe',
+      page: 'Pagina',
+      prev: 'Prec.',
+      next: 'Succ.',
+      clearLog: 'Cancella registro',
+      clearLogTitle: 'Cancella registro stampe',
+      clearLogConfirm: 'Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?',
+      clearLogButton: 'Cancella tutto',
+      cleared: '{{count}} voci di registro cancellate',
+      clearFailed: 'Impossibile cancellare il registro stampe',
+    },
   },
 
   // Queue page
@@ -1049,6 +1081,8 @@ export default {
     // Notifications
     notificationLanguage: 'Lingua notifiche',
     notificationLanguageDescription: 'Lingua per notifiche push',
+    bedCooledThreshold: 'Soglia raffreddamento piatto',
+    bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa',
     notificationProviders: 'Provider notifiche',
     addProvider: 'Aggiungi provider',
     editProvider: 'Modifica provider',
@@ -1279,6 +1313,14 @@ export default {
     printsOnly: 'Solo stampe',
     totalConsumption: 'Consumo totale',
     dataManagement: 'Gestione dati',
+    storageUsage: 'Memoria utilizzata',
+    storageUsageDescription: 'Ripartizione della memoria per categoria',
+    storageUsageTotal: 'Totale',
+    storageUsageErrors: 'Errori',
+    storageUsageOtherBreakdown: 'Altro (include risorse statiche, script e file di configurazione)',
+    storageUsageSystem: 'Sistema',
+    storageUsageData: 'Dati',
+    storageUsageUnavailable: 'Informazioni sull\'utilizzo della memoria non disponibili',
     clearNotificationLogsDescription: 'Elimina log notifiche più vecchi di 30 giorni',
     resetUiPreferencesDescription: 'Reimposta ordine barra laterale, tema, modalità vista e preferenze layout. Stampanti, archivi e impostazioni non vengono modificati.',
     enableHomeAssistant: 'Abilita Home Assistant',
@@ -2733,4 +2775,39 @@ export default {
     replaceCarbonFilter: 'Sostituisci filtro a carbone attivo',
     lubricateLeftNozzleRail: 'Lubrifica guida ugello sinistro (serie H2)',
   },
+
+  // Configure AMS Slot Modal
+  configureAmsSlot: {
+    title: 'Configura Slot AMS',
+    slotConfigured: 'Slot configurato!',
+    configuringSlot: 'Configurazione slot:',
+    slotLabel: '{{ams}} Slot {{slot}}',
+    searchPresets: 'Cerca preset...',
+    colorPlaceholder: 'Nome colore o hex (es. marrone, FF8800)',
+    clearCustomColor: 'Cancella colore personalizzato',
+    noCloudPresets: 'Nessun preset cloud. Accedi a Bambu Cloud per sincronizzare.',
+    noPresetsAvailable: 'Nessun preset disponibile. Accedi a Bambu Cloud o importa profili locali.',
+    noMatchingPresets: 'Nessun preset corrispondente trovato.',
+    custom: 'Personalizzato',
+    builtin: 'Integrato',
+    settingsSentToPrinter: 'Impostazioni inviate alla stampante',
+    filamentProfile: 'Profilo filamento',
+    kProfileLabel: 'Profilo K (Pressure Advance)',
+    filteringFor: 'Filtrando per: {{material}}',
+    noKProfile: 'Nessun profilo K (usa predefinito 0.020)',
+    noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',
+    selectFilamentFirst: 'Seleziona prima un profilo filamento',
+    kFromCalibration: 'K={{value}} dalla calibrazione stampante',
+    customColorLabel: 'Colore personalizzato (opzionale)',
+    presetColors: 'Colori {{name}}:',
+    showLessColors: 'Mostra meno colori',
+    showMoreColors: 'Mostra più colori',
+    clear: 'Cancella',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Ripristino...',
+    resetSlot: 'Ripristina slot',
+    cancel: 'Annulla',
+    configuring: 'Configurazione...',
+    configureSlot: 'Configura slot',
+  },
 };

+ 66 - 0
frontend/src/i18n/locales/ja.ts

@@ -173,6 +173,8 @@ export default {
     noPrintersConfigured: 'プリンターが設定されていません',
     readyToPrint: '印刷可能',
     external: '外部',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: '印刷アーカイブを削除',
     willBeSkipped: 'スキップされます',
     name: '名前',
@@ -482,6 +484,7 @@ export default {
     gridView: 'グリッド表示',
     listView: 'リスト表示',
     calendarView: 'カレンダー表示',
+    logView: '印刷ログ',
     showFailedPrints: '失敗した印刷を表示',
     hideFailedPrints: '失敗した印刷を非表示',
     printTime: '印刷時間',
@@ -621,6 +624,7 @@ export default {
       slicedFor: '{{model}}用にスライス',
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
+      noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       openInBambuStudio: 'スライサーで開く',
       openInBambuStudioToSlice: 'スライサーでスライス',
@@ -674,6 +678,34 @@ export default {
       date: '日付',
       actions: '操作',
     },
+    log: {
+      date: '日時',
+      printName: '印刷名',
+      printer: 'プリンター',
+      user: 'ユーザー',
+      status: 'ステータス',
+      duration: '所要時間',
+      filament: 'フィラメント',
+      allPrinters: '全プリンター',
+      allUsers: '全ユーザー',
+      allStatuses: '全ステータス',
+      cancelled: 'キャンセル',
+      skipped: 'スキップ',
+      dateFrom: '開始日',
+      dateTo: '終了日',
+      noEntries: '印刷ログが見つかりません',
+      showing: '{{total}}件中{{count}}件を表示',
+      rowsPerPage: '行数',
+      page: 'ページ',
+      prev: '前へ',
+      next: '次へ',
+      clearLog: 'ログをクリア',
+      clearLogTitle: '印刷ログをクリア',
+      clearLogConfirm: 'すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか?',
+      clearLogButton: 'すべてクリア',
+      cleared: '{{count}}件のログエントリを削除しました',
+      clearFailed: '印刷ログの削除に失敗しました',
+    },
     noPrinterAvailable: '利用可能なプリンターがありません',
     archiveOrReprint: 'アーカイブまたは再印刷',
     multiPrinterPrint: 'マルチプリンター印刷',
@@ -1106,6 +1138,8 @@ export default {
     updateAvailable: 'アップデートあり',
     notificationLanguage: '通知の言語',
     notificationLanguageDescription: 'プッシュ通知の言語',
+    bedCooledThreshold: 'ベッド冷却しきい値',
+    bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度',
     notificationProviders: '通知プロバイダー',
     addProvider: 'プロバイダーを追加',
     editProvider: 'プロバイダーを編集',
@@ -1297,6 +1331,14 @@ export default {
     archiveSettings: 'アーカイブ設定',
     costTracking: 'コスト追跡',
     dataManagement: 'データ管理',
+    storageUsage: 'ストレージ使用量',
+    storageUsageDescription: 'カテゴリ別のデータ使用量の内訳',
+    storageUsageTotal: '合計',
+    storageUsageErrors: 'エラー',
+    storageUsageOtherBreakdown: 'その他(静的アセット、スクリプト、設定ファイルを含む)',
+    storageUsageSystem: 'システム',
+    storageUsageData: 'データ',
+    storageUsageUnavailable: 'ストレージ使用量情報は利用できません',
     enableMqtt: 'MQTTを有効化',
     useTls: 'TLSを使用',
     enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
@@ -1409,6 +1451,7 @@ export default {
     builtInFeatureRfid: 'AMS内のBambu Lab RFIDスプールを自動検出',
     builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
     builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
+    builtInFeatureThirdParty: 'サードパーティ製スプールをインベントリスプールに割り当て可能',
     // Spoolman設定
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
@@ -2428,6 +2471,7 @@ export default {
     spoolArchived: 'スプールをアーカイブしました',
     spoolRestored: 'スプールを復元しました',
     deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
+    archiveConfirm: 'このスプールをアーカイブしますか?',
     advancedSettings: '詳細設定',
     filamentInfoTab: 'フィラメント情報',
     paProfileTab: 'PAプロファイル',
@@ -2921,15 +2965,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'AMSスロットの設定',
+    slotConfigured: 'スロットを設定しました!',
+    configuringSlot: 'スロットを設定中:',
+    slotLabel: '{{ams}} スロット {{slot}}',
     searchPresets: 'プリセットを検索...',
     colorPlaceholder: '色名またはHex(例: 茶色、FF8800)',
     clearCustomColor: 'カスタム色をクリア',
     noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
+    noPresetsAvailable: 'プリセットがありません。Bambu Cloudにログインするか、ローカルプロファイルをインポートしてください。',
     noMatchingPresets: '一致するプリセットが見つかりません。',
     custom: 'カスタム',
     builtin: '内蔵',
     settingsSentToPrinter: '設定をプリンターに送信しました',
     filamentProfile: 'フィラメントプロファイル',
+    kProfileLabel: 'Kプロファイル(Pressure Advance)',
+    filteringFor: 'フィルター中: {{material}}',
+    noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
+    noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
+    selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
+    kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
+    customColorLabel: 'カスタム色(オプション)',
+    presetColors: '{{name}}の色:',
+    showLessColors: '色を減らす',
+    showMoreColors: '色をもっと表示',
+    clear: 'クリア',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'リセット中...',
+    resetSlot: 'スロットをリセット',
+    cancel: 'キャンセル',
+    configuring: '設定中...',
+    configureSlot: 'スロットを設定',
   },
 
   // Email Settings

+ 16 - 0
frontend/src/index.css

@@ -173,6 +173,22 @@
   --border-color: #2a3d30;
 }
 
+/* Printer card control buttons: stack only when they clip */
+.printer-control-buttons-container {
+  container-type: inline-size;
+}
+
+@container (max-width: 220px) {
+  .printer-control-buttons {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .printer-control-buttons > button {
+    width: 100%;
+  }
+}
+
 /* ============================================
    LAYER 2: STYLE EFFECTS
    ============================================ */

+ 375 - 39
frontend/src/pages/ArchivesPage.tsx

@@ -46,6 +46,7 @@ import {
   ChevronRight,
   Settings,
   User,
+  ClipboardList,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
@@ -303,15 +304,15 @@ function ArchiveCard({
         label: t('archives.menu.print'),
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
-        disabled: !canModify('archives', 'reprint', archive.created_by_id),
-        title: !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
+        disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),
+        title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
       },
       {
         label: t('archives.menu.schedule'),
         icon: <Calendar className="w-4 h-4" />,
         onClick: () => setShowSchedule(true),
-        disabled: !hasPermission('queue:create'),
-        title: !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
+        disabled: !archive.file_path || !hasPermission('queue:create'),
+        title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
       },
       {
         label: t('archives.menu.openInBambuStudio'),
@@ -321,6 +322,8 @@ function ArchiveCard({
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
           openInSlicer(downloadUrl, preferredSlicer);
         },
+        disabled: !archive.file_path,
+        title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
       },
     ] : [
       {
@@ -926,8 +929,8 @@ function ArchiveCard({
                 size="sm"
                 className="flex-1 min-w-0"
                 onClick={() => setShowReprint(true)}
-                disabled={!canModify('archives', 'reprint', archive.created_by_id)}
-                title={!canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : undefined}
+                disabled={!archive.file_path || !canModify('archives', 'reprint', archive.created_by_id)}
+                title={!archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : undefined}
               >
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <span className="hidden sm:inline">{t('archives.card.reprint')}</span>
@@ -937,8 +940,8 @@ function ArchiveCard({
                 size="sm"
                 className="flex-1 min-w-0"
                 onClick={() => setShowSchedule(true)}
-                disabled={!hasPermission('queue:create')}
-                title={!hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : t('archives.card.schedulePrint')}
+                disabled={!archive.file_path || !hasPermission('queue:create')}
+                title={!archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : t('archives.card.schedulePrint')}
               >
                 <Calendar className="w-3 h-3 flex-shrink-0" />
                 <span className="hidden sm:inline">{t('archives.card.schedule')}</span>
@@ -1438,15 +1441,15 @@ function ArchiveListRow({
         label: t('archives.menu.print'),
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
-        disabled: !canModify('archives', 'reprint', archive.created_by_id),
-        title: !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
+        disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),
+        title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
       },
       {
         label: t('archives.menu.schedule'),
         icon: <Calendar className="w-4 h-4" />,
         onClick: () => setShowSchedule(true),
-        disabled: !hasPermission('queue:create'),
-        title: !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
+        disabled: !archive.file_path || !hasPermission('queue:create'),
+        title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
       },
       {
         label: t('archives.menu.openInBambuStudio'),
@@ -1456,6 +1459,8 @@ function ArchiveListRow({
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
           openInSlicer(downloadUrl, preferredSlicer);
         },
+        disabled: !archive.file_path,
+        title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
       },
     ] : [
       {
@@ -1707,6 +1712,11 @@ function ArchiveListRow({
         <div className="col-span-4">
           <div className="flex items-center gap-2">
             <p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
+            {(archive.status === 'failed' || archive.status === 'aborted') && (
+              <span className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0">
+                {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
+              </span>
+            )}
             {archive.timelapse_path && (
               <span title={t('archives.list.hasTimelapse')}>
                 <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
@@ -2055,7 +2065,7 @@ function ArchiveListRow({
 }
 
 type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
-type ViewMode = 'grid' | 'list' | 'calendar';
+type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
 type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
 
 const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
@@ -2124,6 +2134,29 @@ export function ArchivesPage() {
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
 
+  // Log view state
+  const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
+    localStorage.getItem('logFilterUser') || null
+  );
+  const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>
+    localStorage.getItem('logFilterStatus')
+  );
+  const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>
+    localStorage.getItem('logFilterDateFrom') || ''
+  );
+  const [logFilterDateTo, setLogFilterDateTo] = useState(() =>
+    localStorage.getItem('logFilterDateTo') || ''
+  );
+  const [logOffset, setLogOffset] = useState(() => {
+    const saved = localStorage.getItem('logOffset');
+    return saved ? Number(saved) : 0;
+  });
+  const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
+  const [logPageSize, setLogPageSize] = useState(() => {
+    const saved = localStorage.getItem('logPageSize');
+    return saved ? Number(saved) : 25;
+  });
+
   // Clear highlight after 5 seconds and scroll to highlighted element
   useEffect(() => {
     if (highlightedArchiveId) {
@@ -2164,6 +2197,27 @@ export function ArchivesPage() {
     queryFn: api.getSettings,
   });
 
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: api.getUsers,
+    enabled: viewMode === 'log',
+  });
+
+  const { data: printLogData, isLoading: isLogLoading } = useQuery({
+    queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
+    queryFn: () => api.getPrintLog({
+      search: search || undefined,
+      printerId: filterPrinter || undefined,
+      username: logFilterUser || undefined,
+      status: logFilterStatus || undefined,
+      dateFrom: logFilterDateFrom || undefined,
+      dateTo: logFilterDateTo || undefined,
+      limit: logPageSize,
+      offset: logOffset,
+    }),
+    enabled: viewMode === 'log',
+  });
+
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
 
@@ -2182,6 +2236,18 @@ export function ArchivesPage() {
     },
   });
 
+  const clearLogMutation = useMutation({
+    mutationFn: () => api.clearPrintLog(),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      setLogOffset(0);
+      showToast(t('archives.log.cleared', { count: data.deleted }));
+    },
+    onError: () => {
+      showToast(t('archives.log.clearFailed'), 'error');
+    },
+  });
+
   // Persist all filters to localStorage
   useEffect(() => {
     if (filterPrinter !== null) {
@@ -2239,6 +2305,47 @@ export function ArchivesPage() {
     localStorage.setItem('archiveCollection', collection);
   }, [collection]);
 
+  // Persist log view filters
+  useEffect(() => {
+    if (logFilterUser) {
+      localStorage.setItem('logFilterUser', logFilterUser);
+    } else {
+      localStorage.removeItem('logFilterUser');
+    }
+  }, [logFilterUser]);
+
+  useEffect(() => {
+    if (logFilterStatus) {
+      localStorage.setItem('logFilterStatus', logFilterStatus);
+    } else {
+      localStorage.removeItem('logFilterStatus');
+    }
+  }, [logFilterStatus]);
+
+  useEffect(() => {
+    if (logFilterDateFrom) {
+      localStorage.setItem('logFilterDateFrom', logFilterDateFrom);
+    } else {
+      localStorage.removeItem('logFilterDateFrom');
+    }
+  }, [logFilterDateFrom]);
+
+  useEffect(() => {
+    if (logFilterDateTo) {
+      localStorage.setItem('logFilterDateTo', logFilterDateTo);
+    } else {
+      localStorage.removeItem('logFilterDateTo');
+    }
+  }, [logFilterDateTo]);
+
+  useEffect(() => {
+    localStorage.setItem('logOffset', logOffset.toString());
+  }, [logOffset]);
+
+  useEffect(() => {
+    localStorage.setItem('logPageSize', logPageSize.toString());
+  }, [logPageSize]);
+
   const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
 
   // Extract unique materials and colors from archives
@@ -2663,8 +2770,40 @@ export function ArchivesPage() {
         </div>
       </div>
 
-      {/* Filters */}
-      <Card className="mb-6">
+      {/* View mode toggle — always visible */}
+      <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4">
+        <button
+          className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('grid')}
+          title={t('archives.gridView')}
+        >
+          <LayoutGrid className="w-4 h-4" />
+        </button>
+        <button
+          className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('list')}
+          title={t('archives.listView')}
+        >
+          <List className="w-4 h-4" />
+        </button>
+        <button
+          className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('calendar')}
+          title={t('archives.calendarView')}
+        >
+          <CalendarDays className="w-4 h-4" />
+        </button>
+        <button
+          className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('log')}
+          title={t('archives.logView')}
+        >
+          <ClipboardList className="w-4 h-4" />
+        </button>
+      </div>
+
+      {/* Filters (hidden in log view which has its own filters) */}
+      {viewMode !== 'log' && <Card className="mb-6">
         <CardContent className="py-4">
           <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
             {/* Search - full width on mobile */}
@@ -2790,29 +2929,6 @@ export function ArchivesPage() {
                 <option value="size-asc">{t('archives.sortSmallest')}</option>
               </select>
             </div>
-            <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
-              <button
-                className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
-                onClick={() => setViewMode('grid')}
-                title={t('archives.gridView')}
-              >
-                <LayoutGrid className="w-4 h-4" />
-              </button>
-              <button
-                className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
-                onClick={() => setViewMode('list')}
-                title={t('archives.listView')}
-              >
-                <List className="w-4 h-4" />
-              </button>
-              <button
-                className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
-                onClick={() => setViewMode('calendar')}
-                title={t('archives.calendarView')}
-              >
-                <CalendarDays className="w-4 h-4" />
-              </button>
-            </div>
             </div>
             {hasTopFilters && (
               <Button
@@ -2870,7 +2986,7 @@ export function ArchivesPage() {
             </div>
           )}
         </CardContent>
-      </Card>
+      </Card>}
 
       {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
       <PendingUploadsPanel />
@@ -2949,6 +3065,211 @@ export function ArchivesPage() {
             ))}
           </div>
         </Card>
+      ) : viewMode === 'log' ? (
+        <div className="space-y-4">
+          {/* Log filters */}
+          <Card>
+            <CardContent className="py-3">
+              <div className="flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap">
+                {/* Search */}
+                <div className="flex-1 relative md:min-w-[200px]">
+                  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                  <input
+                    type="text"
+                    placeholder={t('archives.searchPlaceholder')}
+                    className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                    value={search}
+                    onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}
+                  />
+                </div>
+                {/* Printer filter */}
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                  value={filterPrinter || ''}
+                  onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}
+                >
+                  <option value="">{t('archives.log.allPrinters')}</option>
+                  {printers?.map((p) => (
+                    <option key={p.id} value={p.id}>{p.name}</option>
+                  ))}
+                </select>
+                {/* User filter */}
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                  value={logFilterUser || ''}
+                  onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}
+                >
+                  <option value="">{t('archives.log.allUsers')}</option>
+                  {users?.map((u) => (
+                    <option key={u.id} value={u.username}>{u.username}</option>
+                  ))}
+                </select>
+                {/* Status filter */}
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                  value={logFilterStatus || ''}
+                  onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}
+                >
+                  <option value="">{t('archives.log.allStatuses')}</option>
+                  <option value="completed">{t('archives.status.completed')}</option>
+                  <option value="failed">{t('archives.status.failed')}</option>
+                  <option value="stopped">{t('archives.status.stopped')}</option>
+                  <option value="cancelled">{t('archives.log.cancelled')}</option>
+                  <option value="skipped">{t('archives.log.skipped')}</option>
+                </select>
+                {/* Date range */}
+                <div className="flex items-center gap-2">
+                  <label className="text-sm text-bambu-gray">{t('archives.log.dateFrom')}</label>
+                  <input
+                    type="date"
+                    className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                    value={logFilterDateFrom}
+                    onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}
+                  />
+                </div>
+                <div className="flex items-center gap-2">
+                  <label className="text-sm text-bambu-gray">{t('archives.log.dateTo')}</label>
+                  <input
+                    type="date"
+                    className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                    value={logFilterDateTo}
+                    onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
+                  />
+                </div>
+                {/* Clear log button */}
+                <div className="ml-auto">
+                  <Button
+                    variant="danger"
+                    size="sm"
+                    onClick={() => setShowClearLogConfirm(true)}
+                    disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                    {t('archives.log.clearLog')}
+                  </Button>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Log table */}
+          <Card>
+            {isLogLoading ? (
+              <div className="flex items-center justify-center py-12">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+              </div>
+            ) : !printLogData?.items.length ? (
+              <div className="text-center py-12 text-bambu-gray">
+                {t('archives.log.noEntries')}
+              </div>
+            ) : (
+              <>
+                <div className="overflow-x-auto">
+                  <table className="w-full text-sm">
+                    <thead>
+                      <tr className="border-b border-bambu-dark-tertiary text-bambu-gray text-left">
+                        <th className="px-4 py-3 font-medium">{t('archives.log.date')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.printName')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.printer')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.user')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
+                      </tr>
+                    </thead>
+                    <tbody className="divide-y divide-bambu-dark-tertiary">
+                      {printLogData.items.map((entry) => (
+                        <tr key={entry.id} className="hover:bg-bambu-dark-secondary/50">
+                          <td className="px-4 py-3 text-white whitespace-nowrap">
+                            {formatDateTime(entry.started_at || entry.created_at, timeFormat)}
+                          </td>
+                          <td className="px-4 py-3">
+                            <div className="flex items-center gap-2">
+                              {entry.thumbnail_path && (
+                                <img
+                                  src={api.getPrintLogThumbnail(entry.id)}
+                                  alt=""
+                                  className="w-8 h-8 rounded object-cover flex-shrink-0"
+                                  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
+                                />
+                              )}
+                              <span className="text-white truncate max-w-[200px]">
+                                {entry.print_name || '—'}
+                              </span>
+                            </div>
+                          </td>
+                          <td className="px-4 py-3 text-bambu-gray-light">{entry.printer_name || '—'}</td>
+                          <td className="px-4 py-3 text-bambu-gray-light">{entry.created_by_username || '—'}</td>
+                          <td className="px-4 py-3">
+                            <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+                              entry.status === 'completed' ? 'bg-green-500/20 text-green-400' :
+                              entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :
+                              entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
+                              entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :
+                              entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :
+                              'bg-gray-500/20 text-gray-400'
+                            }`}>
+                              {entry.status}
+                            </span>
+                          </td>
+                          <td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
+                            {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
+                          </td>
+                          <td className="px-4 py-3">
+                            <div className="flex items-center gap-1.5">
+                              {entry.filament_color && (
+                                <span
+                                  className="w-3 h-3 rounded-full border border-white/20 flex-shrink-0"
+                                  style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}
+                                />
+                              )}
+                              <span className="text-bambu-gray-light text-xs">
+                                {entry.filament_type || '—'}
+                              </span>
+                            </div>
+                          </td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+                {/* Pagination */}
+                <div className="flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2">
+                  <div className="flex items-center gap-3">
+                    <span className="text-sm text-bambu-gray">
+                      {t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
+                    </span>
+                    <div className="flex items-center gap-1.5">
+                      <label className="text-xs text-bambu-gray">{t('archives.log.rowsPerPage')}</label>
+                      <select
+                        className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
+                        value={logPageSize}
+                        onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}
+                      >
+                        <option value={10}>10</option>
+                        <option value={25}>25</option>
+                        <option value={50}>50</option>
+                        <option value={100}>100</option>
+                      </select>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <span className="text-sm text-bambu-gray">
+                      {t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
+                    </span>
+                    <Button variant="secondary" size="sm" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>
+                      <ChevronLeft className="w-4 h-4" />
+                    </Button>
+                    <Button variant="secondary" size="sm" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>
+                      <ChevronRight className="w-4 h-4" />
+                    </Button>
+                  </div>
+                </div>
+              </>
+            )}
+          </Card>
+
+        </div>
       ) : null}
 
       {/* Upload Modal */}
@@ -3010,6 +3331,21 @@ export function ArchivesPage() {
       {showTagManagement && (
         <TagManagementModal onClose={() => setShowTagManagement(false)} />
       )}
+
+      {/* Clear Log Confirmation */}
+      {showClearLogConfirm && (
+        <ConfirmModal
+          title={t('archives.log.clearLogTitle')}
+          message={t('archives.log.clearLogConfirm')}
+          confirmText={t('archives.log.clearLogButton')}
+          variant="danger"
+          onConfirm={() => {
+            clearLogMutation.mutate();
+            setShowClearLogConfirm(false);
+          }}
+          onCancel={() => setShowClearLogConfirm(false)}
+        />
+      )}
     </div>
   );
 }

+ 23 - 6
frontend/src/pages/InventoryPage.tsx

@@ -11,6 +11,7 @@ import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from '../components/Button';
 import { SpoolFormModal } from '../components/SpoolFormModal';
+import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { useToast } from '../contexts/ToastContext';
 import { resolveSpoolColorName } from '../utils/colors';
@@ -303,6 +304,7 @@ export default function InventoryPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
+  const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
 
   // Filter state
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -947,7 +949,7 @@ export default function InventoryPage() {
                               </button>
                             ) : (
                               <button
-                                onClick={() => archiveMutation.mutate(spool.id)}
+                                onClick={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
                                 className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors"
                                 title={t('inventory.archive')}
                               >
@@ -955,11 +957,7 @@ export default function InventoryPage() {
                               </button>
                             )}
                             <button
-                              onClick={() => {
-                                if (confirm(t('inventory.deleteConfirm'))) {
-                                  deleteMutation.mutate(spool.id);
-                                }
-                              }}
+                              onClick={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
                               className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors"
                               title={t('common.delete')}
                             >
@@ -1056,6 +1054,25 @@ export default function InventoryPage() {
         />
       )}
 
+      {/* Confirm Modal (delete / archive) */}
+      {confirmAction && (
+        <ConfirmModal
+          title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
+          message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}
+          confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
+          variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
+          onConfirm={() => {
+            if (confirmAction.type === 'delete') {
+              deleteMutation.mutate(confirmAction.spoolId);
+            } else {
+              archiveMutation.mutate(confirmAction.spoolId);
+            }
+            setConfirmAction(null);
+          }}
+          onCancel={() => setConfirmAction(null)}
+        />
+      )}
+
       {/* Column Config Modal */}
       <ColumnConfigModal
         isOpen={showColumnModal}

+ 3 - 3
frontend/src/pages/MaintenancePage.tsx

@@ -286,9 +286,9 @@ function MaintenanceCard({
 
   return (
     <div className={`rounded-xl border p-4 transition-all ${getBgColor()}`}>
-      <div className="flex items-start gap-3">
+      <div className="flex items-start gap-3 max-[550px]:flex-wrap">
         {/* Icon with status indicator */}
-        <div className={`relative p-2.5 rounded-lg ${
+        <div className={`relative p-2.5 rounded-lg shrink-0 ${
           item.is_due ? 'bg-red-500/20' :
           item.is_warning ? 'bg-amber-500/20' :
           item.enabled ? 'bg-bambu-dark' : 'bg-bambu-dark/50'
@@ -351,7 +351,7 @@ function MaintenanceCard({
         </div>
 
         {/* Actions */}
-        <div className="flex items-center gap-2 shrink-0">
+        <div className="flex items-center gap-2 shrink-0 max-[550px]:w-full max-[550px]:justify-end max-[550px]:mt-1">
           <span title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionUpdate') : undefined}>
             <Toggle
               checked={item.enabled}

+ 249 - 201
frontend/src/pages/PrintersPage.tsx

@@ -1309,7 +1309,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
   if (!printers?.length) return null;
 
   return (
-    <div className="flex items-center gap-4 text-sm">
+    <div className="flex flex-wrap items-center gap-4 gap-y-2 text-sm">
       <div className="flex items-center gap-1.5">
         <div className={`w-2 h-2 rounded-full ${counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500'}`} />
         <span className="text-bambu-gray">
@@ -1335,17 +1335,21 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
       {nextFinish && (
         <>
           <div className="w-px h-4 bg-bambu-dark-tertiary" />
-          <div className="flex items-center gap-2">
-            <span className="text-bambu-green font-medium">{t('printers.nextAvailable')}:</span>
-            <span className="text-white font-medium">{nextFinish.name}</span>
-            <div className="w-16 bg-bambu-dark-tertiary rounded-full h-1.5">
-              <div
-                className="bg-bambu-green h-1.5 rounded-full transition-all"
-                style={{ width: `${nextFinish.progress}%` }}
-              />
+          <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
+            <div className="flex items-center gap-2">
+              <span className="text-bambu-green font-medium">{t('printers.nextAvailable')}:</span>
+              <span className="text-white font-medium">{nextFinish.name}</span>
+            </div>
+            <div className="flex items-center gap-2 w-full sm:w-auto">
+              <div className="w-full sm:w-16 bg-bambu-dark-tertiary rounded-full h-1.5">
+                <div
+                  className="bg-bambu-green h-1.5 rounded-full transition-all"
+                  style={{ width: `${nextFinish.progress}%` }}
+                />
+              </div>
+              <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
+              <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
             </div>
-            <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
-            <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
           </div>
         </>
       )}
@@ -1384,6 +1388,44 @@ function getStatusDisplay(state: string | null | undefined, stg_cur_name: string
   }
 }
 
+// Map SSDP model codes to display names
+function mapModelCode(ssdpModel: string | null): string {
+  if (!ssdpModel) return '';
+  const modelMap: Record<string, string> = {
+    // H2 Series
+    'O1D': 'H2D',
+    'O1E': 'H2D Pro',
+    'O2D': 'H2D Pro',
+    'O1C': 'H2C',
+    'O1S': 'H2S',
+    // X1 Series
+    'BL-P001': 'X1C',
+    'BL-P002': 'X1',
+    'BL-P003': 'X1E',
+    // P Series
+    'C11': 'P1S',
+    'C12': 'P1P',
+    'C13': 'P2S',
+    // A1 Series
+    'N2S': 'A1',
+    'N1': 'A1 Mini',
+    // Direct matches
+    'X1C': 'X1C',
+    'X1': 'X1',
+    'X1E': 'X1E',
+    'P1S': 'P1S',
+    'P1P': 'P1P',
+    'P2S': 'P2S',
+    'A1': 'A1',
+    'A1 Mini': 'A1 Mini',
+    'H2D': 'H2D',
+    'H2D Pro': 'H2D Pro',
+    'H2C': 'H2C',
+    'H2S': 'H2S',
+  };
+  return modelMap[ssdpModel] || ssdpModel;
+}
+
 function PrinterCard({
   printer,
   hideIfDisconnected,
@@ -1469,6 +1511,9 @@ function PrinterCard({
     trayColor?: string;
     traySubBrands?: string;
     trayInfoIdx?: string;
+    extruderId?: number;
+    caliIdx?: number | null;
+    savedPresetId?: string;
   } | null>(null);
   const [showFirmwareModal, setShowFirmwareModal] = useState(false);
   const [plateCheckResult, setPlateCheckResult] = useState<{
@@ -1516,8 +1561,8 @@ function PrinterCard({
         }
       }
     }
-    if (status?.vt_tray?.tray_info_idx) {
-      ids.add(status.vt_tray.tray_info_idx);
+    for (const vt of status?.vt_tray ?? []) {
+      if (vt.tray_info_idx) ids.add(vt.tray_info_idx);
     }
     if (status?.nozzle_rack) {
       for (const slot of status.nozzle_rack) {
@@ -1582,18 +1627,20 @@ function PrinterCard({
   }, [status?.ams]);
   const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
 
-  // Cache tray_now to prevent flickering when 255 (unloaded) or undefined values come in
-  // Only update cache when we get a valid tray ID (0-253 or 254 for external)
-  const cachedTrayNow = useRef<number>(255);
+  // Cache tray_now to prevent flickering when undefined values come in
+  // Valid tray IDs: 0-253 for AMS, 254 for external spool
+  // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
+  const cachedTrayNow = useRef<number | undefined>(undefined);
   const currentTrayNow = status?.tray_now;
-  // Update cache synchronously during render if we have a valid value
+  // Update cache: 255 means "no tray" so clear cache; valid values get cached
   if (currentTrayNow !== undefined && currentTrayNow !== 255) {
     cachedTrayNow.current = currentTrayNow;
+  } else if (currentTrayNow === 255) {
+    cachedTrayNow.current = undefined;
   }
-  // Use cached value if current is 255/undefined but we had a valid value before
-  const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255)
-    ? cachedTrayNow.current
-    : currentTrayNow;
+  const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)
+    ? currentTrayNow
+    : cachedTrayNow.current;
 
   // Fetch smart plug for this printer
   const { data: smartPlug } = useQuery({
@@ -2240,7 +2287,7 @@ function PrinterCard({
                 </button>
               )}
               {/* Firmware Version Badge */}
-              {firmwareInfo?.current_version && firmwareInfo?.latest_version && (
+              {checkPrinterFirmware && firmwareInfo?.current_version && firmwareInfo?.latest_version ? (
                 <button
                   onClick={() => setShowFirmwareModal(true)}
                   className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs hover:opacity-80 transition-opacity ${
@@ -2257,7 +2304,11 @@ function PrinterCard({
                   {firmwareInfo.update_available ? <Download className="w-3 h-3" /> : <CheckCircle className="w-3 h-3" />}
                   {firmwareInfo.current_version}
                 </button>
-              )}
+              ) : status?.firmware_version ? (
+                <span className="flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-bambu-dark-tertiary/50 text-bambu-gray">
+                  {status.firmware_version}
+                </span>
+              ) : null}
             </div>
           )}
         </div>
@@ -2575,9 +2626,9 @@ function PrinterCard({
                     <div className="flex-1 h-px bg-bambu-dark-tertiary/30" />
                   </div>
 
-                  <div className="flex items-center justify-between gap-2">
+                  <div className="flex items-center justify-between gap-2 max-[550px]:items-start">
                     {/* Left: Fan Status - always visible, dynamic coloring */}
-                    <div className="flex items-center gap-2">
+                    <div className="flex items-center gap-2 min-w-0 max-[550px]:flex-wrap max-[550px]:items-start max-[550px]:gap-1.5">
                       {/* Part Cooling Fan */}
                       <div
                         className={`flex items-center gap-1 px-1.5 py-1 rounded ${partFan && partFan > 0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`}
@@ -2613,7 +2664,7 @@ function PrinterCard({
                     </div>
 
                     {/* Right: Print Control Buttons */}
-                    <div className="flex items-center gap-2">
+                    <div className="flex items-center gap-2 flex-shrink-0 max-[550px]:self-start">
                       {/* Stop button */}
                       <button
                         onClick={() => setShowStopConfirm(true)}
@@ -2658,7 +2709,7 @@ function PrinterCard({
             })()}
 
             {/* AMS Units - 2-Column Grid Layout */}
-            {amsData && amsData.length > 0 && viewMode === 'expanded' && (() => {
+            {(amsData?.length > 0 || status.vt_tray.length > 0) && viewMode === 'expanded' && (() => {
               // Separate regular AMS (4-tray) from HT AMS (1-tray)
               const regularAms = amsData.filter(ams => ams.tray.length > 1);
               const htAms = amsData.filter(ams => ams.tray.length === 1);
@@ -2699,7 +2750,7 @@ function PrinterCard({
                                 )}
                               </div>
                               {(ams.humidity != null || ams.temp != null) && (
-                                <div className="flex items-center gap-1.5">
+                                <div className="flex items-center gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
                                   {ams.humidity != null && (
                                     <HumidityIndicator
                                       humidity={ams.humidity}
@@ -2883,7 +2934,7 @@ function PrinterCard({
                                             });
                                           } : undefined,
                                         }}
-                                        inventory={(() => {
+                                        inventory={spoolmanEnabled ? undefined : (() => {
                                           const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
                                           return {
                                             assignedSpool: assignment?.spool ? {
@@ -2915,6 +2966,9 @@ function PrinterCard({
                                             trayColor: tray?.tray_color || undefined,
                                             traySubBrands: tray?.tray_sub_brands || undefined,
                                             trayInfoIdx: tray?.tray_info_idx || undefined,
+                                            extruderId: mappedExtruderId,
+                                            caliIdx: tray?.cali_idx,
+                                            savedPresetId: slotPreset?.preset_id,
                                           }),
                                         }}
                                       >
@@ -2928,6 +2982,7 @@ function PrinterCard({
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayCount: ams.tray.length,
+                                            extruderId: mappedExtruderId,
                                           }),
                                         }}
                                       >
@@ -2945,7 +3000,7 @@ function PrinterCard({
                   )}
 
                     {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */}
-                    {(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && (
+                    {(htAms.length > 0 || status.vt_tray.length > 0) && (
                       <div className="grid grid-cols-4 gap-3">
                       {/* HT AMS units - name/badge top, slot left, stats right */}
                       {htAms.map((ams) => {
@@ -3049,9 +3104,9 @@ function PrinterCard({
                               )}
                             </div>
                             {/* Row 2: Slot (left) + Stats (right stacked) */}
-                            <div className="flex gap-1.5">
+                            <div className="flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
                               {/* Slot wrapper with menu button, dropdown, and loading overlay */}
-                              <div className="relative group flex-1">
+                              <div className="relative group flex-1 max-[550px]:w-full">
                                 {/* Loading overlay during RFID re-read */}
                                 {isHtRefreshing && (
                                   <div className="absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20">
@@ -3117,7 +3172,7 @@ function PrinterCard({
                                         });
                                       } : undefined,
                                     }}
-                                    inventory={(() => {
+                                    inventory={spoolmanEnabled ? undefined : (() => {
                                       const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
                                       return {
                                         assignedSpool: assignment?.spool ? {
@@ -3149,6 +3204,9 @@ function PrinterCard({
                                         trayColor: tray?.tray_color || undefined,
                                         traySubBrands: tray?.tray_sub_brands || undefined,
                                         trayInfoIdx: tray?.tray_info_idx || undefined,
+                                        extruderId: mappedExtruderId,
+                                        caliIdx: tray?.cali_idx,
+                                        savedPresetId: slotPreset?.preset_id,
                                       }),
                                     }}
                                   >
@@ -3162,6 +3220,7 @@ function PrinterCard({
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayCount: ams.tray.length,
+                                        extruderId: mappedExtruderId,
                                       }),
                                     }}
                                   >
@@ -3171,7 +3230,7 @@ function PrinterCard({
                               </div>
                               {/* Stats stacked vertically: Temp on top, Humidity below */}
                               {(ams.humidity != null || ams.temp != null) && (
-                                <div className="flex flex-col justify-center gap-1 shrink-0">
+                                <div className="flex flex-col justify-center gap-1 shrink-0 max-[550px]:w-full">
                                   {ams.temp != null && (
                                     <TemperatureIndicator
                                       temp={ams.temp}
@@ -3204,138 +3263,162 @@ function PrinterCard({
                           </div>
                         );
                       })}
-                      {/* External spool - name top, slot below (no stats) */}
-                      {status.vt_tray && status.vt_tray.tray_type && (() => {
-                        const extTray = status.vt_tray;
-                        // Check if external spool is active (tray_now = 254)
-                        const isExtActive = effectiveTrayNow === 254;
-                        // Get cloud preset info if available
-                        const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
-                        // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
-                        const extSlotPreset = slotPresets?.[255 * 4 + 0];
-
-                        // Fill level fallback chain: Spoolman → Inventory spool
-                        const extTrayTag = extTray.tray_uuid?.toUpperCase();
-                        const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
-                        const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
-                        const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0);
-                        const extInventoryFill = (() => {
-                          const sp = extInventoryAssignment?.spool;
-                          if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
-                            return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
-                          }
-                          return null;
-                        })();
-                        const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
-
-                        // Build filament data for hover card
-                        const extFilamentData = {
-                          vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
-                          colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
-                          colorHex: extTray.tray_color || null,
-                          kFactor: formatKValue(extTray.k),
-                          fillLevel: extEffectiveFill,
-                          trayUuid: extTray.tray_uuid || null,
-                          tagUid: extTray.tag_uid || null,
-                          fillSource: extSpoolmanFill !== null ? 'spoolman' as const
-                            : extInventoryFill !== null ? 'inventory' as const
-                            : undefined,
-                        };
-
-                        const extSlotContent = (
-                          <div className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
-                            <div
-                              className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
-                              style={{
-                                backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : '#333',
-                                borderColor: isExtActive ? 'var(--accent)' : 'rgba(255,255,255,0.1)',
-                              }}
-                            />
-                            <div className="text-[9px] text-white font-bold truncate">
-                              {extTray.tray_type || 'Spool'}
-                            </div>
-                            {/* Fill bar - use Spoolman data if available */}
-                            <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              {extSpoolmanFill !== null ? (
-                                <div
-                                  className="h-full rounded-full transition-all"
-                                  style={{
-                                    width: `${extSpoolmanFill}%`,
-                                    backgroundColor: getFillBarColor(extSpoolmanFill),
-                                  }}
-                                />
-                              ) : (
-                                <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
-                              )}
-                            </div>
+                      {/* External spool(s) - grouped in one card like regular AMS */}
+                      {status.vt_tray.length > 0 && (
+                        <div className={`p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30 ${status.vt_tray.length === 1 ? 'max-w-[50%]' : ''}`}>
+                          <div className="flex items-center gap-1 mb-2">
+                            <span className="text-[10px] text-white font-medium">{t('printers.external')}</span>
                           </div>
-                        );
-
-                        return (
-                          <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
-                            {/* Row 1: Label */}
-                            <div className="flex items-center gap-1 mb-2">
-                              <span className="text-[10px] text-white font-medium">{t('printers.external')}</span>
-                            </div>
-                            {/* Row 2: Slot (full width since no stats) */}
-                            <FilamentHoverCard
-                              data={extFilamentData}
-                              spoolman={{
-                                enabled: spoolmanEnabled,
-                                hasUnlinkedSpools,
-                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
-                                spoolmanUrl,
-                                onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
-                                  setLinkSpoolModal({
-                                    tagUid: extFilamentData.tagUid || '',
-                                    trayUuid: uuid,
-                                    printerId: printer.id,
-                                    amsId: 255,
-                                    trayId: 0,
-                                  });
-                                } : undefined,
-                              }}
-                              inventory={(() => {
-                                const assignment = onGetAssignment?.(printer.id, 255, 0);
-                                return {
-                                  assignedSpool: assignment?.spool ? {
-                                    id: assignment.spool.id,
-                                    material: assignment.spool.material,
-                                    brand: assignment.spool.brand,
-                                    color_name: assignment.spool.color_name,
-                                  } : null,
-                                  onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
-                                    printerId: printer.id,
-                                    amsId: 255,
-                                    trayId: 0,
-                                    trayInfo: {
-                                      type: extFilamentData.profile,
-                                      color: extFilamentData.colorHex || '',
-                                      location: 'External Spool',
-                                    },
-                                  }) : undefined,
-                                  onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
-                                };
-                              })()}
-                              configureSlot={{
-                                enabled: hasPermission('printers:control'),
-                                onConfigure: () => setConfigureSlotModal({
-                                  amsId: 255, // External spool indicator
-                                  trayId: 0,
-                                  trayCount: 1, // External = single slot
-                                  trayType: extTray.tray_type || undefined,
-                                  trayColor: extTray.tray_color || undefined,
-                                  traySubBrands: extTray.tray_sub_brands || undefined,
-                                  trayInfoIdx: extTray.tray_info_idx || undefined,
-                                }),
-                              }}
-                            >
-                              {extSlotContent}
-                            </FilamentHoverCard>
+                          <div className={`grid ${status.vt_tray.length > 1 ? 'grid-cols-2' : 'grid-cols-1'} gap-1.5`}>
+                            {[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => {
+                              const extTrayId = extTray.id ?? 254;
+                              const isExtActive = effectiveTrayNow === extTrayId;
+                              const slotTrayId = extTrayId - 254; // 0 or 1
+                              const extLabel = isDualNozzle
+                                ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR'))
+                                : '';
+                              const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
+                              const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
+
+                              const extTrayTag = extTray.tray_uuid?.toUpperCase();
+                              const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
+                              const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
+                              const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
+                              const extInventoryFill = (() => {
+                                const sp = extInventoryAssignment?.spool;
+                                if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                                  return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+                                }
+                                return null;
+                              })();
+                              const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
+
+                              const extFilamentData = {
+                                vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                                profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                                colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
+                                colorHex: extTray.tray_color || null,
+                                kFactor: formatKValue(extTray.k),
+                                fillLevel: extEffectiveFill,
+                                trayUuid: extTray.tray_uuid || null,
+                                tagUid: extTray.tag_uid || null,
+                                fillSource: extSpoolmanFill !== null ? 'spoolman' as const
+                                  : extInventoryFill !== null ? 'inventory' as const
+                                  : undefined,
+                              };
+
+                              const isEmpty = !extTray.tray_type;
+                              const extSlotContent = (
+                                <div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
+                                  <div
+                                    className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                                    style={{
+                                      backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : (extTray.tray_type ? '#333' : 'transparent'),
+                                      borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+                                      borderStyle: isEmpty ? 'dashed' : 'solid',
+                                    }}
+                                  />
+                                  <div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
+                                    {extTray.tray_type || '—'}
+                                  </div>
+                                  <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                                    {extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty ? (
+                                      <div
+                                        className="h-full rounded-full transition-all"
+                                        style={{
+                                          width: `${extEffectiveFill}%`,
+                                          backgroundColor: getFillBarColor(extEffectiveFill),
+                                        }}
+                                      />
+                                    ) : !isEmpty ? (
+                                      <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                                    ) : null}
+                                  </div>
+                                  {extLabel && <div className="text-[7px] text-white/40 mt-0.5 truncate">{extLabel}</div>}
+                                </div>
+                              );
+
+                              return (
+                                <div key={extTrayId} className="relative group">
+                                  {!isEmpty ? (
+                                    <FilamentHoverCard
+                                      data={extFilamentData}
+                                      spoolman={{
+                                        enabled: spoolmanEnabled,
+                                        hasUnlinkedSpools,
+                                        linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
+                                        spoolmanUrl,
+                                        onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
+                                          setLinkSpoolModal({
+                                            tagUid: extFilamentData.tagUid || '',
+                                            trayUuid: uuid,
+                                            printerId: printer.id,
+                                            amsId: 255,
+                                            trayId: slotTrayId,
+                                          });
+                                        } : undefined,
+                                      }}
+                                      inventory={spoolmanEnabled ? undefined : (() => {
+                                        const assignment = onGetAssignment?.(printer.id, 255, slotTrayId);
+                                        return {
+                                          assignedSpool: assignment?.spool ? {
+                                            id: assignment.spool.id,
+                                            material: assignment.spool.material,
+                                            brand: assignment.spool.brand,
+                                            color_name: assignment.spool.color_name,
+                                          } : null,
+                                          onAssignSpool: () => setAssignSpoolModal({
+                                            printerId: printer.id,
+                                            amsId: 255,
+                                            trayId: slotTrayId,
+                                            trayInfo: {
+                                              type: extFilamentData.profile,
+                                              color: extFilamentData.colorHex || '',
+                                              location: extLabel || t('printers.external'),
+                                            },
+                                          }),
+                                          onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined,
+                                        };
+                                      })()}
+                                      configureSlot={{
+                                        enabled: hasPermission('printers:control'),
+                                        onConfigure: () => setConfigureSlotModal({
+                                          amsId: 255,
+                                          trayId: slotTrayId,
+                                          trayCount: 1,
+                                          trayType: extTray.tray_type || undefined,
+                                          trayColor: extTray.tray_color || undefined,
+                                          traySubBrands: extTray.tray_sub_brands || undefined,
+                                          trayInfoIdx: extTray.tray_info_idx || undefined,
+                                          extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
+                                          caliIdx: extTray.cali_idx,
+                                          savedPresetId: extSlotPreset?.preset_id,
+                                        }),
+                                      }}
+                                    >
+                                      {extSlotContent}
+                                    </FilamentHoverCard>
+                                  ) : (
+                                    <EmptySlotHoverCard
+                                      configureSlot={{
+                                        enabled: hasPermission('printers:control'),
+                                        onConfigure: () => setConfigureSlotModal({
+                                          amsId: 255,
+                                          trayId: slotTrayId,
+                                          trayCount: 1,
+                                          extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
+                                        }),
+                                      }}
+                                    >
+                                      {extSlotContent}
+                                    </EmptySlotHoverCard>
+                                  )}
+                                </div>
+                              );
+                            })}
                           </div>
-                        );
-                      })()}
+                        </div>
+                      )}
                       </div>
                     )}
                   </div>
@@ -3974,6 +4057,7 @@ function PrinterCard({
           onClose={() => setConfigureSlotModal(null)}
           printerId={printer.id}
           slotInfo={configureSlotModal}
+          printerModel={mapModelCode(printer.model) || undefined}
           onSuccess={() => {
             // Refresh slot presets to show updated profile name
             queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
@@ -4128,43 +4212,7 @@ function AddPrinterModal({
     }
   };
 
-  // Map SSDP model codes to dropdown values
-  const mapModelCode = (ssdpModel: string | null): string => {
-    if (!ssdpModel) return '';
-    const modelMap: Record<string, string> = {
-      // H2 Series
-      'O1D': 'H2D',
-      'O1E': 'H2D Pro',  // Some devices report O1E
-      'O2D': 'H2D Pro',  // Some devices report O2D
-      'O1C': 'H2C',
-      'O1S': 'H2S',
-      // X1 Series
-      'BL-P001': 'X1C',
-      'BL-P002': 'X1',
-      'BL-P003': 'X1E',
-      // P Series
-      'C11': 'P1S',
-      'C12': 'P1P',
-      'C13': 'P2S',
-      // A1 Series
-      'N2S': 'A1',
-      'N1': 'A1 Mini',
-      // Direct matches
-      'X1C': 'X1C',
-      'X1': 'X1',
-      'X1E': 'X1E',
-      'P1S': 'P1S',
-      'P1P': 'P1P',
-      'P2S': 'P2S',
-      'A1': 'A1',
-      'A1 Mini': 'A1 Mini',
-      'H2D': 'H2D',
-      'H2D Pro': 'H2D Pro',
-      'H2C': 'H2C',
-      'H2S': 'H2S',
-    };
-    return modelMap[ssdpModel] || ssdpModel;
-  };
+  // Reuse module-level mapModelCode
 
   const selectPrinter = (printer: DiscoveredPrinter) => {
     // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial

+ 9 - 9
frontend/src/pages/ProfilesPage.tsx

@@ -262,7 +262,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
             </div>
           )}
 
-          <div className="flex gap-2">
+          <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
             {step === 'code' && (
               <Button type="button" variant="secondary" onClick={() => setStep('email')} className="flex-1">
                 {t('profiles.login.back')}
@@ -1723,7 +1723,7 @@ function CreatePresetModal({
         />
       )}
 
-      <Card className="w-full max-w-6xl max-h-[90vh] flex flex-col">
+      <Card className="w-full max-w-6xl max-h-[90vh] flex flex-col overflow-y-auto">
         <CardContent className="p-0 flex flex-col h-full">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -1764,7 +1764,7 @@ function CreatePresetModal({
 
           {/* Basic Info */}
           <div className="p-4 border-b border-bambu-dark-tertiary space-y-3">
-            <div className="grid grid-cols-3 gap-4">
+            <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">{t('common.type')}</label>
                 <select
@@ -1815,7 +1815,7 @@ function CreatePresetModal({
           </div>
 
           {/* Tabs */}
-          <div className="flex border-b border-bambu-dark-tertiary">
+          <div className="flex border-b border-bambu-dark-tertiary max-[640px]:flex-wrap max-[640px]:items-center">
             <button
               onClick={() => setActiveTab('common')}
               className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -1844,7 +1844,7 @@ function CreatePresetModal({
               JSON
               {jsonError && <AlertCircle className="w-3 h-3 text-red-400" />}
             </button>
-            <div className="flex-1" />
+            <div className="flex-1 max-[640px]:hidden" />
             <button
               onClick={() => {
                 const exportData = {
@@ -1904,7 +1904,7 @@ function CreatePresetModal({
           </div>
 
           {/* Tab Content */}
-          <div className="flex-1 overflow-y-auto p-4">
+          <div className="flex-1 p-4">
             {activeTab === 'common' && (
               <div className="space-y-6">
                 {/* Templates */}
@@ -2010,9 +2010,9 @@ function CreatePresetModal({
                   <h3 className="text-sm font-medium text-white mb-3">{t('profiles.presets.commonSettings')}</h3>
                   <div className="grid grid-cols-2 gap-x-6 gap-y-3">
                     {dynamicFields.slice(0, 10).map(field => (
-                      <div key={field.key} className="flex items-center justify-between gap-4">
+                      <div key={field.key} className="flex items-center justify-between gap-4 max-[640px]:flex-col max-[640px]:items-start">
                         <label className="text-sm text-bambu-gray flex-shrink-0">{field.label}</label>
-                        <div className="w-48">{renderFieldInput(field)}</div>
+                        <div className="w-48 max-[640px]:w-full">{renderFieldInput(field)}</div>
                       </div>
                     ))}
                   </div>
@@ -2460,7 +2460,7 @@ function CloudProfilesView({
             />
           </div>
 
-          <div className="flex gap-2">
+          <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
             <Button
               variant={compareMode ? 'primary' : 'secondary'}
               onClick={() => {

+ 190 - 2
frontend/src/pages/SettingsPage.tsx

@@ -6,7 +6,7 @@ import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
-import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
+import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -37,6 +37,38 @@ const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', '
 type TabType = typeof validTabs[number];
 type UsersSubTab = 'users' | 'email';
 
+const STORAGE_CATEGORY_COLORS: Record<string, string> = {
+  database: 'bg-blue-600',
+  library_files: 'bg-green-500',
+  library_thumbnails: 'bg-teal-500',
+  library_other: 'bg-emerald-700',
+  archive_timelapses: 'bg-red-500',
+  archive_thumbnails: 'bg-amber-500',
+  archive_files: 'bg-sky-500',
+  virtual_printer_uploads: 'bg-purple-500',
+  virtual_printer_upload_cache: 'bg-fuchsia-500',
+  virtual_printer_certs: 'bg-violet-500',
+  virtual_printer_other: 'bg-purple-700',
+  downloads: 'bg-cyan-500',
+  plate_calibration: 'bg-lime-500',
+  logs: 'bg-orange-500',
+  other_data: 'bg-yellow-500',
+};
+
+const STORAGE_FALLBACK_COLORS = [
+  'bg-blue-500',
+  'bg-green-500',
+  'bg-yellow-500',
+  'bg-red-500',
+  'bg-orange-500',
+  'bg-teal-500',
+  'bg-cyan-500',
+  'bg-purple-500',
+];
+
+const getStorageColor = (key: string, index: number) =>
+  STORAGE_CATEGORY_COLORS[key] || STORAGE_FALLBACK_COLORS[index % STORAGE_FALLBACK_COLORS.length];
+
 export function SettingsPage() {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -100,6 +132,7 @@ export function SettingsPage() {
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
   const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
   const [changePasswordLoading, setChangePasswordLoading] = useState(false);
+  const [storageUsageRefreshing, setStorageUsageRefreshing] = useState(false);
 
   // User management state
   const [showCreateUserModal, setShowCreateUserModal] = useState(false);
@@ -163,6 +196,33 @@ export function SettingsPage() {
     queryFn: api.getSettings,
   });
 
+  const {
+    data: storageUsage,
+    isLoading: storageUsageLoading,
+    isFetching: storageUsageFetching,
+  } = useQuery<StorageUsageResponse>({
+    queryKey: ['storage-usage'],
+    queryFn: () => api.getStorageUsage(),
+    enabled: activeTab === 'general',
+    staleTime: Infinity,
+    refetchInterval: false,
+    refetchOnWindowFocus: false,
+    refetchOnReconnect: false,
+  });
+
+  const handleStorageUsageRefresh = async () => {
+    setStorageUsageRefreshing(true);
+    try {
+      const data = await api.getStorageUsage({ refresh: true });
+      queryClient.setQueryData(['storage-usage'], data);
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'Failed to refresh storage usage';
+      showToast(message, 'error');
+    } finally {
+      setStorageUsageRefreshing(false);
+    }
+  };
+
   const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
     queryKey: ['smart-plugs'],
     queryFn: api.getSmartPlugs,
@@ -285,6 +345,7 @@ export function SettingsPage() {
   const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
+    enabled: settings?.check_updates !== false,
     staleTime: 5 * 60 * 1000,
   });
 
@@ -772,6 +833,7 @@ export function SettingsPage() {
       settings.check_updates !== localSettings.check_updates ||
       (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       settings.notification_language !== localSettings.notification_language ||
+      (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
       settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
       settings.ams_temp_good !== localSettings.ams_temp_good ||
@@ -836,6 +898,7 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         notification_language: localSettings.notification_language,
+        bed_cooled_threshold: localSettings.bed_cooled_threshold,
         ams_humidity_good: localSettings.ams_humidity_good,
         ams_humidity_fair: localSettings.ams_humidity_fair,
         ams_temp_good: localSettings.ams_temp_good,
@@ -1875,6 +1938,107 @@ export function SettingsPage() {
                   Reset
                 </Button>
               </div>
+              <div className="pt-4 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
+                    </p>
+                  </div>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={handleStorageUsageRefresh}
+                    disabled={storageUsageFetching || storageUsageRefreshing}
+                  >
+                    <RefreshCw
+                      className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
+                    />
+                    {t('common.refresh', 'Refresh')}
+                  </Button>
+                </div>
+                <div className="mt-3">
+                  {storageUsageLoading ? (
+                    <div className="flex items-center gap-2 text-sm text-bambu-gray">
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {t('common.loading', 'Loading')}
+                    </div>
+                  ) : storageUsage ? (
+                    <>
+                      <div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
+                        {storageUsage.categories
+                          .filter((category) => category.bytes > 0)
+                          .map((category, index) => (
+                            <div
+                              key={category.key}
+                              className={`${getStorageColor(category.key, index)} h-full`}
+                              style={{ width: `${category.percent_of_total}%` }}
+                              title={`${category.label}: ${category.formatted}`}
+                            />
+                          ))}
+                      </div>
+                      <div className="mt-3 flex flex-wrap gap-3">
+                        {storageUsage.categories
+                          .filter((category) => category.bytes > 0)
+                          .map((category, index) => (
+                            <div key={category.key} className="flex items-center gap-2 text-xs">
+                              <span
+                                className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
+                              />
+                              <span className="text-bambu-gray">{category.label}</span>
+                              <span className="text-white">{category.formatted}</span>
+                              <span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
+                            </div>
+                          ))}
+                      </div>
+                      <div className="mt-2 text-xs text-bambu-gray">
+                        {t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
+                        {storageUsage.scan_errors > 0 && (
+                          <span className="ml-2 text-amber-400">
+                            {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
+                          </span>
+                        )}
+                      </div>
+                      {storageUsage.other_breakdown?.length > 0 && (
+                        <div className="mt-4">
+                          <p className="text-xs text-bambu-gray mb-2">
+                            {t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
+                          </p>
+                          <div className="space-y-2">
+                            {storageUsage.other_breakdown.map((item) => (
+                              <div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
+                                <div className="flex items-center gap-2">
+                                  <span className="text-white">{item.label}</span>
+                                  <span
+                                    className={`px-2 py-0.5 rounded-full border ${
+                                      item.kind === 'system'
+                                        ? 'border-slate-500 text-slate-300'
+                                        : 'border-bambu-green text-bambu-green'
+                                    }`}
+                                  >
+                                    {item.kind === 'system'
+                                      ? t('settings.storageUsageSystem', 'System')
+                                      : t('settings.storageUsageData', 'Data')}
+                                  </span>
+                                </div>
+                                <div className="flex items-center gap-2 text-bambu-gray">
+                                  <span className="text-white">{item.formatted}</span>
+                                  <span>({item.percent_of_total.toFixed(1)}%)</span>
+                                </div>
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+                    </>
+                  ) : (
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
+                    </p>
+                  )}
+                </div>
+              </div>
               <div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
                 <div>
                   <p className="text-white">Backup & Restore</p>
@@ -2695,6 +2859,30 @@ export function SettingsPage() {
               </CardContent>
             </Card>
 
+            {/* Bed Cooled Threshold Setting */}
+            <Card className="mb-4">
+              <CardContent className="py-3">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white text-sm font-medium">{t('settings.bedCooledThreshold')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.bedCooledThresholdDescription')}</p>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <input
+                      type="number"
+                      min={20}
+                      max={80}
+                      step={1}
+                      value={localSettings.bed_cooled_threshold ?? 35}
+                      onChange={(e) => updateSetting('bed_cooled_threshold', Number(e.target.value))}
+                      className="w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-bambu-gray">°C</span>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
             {/* Test All Results */}
             {testAllResult && (
               <Card className="mb-4">
@@ -2789,7 +2977,7 @@ export function SettingsPage() {
               </div>
             ) : notificationTemplates && notificationTemplates.length > 0 ? (
               <div className="space-y-2">
-                {notificationTemplates.map((template) => (
+                {[...notificationTemplates].sort((a, b) => a.name.localeCompare(b.name)).map((template) => (
                   <Card
                     key={template.id}
                     className="cursor-pointer hover:border-bambu-green/50 transition-colors"

+ 1 - 0
frontend/src/pages/StatsPage.tsx

@@ -760,6 +760,7 @@ export function StatsPage() {
         key={dashboardKey}
         widgets={widgets}
         storageKey="bambusy-dashboard-layout"
+        stackBelow={640}
         hideControls
       />
     </div>

+ 1 - 1
frontend/src/utils/amsHelpers.ts

@@ -88,7 +88,7 @@ export function getGlobalTrayId(
   trayId: number,
   isExternal: boolean
 ): number {
-  if (isExternal) return 254;
+  if (isExternal) return 254 + trayId;
   // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
   if (amsId >= 128) return amsId;
   return amsId * 4 + trayId;

+ 10 - 1
install/start_bambuddy.bat

@@ -20,8 +20,17 @@ REM    start_bambuddy.bat reset      Clean all & fresh start
 REM    set PORT=9000 & start_bambuddy.bat   Change port
 REM ============================================
 
-set "ROOT=%~dp0"
+REM Resolve ROOT based on the script location (more reliable than %CD%).
+set "SCRIPT_DIR=%~dp0"
+if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
+for %%I in ("%SCRIPT_DIR%") do set "SCRIPT_DIR_NAME=%%~nxI"
+if /I "%SCRIPT_DIR_NAME%"=="install" (
+    set "ROOT=%SCRIPT_DIR%\.."
+) else (
+    set "ROOT=%SCRIPT_DIR%"
+)
 if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
+cd /d "%ROOT%"
 
 set "PORTABLE=%ROOT%\.portable"
 set "PYTHON_DIR=%PORTABLE%\python"

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BOd5pCVD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BpibLMBb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DZOdnZuT.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-OqmBOPoC.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BOd5pCVD.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DZOdnZuT.css">
+    <script type="module" crossorigin src="/assets/index-BpibLMBb.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
   </head>
   <body>
     <div id="root"></div>

+ 1 - 0
update_website_wiki.sh

@@ -21,6 +21,7 @@ git commit -m "Updated website"
 git push
 
 cd ../spoolbuddy-wiki
+git pull
 git add .
 git commit -m "Updated Wiki"
 git push

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