Browse Source

Merge branch '0.2.1b' into feature/addIpTablesOnDockerInstal

MartinNYHC 3 months ago
parent
commit
9d9a2eb904
68 changed files with 5203 additions and 2525 deletions
  1. 1 0
      .github/FUNDING.yml
  2. 1 0
      .github/workflows/stale.yml
  3. 42 1
      CHANGELOG.md
  4. 1 0
      Dockerfile
  5. 2 2
      README.md
  6. 48 14
      backend/app/api/routes/archives.py
  7. 1 1
      backend/app/api/routes/library.py
  8. 11 0
      backend/app/api/routes/print_queue.py
  9. 27 2
      backend/app/api/routes/printers.py
  10. 1 1
      backend/app/core/config.py
  11. 188 120
      backend/app/main.py
  12. 1 0
      backend/app/schemas/print_queue.py
  13. 2 0
      backend/app/schemas/printer.py
  14. 112 1
      backend/app/services/archive.py
  15. 145 4
      backend/app/services/bambu_mqtt.py
  16. 31 14
      backend/app/services/notification_service.py
  17. 33 14
      backend/app/services/print_scheduler.py
  18. 199 21
      backend/app/services/usage_tracker.py
  19. 190 0
      backend/app/services/virtual_printer/bind_server.py
  20. 17 1
      backend/app/services/virtual_printer/manager.py
  21. 219 0
      backend/app/services/virtual_printer/tcp_proxy.py
  22. 24 0
      backend/app/utils/color_utils.py
  23. 91 39
      backend/app/utils/threemf_tools.py
  24. 62 0
      backend/tests/integration/test_printers_api.py
  25. 919 0
      backend/tests/unit/services/test_bambu_mqtt.py
  26. 249 0
      backend/tests/unit/services/test_virtual_printer.py
  27. 360 21
      backend/tests/unit/test_archive_filtering.py
  28. 54 0
      backend/tests/unit/test_color_utils.py
  29. 363 11
      backend/tests/unit/test_scheduler_ams_mapping.py
  30. 161 0
      backend/tests/unit/test_threemf_tools.py
  31. 367 2
      backend/tests/unit/test_usage_tracker.py
  32. 1 0
      docker-compose.yml
  33. 1 0
      frontend/.npmrc
  34. 146 2067
      frontend/package-lock.json
  35. 0 2
      frontend/package.json
  36. 146 0
      frontend/src/__tests__/components/HMSErrorModal.test.tsx
  37. 22 0
      frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx
  38. 306 2
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  39. 65 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  40. 11 0
      frontend/src/api/client.ts
  41. 1 1
      frontend/src/components/CalendarView.tsx
  42. 34 6
      frontend/src/components/HMSErrorModal.tsx
  43. 3 1
      frontend/src/components/PrintModal/FilamentMapping.tsx
  44. 2 2
      frontend/src/components/PrintModal/PlateSelector.tsx
  45. 5 5
      frontend/src/components/PrintModal/index.tsx
  46. 5 4
      frontend/src/components/PrinterQueueWidget.tsx
  47. 99 3
      frontend/src/components/SkipObjectsModal.tsx
  48. 1 1
      frontend/src/components/SpoolFormModal.tsx
  49. 4 4
      frontend/src/components/spool-form/ColorSection.tsx
  50. 8 11
      frontend/src/hooks/useFilamentMapping.ts
  51. 14 1
      frontend/src/i18n/locales/de.ts
  52. 14 1
      frontend/src/i18n/locales/en.ts
  53. 6 1
      frontend/src/i18n/locales/fr.ts
  54. 6 1
      frontend/src/i18n/locales/it.ts
  55. 14 1
      frontend/src/i18n/locales/ja.ts
  56. 24 0
      frontend/src/index.css
  57. 183 37
      frontend/src/pages/ArchivesPage.tsx
  58. 1 9
      frontend/src/pages/FileManagerPage.tsx
  59. 9 43
      frontend/src/pages/PrintersPage.tsx
  60. 88 12
      frontend/src/pages/QueuePage.tsx
  61. 11 27
      frontend/src/pages/StreamOverlayPage.tsx
  62. 0 11
      frontend/src/utils/amsHelpers.ts
  63. 48 0
      frontend/src/utils/date.ts
  64. 0 0
      static/assets/index-VlqasY_r.css
  65. 0 0
      static/assets/index-tulFiIvt.css
  66. 0 0
      static/assets/index-xpjLLAhl.js
  67. 2 2
      static/index.html
  68. 1 1
      test_all.sh

+ 1 - 0
.github/FUNDING.yml

@@ -0,0 +1 @@
+github: maziggy

+ 1 - 0
.github/workflows/stale.yml

@@ -18,3 +18,4 @@ jobs:
           days-before-stale: 21
           days-before-stale: 21
           days-before-close: 7
           days-before-close: 7
           stale-issue-label: 'stale'
           stale-issue-label: 'stale'
+          only-labels: 'feedback'

+ 42 - 1
CHANGELOG.md

@@ -2,7 +2,30 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
-## [0.2.0b] - Not released
+## [0.2.1b] - Not released
+
+### Fixed
+- **Nozzle Mapping Uses Wrong Source in 3MF Files** — The `extract_nozzle_mapping_from_3mf()` function used `filament_nozzle_map` (user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in the `group_id` attribute on `<filament>` elements in `slice_info.config`. Now uses `group_id` as the primary source and falls back to `filament_nozzle_map` only when `group_id` is not present.
+- **Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle** — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.
+- **Print Scheduler External Spool Ignores Nozzle Assignment** — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.
+- **ams_extruder_map Race Condition on Printer Status API** — The `/printers/{id}/status` endpoint read `ams_extruder_map` from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.
+- **Filament Mapping Frontend Ignores Nozzle for External Spools** — The `useFilamentMapping` hook always set `extruder_id: 0` for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.
+- **AMS-HT Global Tray ID Computed Wrong on Printer Card** — The PrintersPage computed AMS-HT tray IDs using `ams_id * 4 + slot` (giving 512+), but AMS-HT units use their raw `ams_id` (128-135) as the global tray ID. Now uses `ams_id` directly for AMS-HT units.
+- **Filament Mapping Dropdown Shows Wrong Nozzle Trays** — The FilamentMapping dropdown filtered by `extruder_id` using strict equality, but `extruder_id` could be `undefined` for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when `extruder_id` is undefined.
+- **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.
+- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
+- **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
+- **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
+
+- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
+- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
+
+### Improved
+- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
+- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
+
+
+## [0.2.0] - 2026-02-17
 
 
 ### New Features
 ### 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.
 - **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.
@@ -17,6 +40,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **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.
 - **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.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
+- **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
+- **Clear HMS Errors** — New "Clear Errors" button in the HMS error modal sends a `clean_print_error` MQTT command to dismiss stale `print_error` values that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated to `printers:control`. The button only appears when there are active errors.
 
 
 ### Fixed
 ### 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.
 - **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.
@@ -38,9 +63,24 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
 - **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
 - **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
 - **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
 - **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
 - **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`).
+- **Phantom Prints on Power Cycle** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The print queue uploaded `.3mf` files to the printer's SD card root (`/`) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking). The cleanup also tries `.gcode` files and retries up to 3 times with a 2-second delay to handle printers that briefly lock the filesystem after a print ends. Runs before the archive lookup so it works even when auto-archiving is disabled.
+- **Queue Items Stuck in "Printing" After Print Completes** — The queue item status update (from `printing` to `completed`/`failed`) was placed after an early return that exits when the archive record cannot be found. If the archive lookup failed (e.g. app restart mid-print, manual archive deletion), the function returned early and the queue item stayed in `printing` forever. Over multiple print cycles, stale items accumulated — causing the "Printing" count to show double the actual printers and completed prints to remain in the "Currently Printing" section. Moved the queue item status update (including MQTT relay notification, queue-completed notification, and auto-power-off) to before the archive lookup early return so it always runs.
+- **Spool Form Scrollbar Flicker in Edge** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The Add/Edit Spool modal's scrollable area used `overflow-y: auto`, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Added `scrollbar-gutter: stable` to reserve scrollbar space and prevent layout thrashing.
 - **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.
 - **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.
+- **Schedule Print Allows No Plate Selected for Multi-Plate Files** ([#394](https://github.com/maziggy/bambuddy/issues/394)) — When scheduling a multi-plate file from the file manager, the modal showed a "Selection required" warning but still allowed submission without selecting a plate. The job defaulted to plate 1, but the queue item didn't indicate which plate, and editing showed no plate selected. Now auto-selects the first plate by default when plates load, and the submit button validation applies to both archive and library files.
+- **3MF Usage Tracking Broken for Queue Prints from File Manager** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was queued from the file manager (library file), the scheduler did not create an archive or register the expected print. The `on_print_start` callback had to re-download the 3MF from the printer via FTP, and if that failed, a fallback archive was created without the 3MF file — making 3MF-based filament usage tracking impossible. The queue item's `archive_id` also remained NULL, so the usage tracker could not find the queue's AMS slot mapping for correct spool resolution. The scheduler now creates an archive from the library file before uploading, links it to the queue item, and registers it as an expected print — matching the behavior of the direct library print route.
+- **Printer Queue Widget Shows "Archive #null" for File Manager Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The "Next in queue" widget on the printer card only checked `archive_name` and `archive_id` when displaying the queued item name. Queue items from the file manager have `library_file_name` and `library_file_id` instead, so the widget displayed "Archive #null". Now falls back to `library_file_name` and `library_file_id`, matching the Queue page display logic.
+- **Inventory Usage Not Tracked for Remapped AMS Slots** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When reprinting an archive with a different AMS slot mapping (e.g. changing from slot A1 to C4 in the mapping modal), the usage tracker used the default 3MF slot-to-tray mapping instead of the actual mapping from the print command. The `ams_mapping` from reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking.
+- **Inventory Usage Not Tracked for Slicer-Initiated Prints on H2D** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2D printers, the AMS `tray_now` field is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to "unloaded" when the AMS retracts filament at completion. The usage tracker now tracks `last_loaded_tray` — the last valid tray seen during printing — as a fallback when both `tray_now` at start and at completion are invalid. Also captures `tray_now` at print start for printers that report a valid value before the RUNNING state.
+- **Inventory Usage Wrong Tray for Slicer-Initiated Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was started from an external slicer (BambuStudio, OrcaSlicer, Bambu Handy), Bambuddy never saw the `ams_mapping` the slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back to `tray_now` which could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing the `ams_mapping` universally — regardless of who starts the print. The request topic subscription is fail-safe: if the printer's MQTT broker rejects it (e.g., P1S), Bambuddy detects the rejection via SUBACK or disconnect timing and gracefully disables the subscription for that printer, falling back to the existing `tray_now`-based tracking without breaking the MQTT connection.
+- **P1S Timelapse Not Detected — AVI Format Support** ([#405](https://github.com/maziggy/bambuddy/issues/405)) — P1-series printers save timelapse videos as `.avi` (MJPEG), but the timelapse scanner only looked for `.mp4` files — so P1S timelapses were never found or attached to archives. Now discovers both `.mp4` and `.avi` timelapse files across all FTP directories (`/timelapse`, `/timelapse/video`, `/record`, `/recording`). AVI files are saved immediately and converted to MP4 in a non-blocking background task using FFmpeg with `-threads 1` and `nice -n 19` to minimize CPU impact on Raspberry Pi. If FFmpeg is unavailable, the AVI is served as-is with the correct MIME type. The manual "Scan for Timelapse" route also searches the additional directories used by P1-series printers.
+- **Timelapse Upload & Remove** ([#406](https://github.com/maziggy/bambuddy/issues/406)) — When the auto-scan attaches the wrong timelapse (e.g., from a different print), there was no way to remove it or attach the correct one. Added "Upload Timelapse" and "Remove Timelapse" context menu items. Upload accepts `.mp4`, `.avi`, and `.mkv` files (non-MP4 auto-converted in background). Remove deletes the file and clears the database reference. Both actions are permission-gated and available in grid and list views.
+- **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.
 
 
 ### Improved
 ### Improved
+- **Virtual Printer: Port 3000 Bind/Detect Server** — Recent BambuStudio/OrcaSlicer updates require a bind/detect handshake on port 3000 before connecting via MQTT/FTP. Added a BindServer that responds to the slicer's detect protocol in all server modes (immediate, review, print_queue). Without this, slicers cannot discover or connect to the virtual printer. Docker users in bridge mode need to expose port 3000 (`-p 3000:3000`). Proxy mode already forwards port 3000 via TCPProxy. Wiki documentation updated with revised port tables, Docker examples, and platform setup instructions.
+- **Usage Tracking Diagnostic Logging** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Added INFO-level logging at print start and completion that dumps the printer's MQTT `mapping` field, `tray_now`, `last_loaded_tray`, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode.
+- **Skip Objects: Click-to-Enlarge Lightbox** ([#396](https://github.com/maziggy/bambuddy/issues/396)) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.
 - **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency.
 - **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.
 - **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.
 - **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.
@@ -54,6 +94,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 ### New Features
 - **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
 - **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
 - **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
 - **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
+- **Clear Plate State Persists Across Page Refresh** ([#410](https://github.com/maziggy/bambuddy/issues/410)) — After clicking "Clear Plate & Start Next", refreshing the page showed the Clear Plate button again because the frontend determined the state purely from the printer's FINISH/FAILED status. The `plate_cleared` flag is now included in the printer status API response, so the widget correctly shows the passive queue link instead of the Clear Plate button after acknowledgment — even after a page refresh.
 
 
 ### Improved
 ### Improved
 - **Skip Objects: Confirmation Dialog** ([#346](https://github.com/maziggy/bambuddy/issues/346)) — Added a warning confirmation modal before skipping an object during a print. Shows the object name and warns the action is irreversible. Prevents accidentally skipping the wrong object. Translated in all 4 locales (en, de, ja, it).
 - **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).

+ 1 - 0
Dockerfile

@@ -46,6 +46,7 @@ ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
 ENV LOG_DIR=/app/logs
 ENV PORT=8000
 ENV PORT=8000
 
 
+EXPOSE 3000
 EXPOSE 8000
 EXPOSE 8000
 EXPOSE 8883
 EXPOSE 8883
 EXPOSE 9990
 EXPOSE 9990

+ 2 - 2
README.md

@@ -71,7 +71,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - 3D model preview (Three.js)
 - 3D model preview (Three.js)
 - Duplicate detection & full-text search
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Photo attachments & failure analysis
-- Timelapse editor (trim, speed, music)
+- Timelapse editor (trim, speed, music) with automatic AVI-to-MP4 conversion for P1-series printers, manual upload & remove
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
@@ -91,7 +91,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS slot RFID re-read
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - 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)
 - Dual external spool support for H2D (Ext-L / Ext-R)
-- HMS error monitoring with history
+- HMS error monitoring with history and clear errors
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
 - Cost analytics & failure analysis
 - Cost analytics & failure analysis

+ 48 - 14
backend/app/api/routes/archives.py

@@ -1143,10 +1143,15 @@ async def get_timelapse(
     # Use file modification time as ETag to bust cache after processing
     # Use file modification time as ETag to bust cache after processing
     mtime = int(timelapse_path.stat().st_mtime)
     mtime = int(timelapse_path.stat().st_mtime)
 
 
+    # Detect media type from file extension (AVI from P1S before background conversion)
+    suffix = timelapse_path.suffix.lower()
+    media_type = {".mp4": "video/mp4", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska"}.get(suffix, "video/mp4")
+    ext = suffix if suffix in (".mp4", ".avi", ".mkv") else ".mp4"
+
     return FileResponse(
     return FileResponse(
         path=timelapse_path,
         path=timelapse_path,
-        media_type="video/mp4",
-        filename=f"{archive.print_name or 'timelapse'}.mp4",
+        media_type=media_type,
+        filename=f"{archive.print_name or 'timelapse'}{ext}",
         headers={
         headers={
             "Cache-Control": "no-cache, must-revalidate",
             "Cache-Control": "no-cache, must-revalidate",
             "ETag": f'"{mtime}"',
             "ETag": f'"{mtime}"',
@@ -1154,6 +1159,33 @@ async def get_timelapse(
     )
     )
 
 
 
 
+@router.delete("/{archive_id}/timelapse")
+async def delete_timelapse(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+):
+    """Remove the timelapse video from an archive."""
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.timelapse_path:
+        raise HTTPException(404, "No timelapse attached to this archive")
+
+    # Delete the file
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if timelapse_path.exists():
+        timelapse_path.unlink()
+
+    # Clear the path in database
+    archive.timelapse_path = None
+    await db.commit()
+
+    return {"status": "deleted"}
+
+
 @router.post("/{archive_id}/timelapse/scan")
 @router.post("/{archive_id}/timelapse/scan")
 async def scan_timelapse(
 async def scan_timelapse(
     archive_id: int,
     archive_id: int,
@@ -1190,9 +1222,9 @@ async def scan_timelapse(
     base_name = Path(archive.filename).stem
     base_name = Path(archive.filename).stem
 
 
     # Scan timelapse directory on printer
     # Scan timelapse directory on printer
-    # Try both /timelapse and /timelapse/video (different printer models use different paths)
+    # Different printer models use different paths
     files = []
     files = []
-    for timelapse_path in ["/timelapse", "/timelapse/video"]:
+    for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
         try:
             files = await list_files_async(
             files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
@@ -1206,10 +1238,12 @@ async def scan_timelapse(
 
 
     # Look for matching timelapse
     # Look for matching timelapse
     matching_file = None
     matching_file = None
-    mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
+    video_files = [
+        f for f in files if not f.get("is_directory") and f.get("name", "").lower().endswith((".mp4", ".avi"))
+    ]
 
 
     # Strategy 1: Match by print name in filename
     # Strategy 1: Match by print name in filename
-    for f in mp4_files:
+    for f in video_files:
         fname = f.get("name", "")
         fname = f.get("name", "")
         if base_name.lower() in fname.lower():
         if base_name.lower() in fname.lower():
             matching_file = f
             matching_file = f
@@ -1228,7 +1262,7 @@ async def scan_timelapse(
         best_match = None
         best_match = None
         best_diff = timedelta(hours=24)  # Max 24 hour difference
         best_diff = timedelta(hours=24)  # Max 24 hour difference
 
 
-        for f in mp4_files:
+        for f in video_files:
             fname = f.get("name", "")
             fname = f.get("name", "")
             # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
             # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
             match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname)
             match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname)
@@ -1285,7 +1319,7 @@ async def scan_timelapse(
         best_match = None
         best_match = None
         best_diff = timedelta(hours=24)
         best_diff = timedelta(hours=24)
 
 
-        for f in mp4_files:
+        for f in video_files:
             mtime = f.get("mtime")
             mtime = f.get("mtime")
             if mtime:
             if mtime:
                 # Timelapse file should be modified during or shortly after the print
                 # Timelapse file should be modified during or shortly after the print
@@ -1305,7 +1339,7 @@ async def scan_timelapse(
 
 
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
     # This handles cases where printer clock is wrong or timezone issues exist
-    if not matching_file and len(mp4_files) == 1:
+    if not matching_file and len(video_files) == 1:
         from datetime import datetime, timedelta
         from datetime import datetime, timedelta
 
 
         archive_completed = archive.completed_at or archive.created_at
         archive_completed = archive.completed_at or archive.created_at
@@ -1313,8 +1347,8 @@ async def scan_timelapse(
             time_since_completion = datetime.now() - archive_completed
             time_since_completion = datetime.now() - archive_completed
             # If archive was completed within the last hour, assume the single timelapse is for it
             # If archive was completed within the last hour, assume the single timelapse is for it
             if time_since_completion < timedelta(hours=1):
             if time_since_completion < timedelta(hours=1):
-                matching_file = mp4_files[0]
-                logger.info("Using single timelapse file as fallback: %s", mp4_files[0].get("name"))
+                matching_file = video_files[0]
+                logger.info("Using single timelapse file as fallback: %s", video_files[0].get("name"))
 
 
     # Note: We intentionally don't use a "most recent file" fallback because
     # Note: We intentionally don't use a "most recent file" fallback because
     # we can't verify if timelapse was actually enabled for this print.
     # we can't verify if timelapse was actually enabled for this print.
@@ -1329,7 +1363,7 @@ async def scan_timelapse(
                 "size": f.get("size"),
                 "size": f.get("size"),
                 "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
                 "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
             }
             }
-            for f in mp4_files
+            for f in video_files
         ]
         ]
         # Sort by mtime descending (most recent first)
         # Sort by mtime descending (most recent first)
         available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
         available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
@@ -1414,7 +1448,7 @@ async def select_timelapse(
     # Find the file on the printer
     # Find the file on the printer
     files = []
     files = []
     remote_path = None
     remote_path = None
-    for timelapse_dir in ["/timelapse", "/timelapse/video"]:
+    for timelapse_dir in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
         try:
             files = await list_files_async(
             files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_dir, printer_model=printer.model
                 printer.ip_address, printer.access_code, timelapse_dir, printer_model=printer.model
@@ -2819,7 +2853,7 @@ async def reprint_archive(
         )
         )
 
 
     # Register this as an expected print so we don't create a duplicate archive
     # Register this as an expected print so we don't create a duplicate archive
-    register_expected_print(printer_id, remote_filename, archive_id)
+    register_expected_print(printer_id, remote_filename, archive_id, ams_mapping=body.ams_mapping)
 
 
     # Use plate_id from request if provided, otherwise auto-detect from 3MF file
     # Use plate_id from request if provided, otherwise auto-detect from 3MF file
     if body.plate_id is not None:
     if body.plate_id is not None:

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

@@ -1868,7 +1868,7 @@ async def print_library_file(
         )
         )
 
 
     # Register this as an expected print so we don't create a duplicate archive
     # Register this as an expected print so we don't create a duplicate archive
-    register_expected_print(printer_id, remote_filename, archive.id)
+    register_expected_print(printer_id, remote_filename, archive.id, ams_mapping=body.ams_mapping)
 
 
     # Determine plate ID
     # Determine plate ID
     if body.plate_id is not None:
     if body.plate_id is not None:

+ 11 - 0
backend/app/api/routes/print_queue.py

@@ -31,6 +31,7 @@ from backend.app.schemas.print_queue import (
 )
 )
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -205,12 +206,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         response.print_time_seconds = item.archive.print_time_seconds
+        response.filament_used_grams = item.archive.filament_used_grams
         if item.plate_id:
         if item.plate_id:
             archive_path = settings.base_dir / item.archive.file_path
             archive_path = settings.base_dir / item.archive.file_path
             if archive_path.exists():
             if archive_path.exists():
                 plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
                 plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
+                if plate_weight > 0:
+                    response.filament_used_grams = plate_weight
     if item.library_file:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -221,13 +226,19 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # Get print time from library file metadata if no archive
         # Get print time from library file metadata if no archive
         if not item.archive and item.library_file.file_metadata:
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
+            response.filament_used_grams = item.library_file.file_metadata.get("filament_used_grams")
         if item.plate_id:
         if item.plate_id:
             lib_path = Path(item.library_file.file_path)
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
             if library_file_path.exists():
                 plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
                 plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
+                plate_weight = sum(
+                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
+                )
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
+                if plate_weight > 0:
+                    response.filament_used_grams = plate_weight
     if item.printer:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response

+ 27 - 2
backend/app/api/routes/printers.py

@@ -402,8 +402,9 @@ async def get_printer_status(
 
 
     # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
     # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
     ams_mapping = raw_data.get("ams_mapping", [])
     ams_mapping = raw_data.get("ams_mapping", [])
-    # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
-    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+    # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
+    # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
+    ams_extruder_map = state.ams_extruder_map or {}
     logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
     logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
 
 
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
@@ -466,6 +467,7 @@ async def get_printer_status(
         big_fan2_speed=state.big_fan2_speed,
         big_fan2_speed=state.big_fan2_speed,
         heatbreak_fan_speed=state.heatbreak_fan_speed,
         heatbreak_fan_speed=state.heatbreak_fan_speed,
         firmware_version=state.firmware_version,
         firmware_version=state.firmware_version,
+        plate_cleared=printer_manager.is_plate_cleared(printer_id),
     )
     )
 
 
 
 
@@ -1958,6 +1960,29 @@ async def set_chamber_light(
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
 
 
 
 
+@router.post("/{printer_id}/hms/clear")
+async def clear_hms_errors(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Clear HMS/print errors on the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.clear_hms_errors()
+    if not success:
+        raise HTTPException(500, "Failed to clear HMS errors")
+
+    return {"success": True, "message": "HMS errors cleared"}
+
+
 @router.get("/{printer_id}/print/objects")
 @router.get("/{printer_id}/print/objects")
 async def get_printable_objects(
 async def get_printable_objects(
     printer_id: int,
     printer_id: int,

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

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.2.0b"
+APP_VERSION = "0.2.1b"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

+ 188 - 120
backend/app/main.py

@@ -249,6 +249,10 @@ _print_energy_start: dict[int, float] = {}
 # Track reprints to add costs on completion: {archive_id}
 # Track reprints to add costs on completion: {archive_id}
 _reprint_archives: set[int] = set()
 _reprint_archives: set[int] = set()
 
 
+# Track AMS mapping for prints: {archive_id: [global_tray_id_per_slot]}
+# Used by usage tracker to map 3MF slots to physical AMS trays
+_print_ams_mappings: dict[int, list[int]] = {}
+
 # Track progress milestones for notifications: {printer_id: last_milestone_notified}
 # Track progress milestones for notifications: {printer_id: last_milestone_notified}
 # Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
 # Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
 _last_progress_milestone: dict[int, int] = {}
 _last_progress_milestone: dict[int, int] = {}
@@ -257,7 +261,7 @@ _last_progress_milestone: dict[int, int] = {}
 # This prevents sending duplicate notifications for the same error
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
 _notified_hms_errors: dict[int, set[str]] = {}
 
 
-# Track timelapse file baselines at print start: {printer_id: set of MP4 filenames}
+# Track timelapse file baselines at print start: {printer_id: set of video filenames}
 # Used for snapshot-diff detection at print completion
 # Used for snapshot-diff detection at print completion
 _timelapse_baselines: dict[int, set[str]] = {}
 _timelapse_baselines: dict[int, set[str]] = {}
 
 
@@ -292,7 +296,7 @@ async def _get_plug_energy(plug, db) -> dict | None:
         return await tasmota_service.get_energy(plug)
         return await tasmota_service.get_energy(plug)
 
 
 
 
-def register_expected_print(printer_id: int, filename: str, archive_id: int):
+def register_expected_print(printer_id: int, filename: str, archive_id: int, ams_mapping: list[int] | None = None):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     # Store with multiple filename variations to catch different naming patterns
     # Store with multiple filename variations to catch different naming patterns
     _expected_prints[(printer_id, filename)] = archive_id
     _expected_prints[(printer_id, filename)] = archive_id
@@ -301,8 +305,11 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
         base = filename[:-4]
         base = filename[:-4]
         _expected_prints[(printer_id, base)] = archive_id
         _expected_prints[(printer_id, base)] = archive_id
         _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
         _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
+    # Store AMS mapping for usage tracking at print completion
+    if ams_mapping is not None:
+        _print_ams_mappings[archive_id] = ams_mapping
     logging.getLogger(__name__).info(
     logging.getLogger(__name__).info(
-        f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}"
+        f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
     )
     )
 
 
 
 
@@ -537,6 +544,8 @@ async def on_ams_change(printer_id: int, ams_data: list):
     except Exception as e:
     except Exception as e:
         logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)
         logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)
 
 
+    from backend.app.utils.color_utils import colors_similar as _colors_similar
+
     # Auto-unlink spool assignments with stale fingerprints
     # Auto-unlink spool assignments with stale fingerprints
     try:
     try:
         async with async_session() as db:
         async with async_session() as db:
@@ -604,14 +613,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     cur_type = current_tray.get("tray_type", "")
                     cur_type = current_tray.get("tray_type", "")
                     fp_color = assignment.fingerprint_color or ""
                     fp_color = assignment.fingerprint_color or ""
                     fp_type = assignment.fingerprint_type or ""
                     fp_type = assignment.fingerprint_type or ""
-                    if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
+                    if not _colors_similar(cur_color, fp_color) or cur_type.upper() != fp_type.upper():
                         # Fingerprint mismatch — but check if tray now matches the
                         # Fingerprint mismatch — but check if tray now matches the
                         # assigned spool (e.g. auto-configure changed the tray).
                         # assigned spool (e.g. auto-configure changed the tray).
                         spool = assignment.spool
                         spool = assignment.spool
                         if spool:
                         if spool:
                             spool_color = (spool.rgba or "FFFFFFFF").upper()
                             spool_color = (spool.rgba or "FFFFFFFF").upper()
                             spool_type = (spool.material or "").upper()
                             spool_type = (spool.material or "").upper()
-                            if cur_color.upper() == spool_color and cur_type.upper() == spool_type:
+                            if _colors_similar(cur_color, spool_color) and cur_type.upper() == spool_type:
                                 # Tray was reconfigured to match the spool — update fingerprint
                                 # Tray was reconfigured to match the spool — update fingerprint
                                 logger.info(
                                 logger.info(
                                     "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
                                     "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
@@ -1703,10 +1712,10 @@ async def on_print_start(printer_id: int, data: dict):
 
 
                 # Capture timelapse file baseline for snapshot-diff on completion
                 # Capture timelapse file baseline for snapshot-diff on completion
                 try:
                 try:
-                    baseline_files, _ = await _list_timelapse_mp4s(printer)
+                    baseline_files, _ = await _list_timelapse_videos(printer)
                     _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
                     _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
                     logger.info(
                     logger.info(
-                        "[TIMELAPSE] Baseline at print start: %s MP4 files for printer %s",
+                        "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
                         len(_timelapse_baselines[printer_id]),
                         len(_timelapse_baselines[printer_id]),
                         printer_id,
                         printer_id,
                     )
                     )
@@ -1717,10 +1726,14 @@ async def on_print_start(printer_id: int, data: dict):
                 temp_path.unlink()
                 temp_path.unlink()
 
 
 
 
-async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
-    """List MP4 files from printer's timelapse directory.
+_TIMELAPSE_VIDEO_EXTENSIONS = (".mp4", ".avi")
+
 
 
-    Returns (mp4_files, found_path) where mp4_files is a list of file dicts
+async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
+    """List video files from printer's timelapse directory.
+
+    Finds MP4 (X1/A1 series) and AVI (P1 series) timelapse files.
+    Returns (video_files, found_path) where video_files is a list of file dicts
     and found_path is the directory where they were found, or ([], None).
     and found_path is the directory where they were found, or ([], None).
     """
     """
     from backend.app.services.bambu_ftp import list_files_async
     from backend.app.services.bambu_ftp import list_files_async
@@ -1733,9 +1746,13 @@ async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
             )
             )
             if found_files:
             if found_files:
-                mp4_files = [f for f in found_files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
-                if mp4_files:
-                    return mp4_files, timelapse_path
+                video_files = [
+                    f
+                    for f in found_files
+                    if not f.get("is_directory") and f.get("name", "").lower().endswith(_TIMELAPSE_VIDEO_EXTENSIONS)
+                ]
+                if video_files:
+                    return video_files, timelapse_path
         except Exception as e:
         except Exception as e:
             logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
             logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
             continue
             continue
@@ -1783,7 +1800,7 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
             if baseline_names is not None:
             if baseline_names is not None:
                 # Use pre-captured baseline from print start (no race condition)
                 # Use pre-captured baseline from print start (no race condition)
                 logger.info(
                 logger.info(
-                    "[TIMELAPSE] Using print-start baseline: %s existing MP4 files for archive %s",
+                    "[TIMELAPSE] Using print-start baseline: %s existing video files for archive %s",
                     len(baseline_names),
                     len(baseline_names),
                     archive_id,
                     archive_id,
                 )
                 )
@@ -1795,10 +1812,10 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
                     return
                     return
 
 
-                baseline_files, _ = await _list_timelapse_mp4s(printer)
+                baseline_files, _ = await _list_timelapse_videos(printer)
                 baseline_names = {f.get("name", "") for f in baseline_files}
                 baseline_names = {f.get("name", "") for f in baseline_files}
                 logger.info(
                 logger.info(
-                    "[TIMELAPSE] Baseline snapshot (fallback): %s existing MP4 files for archive %s",
+                    "[TIMELAPSE] Baseline snapshot (fallback): %s existing video files for archive %s",
                     len(baseline_names),
                     len(baseline_names),
                     archive_id,
                     archive_id,
                 )
                 )
@@ -1846,18 +1863,18 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
                     return
                     return
 
 
-                mp4_files, found_path = await _list_timelapse_mp4s(printer)
+                video_files, found_path = await _list_timelapse_videos(printer)
 
 
-                if not mp4_files:
-                    logger.info("[TIMELAPSE] Attempt %s: No MP4 files found, will retry", attempt)
+                if not video_files:
+                    logger.info("[TIMELAPSE] Attempt %s: No video files found, will retry", attempt)
                     continue
                     continue
 
 
-                logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path)
-                for f in mp4_files[:5]:
+                logger.info("[TIMELAPSE] Attempt %s: Found %s video files in %s", attempt, len(video_files), found_path)
+                for f in video_files[:5]:
                     logger.info("[TIMELAPSE]   - %s", f.get("name"))
                     logger.info("[TIMELAPSE]   - %s", f.get("name"))
 
 
                 # Find files that are NEW (not in baseline snapshot)
                 # Find files that are NEW (not in baseline snapshot)
-                new_files = [f for f in mp4_files if f.get("name", "") not in baseline_names]
+                new_files = [f for f in video_files if f.get("name", "") not in baseline_names]
 
 
                 if new_files:
                 if new_files:
                     # Pick the first new file (there should typically be exactly one)
                     # Pick the first new file (there should typically be exactly one)
@@ -1908,8 +1925,8 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                 if not printer:
                 if not printer:
                     return
                     return
 
 
-                mp4_files, found_path = await _list_timelapse_mp4s(printer)
-                for f in mp4_files:
+                video_files, found_path = await _list_timelapse_videos(printer)
+                for f in video_files:
                     fname = f.get("name", "")
                     fname = f.get("name", "")
                     if base_name.lower() in fname.lower():
                     if base_name.lower() in fname.lower():
                         remote_path = f.get("path") or f"/timelapse/{fname}"
                         remote_path = f.get("path") or f"/timelapse/{fname}"
@@ -2079,6 +2096,142 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive:
                 if archive:
                     archive_id = archive.id
                     archive_id = archive.id
 
 
+    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
+    # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S)
+    # auto-start files found in root on power cycle, causing ghost prints.
+    # Must run before the archive_id early-return so it executes even when archiving is disabled.
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info and subtask_name:
+            from backend.app.services.bambu_ftp import delete_file_async
+
+            # Try both .3mf and .gcode extensions — the printer may have either
+            for ext in (".3mf", ".gcode"):
+                remote_path = f"/{subtask_name}{ext}"
+                # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
+                for attempt in range(1, 4):
+                    try:
+                        delete_result = await delete_file_async(
+                            printer_info.ip_address,
+                            printer_info.access_code,
+                            remote_path,
+                            printer_model=printer_info.model,
+                        )
+                        if delete_result:
+                            logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
+                        break  # Success or file doesn't exist — no need to retry
+                    except Exception as e:
+                        if attempt < 3:
+                            logger.debug(
+                                "SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
+                                attempt,
+                                remote_path,
+                                e,
+                            )
+                            await asyncio.sleep(2)
+                        else:
+                            logger.debug(
+                                "SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
+                                remote_path,
+                                e,
+                            )
+    except Exception as e:
+        logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
+
+    log_timing("SD card cleanup")
+
+    # Update queue item status early — must run before the archive_id early-return
+    # so queue items don't get stuck in "printing" when archive lookup fails.
+    try:
+        async with async_session() as db:
+            from backend.app.models.print_queue import PrintQueueItem
+
+            result = await db.execute(
+                select(PrintQueueItem)
+                .where(PrintQueueItem.printer_id == printer_id)
+                .where(PrintQueueItem.status == "printing")
+            )
+            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:
+                queue_status = data.get("status", "completed")
+                queue_item.status = queue_status
+                queue_item.completed_at = datetime.now()
+                await db.commit()
+                logger.info("Updated queue item %s status to %s", queue_item.id, queue_status)
+
+                # MQTT relay - publish queue job completed
+                try:
+                    printer_info = printer_manager.get_printer(printer_id)
+                    await mqtt_relay.on_queue_job_completed(
+                        job_id=queue_item.id,
+                        filename=filename or subtask_name,
+                        printer_id=printer_id,
+                        printer_name=printer_info.name if printer_info else "Unknown",
+                        status=queue_status,
+                    )
+                except Exception:
+                    pass  # Don't fail if MQTT fails
+
+                # Check if queue is now empty and send notification
+                try:
+                    from sqlalchemy import func as sa_func
+
+                    count_result = await db.execute(
+                        select(sa_func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending")
+                    )
+                    pending_count = count_result.scalar() or 0
+
+                    if pending_count == 0:
+                        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+                        completed_result = await db.execute(
+                            select(sa_func.count(PrintQueueItem.id)).where(
+                                PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
+                                PrintQueueItem.completed_at >= today_start,
+                            )
+                        )
+                        completed_count = completed_result.scalar() or 1
+
+                        await notification_service.on_queue_completed(
+                            completed_count=completed_count,
+                            db=db,
+                        )
+                except Exception:
+                    pass  # Don't fail if notification fails
+
+                # Handle auto_off_after - power off printer if requested (after cooldown)
+                if queue_item.auto_off_after:
+                    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+                    plug = result.scalar_one_or_none()
+                    if plug and plug.enabled:
+                        logger.info("Auto-off requested for printer %s, waiting for cooldown...", printer_id)
+
+                        async def cooldown_and_poweroff(pid: int, plug_id: int):
+                            # Wait for nozzle to cool down
+                            await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
+                            # Re-fetch plug in new session
+                            async with async_session() as new_db:
+                                result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+                                p = result.scalar_one_or_none()
+                                if p and p.enabled:
+                                    success = await tasmota_service.turn_off(p)
+                                    if success:
+                                        logger.info("Powered off printer %s via smart plug '%s'", pid, p.name)
+                                    else:
+                                        logger.warning("Failed to power off printer %s via smart plug", pid)
+
+                        asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id))
+    except Exception as e:
+        logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
+
+    log_timing("Queue item update")
+
     if not archive_id:
     if not archive_id:
         logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
         logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
         return
         return
@@ -2194,6 +2347,11 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
     # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
     # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
     usage_results: list[dict] = []
     usage_results: list[dict] = []
+    # Prefer ams_mapping captured from MQTT request topic (works for all print sources)
+    stored_ams_mapping = data.get("ams_mapping")
+    # Fallback to _print_ams_mappings for queue/reprint (set before print starts)
+    if not stored_ams_mapping and archive_id:
+        stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
     try:
     try:
         async with async_session() as db:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
             from backend.app.api.routes.settings import get_setting
@@ -2204,7 +2362,12 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
             async with async_session() as db:
             async with async_session() as db:
                 usage_results = await usage_on_print_complete(
                 usage_results = await usage_on_print_complete(
-                    printer_id, data, printer_manager, db, archive_id=archive_id
+                    printer_id,
+                    data,
+                    printer_manager,
+                    db,
+                    archive_id=archive_id,
+                    ams_mapping=stored_ams_mapping,
                 )
                 )
                 if usage_results:
                 if usage_results:
                     await ws_manager.broadcast(
                     await ws_manager.broadcast(
@@ -2664,101 +2827,6 @@ async def on_print_complete(printer_id: int, data: dict):
         asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
         asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
         log_timing("Timelapse scan scheduled")
         log_timing("Timelapse scan scheduled")
 
 
-    # Update queue item if this was a scheduled print
-    try:
-        async with async_session() as db:
-            from backend.app.models.print_queue import PrintQueueItem
-            # Note: SmartPlug is already imported at module level (line 56)
-            # Do NOT import it here as it would shadow the module-level import
-            # and cause "cannot access local variable" errors earlier in this function
-
-            result = await db.execute(
-                select(PrintQueueItem)
-                .where(PrintQueueItem.printer_id == printer_id)
-                .where(PrintQueueItem.status == "printing")
-            )
-            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
-                queue_item.completed_at = datetime.now()
-                await db.commit()
-                logger.info("Updated queue item %s status to %s", queue_item.id, status)
-
-                # MQTT relay - publish queue job completed
-                try:
-                    printer_info = printer_manager.get_printer(printer_id)
-                    await mqtt_relay.on_queue_job_completed(
-                        job_id=queue_item.id,
-                        filename=filename or subtask_name,
-                        printer_id=printer_id,
-                        printer_name=printer_info.name if printer_info else "Unknown",
-                        status=status,
-                    )
-                except Exception:
-                    pass  # Don't fail if MQTT fails
-
-                # Check if queue is now empty and send notification
-                try:
-                    from sqlalchemy import func
-
-                    # Count remaining pending items
-                    count_result = await db.execute(
-                        select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending")
-                    )
-                    pending_count = count_result.scalar() or 0
-
-                    if pending_count == 0:
-                        # Count how many completed today (rough approximation)
-                        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
-                        completed_result = await db.execute(
-                            select(func.count(PrintQueueItem.id)).where(
-                                PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
-                                PrintQueueItem.completed_at >= today_start,
-                            )
-                        )
-                        completed_count = completed_result.scalar() or 1
-
-                        await notification_service.on_queue_completed(
-                            completed_count=completed_count,
-                            db=db,
-                        )
-                except Exception:
-                    pass  # Don't fail if notification fails
-
-                # Handle auto_off_after - power off printer if requested (after cooldown)
-                if queue_item.auto_off_after:
-                    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plug = result.scalar_one_or_none()
-                    if plug and plug.enabled:
-                        logger.info("Auto-off requested for printer %s, waiting for cooldown...", printer_id)
-
-                        async def cooldown_and_poweroff(pid: int, plug_id: int):
-                            # Wait for nozzle to cool down
-                            await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
-                            # Re-fetch plug in new session
-                            async with async_session() as new_db:
-                                result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
-                                p = result.scalar_one_or_none()
-                                if p and p.enabled:
-                                    success = await tasmota_service.turn_off(p)
-                                    if success:
-                                        logger.info("Powered off printer %s via smart plug '%s'", pid, p.name)
-                                    else:
-                                        logger.warning("Failed to power off printer %s via smart plug", pid)
-
-                        asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id))
-    except Exception as e:
-        logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
-
-    log_timing("Queue item update")
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
 
 
 
 

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

@@ -97,6 +97,7 @@ class PrintQueueItemResponse(BaseModel):
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None
     printer_name: str | None = None
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
+    filament_used_grams: float | None = None  # Estimated print weight from archive or library file
 
 
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: int | None = None
     created_by_id: int | None = None

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

@@ -245,3 +245,5 @@ class PrinterStatus(BaseModel):
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
     # Firmware version (from info.module[name="ota"].sw_ver)
     # Firmware version (from info.module[name="ota"].sw_ver)
     firmware_version: str | None = None
     firmware_version: str | None = None
+    # Queue: user has acknowledged plate is cleared for next queued print
+    plate_cleared: bool = False

+ 112 - 1
backend/app/services/archive.py

@@ -1115,7 +1115,11 @@ class ArchiveService:
         timelapse_data: bytes,
         timelapse_data: bytes,
         filename: str = "timelapse.mp4",
         filename: str = "timelapse.mp4",
     ) -> bool:
     ) -> bool:
-        """Attach a timelapse video to an archive."""
+        """Attach a timelapse video to an archive.
+
+        Non-MP4 videos (e.g. AVI from P1S) are saved as-is and a background
+        task converts them to MP4 for browser compatibility.
+        """
         import asyncio
         import asyncio
 
 
         archive = await self.get_archive(archive_id)
         archive = await self.get_archive(archive_id)
@@ -1135,4 +1139,111 @@ class ArchiveService:
         archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
         archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
         await self.db.commit()
         await self.db.commit()
 
 
+        # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
+        if not filename.lower().endswith(".mp4"):
+            asyncio.create_task(
+                _convert_timelapse_to_mp4(archive_id, timelapse_file),
+                name=f"timelapse-convert-{archive_id}",
+            )
+
         return True
         return True
+
+
+async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
+    """Background task: convert non-MP4 timelapse (e.g. AVI from P1S) to MP4.
+
+    Runs with low CPU priority (-threads 1, nice) so it doesn't starve
+    other processes on resource-constrained devices like Raspberry Pi.
+    """
+    import asyncio
+
+    from backend.app.core.database import async_session
+    from backend.app.services.camera import get_ffmpeg_path
+
+    logger = logging.getLogger(__name__)
+
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.info(
+            "FFmpeg not available, skipping timelapse conversion for archive %s (file saved as %s)",
+            archive_id,
+            source_path.suffix,
+        )
+        return
+
+    mp4_path = source_path.with_suffix(".mp4")
+
+    try:
+        cmd = [
+            ffmpeg,
+            "-y",
+            "-i",
+            str(source_path),
+            "-c:v",
+            "libx264",
+            "-preset",
+            "fast",
+            "-crf",
+            "23",
+            "-threads",
+            "1",
+            "-movflags",
+            "+faststart",
+            str(mp4_path),
+        ]
+
+        # Try with nice for lower CPU priority (standard on Linux/macOS)
+        try:
+            process = await asyncio.create_subprocess_exec(
+                "nice",
+                "-n",
+                "19",
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+        except FileNotFoundError:
+            # nice not available (e.g. Windows), run without
+            process = await asyncio.create_subprocess_exec(
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+
+        _, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.warning(
+                "Timelapse conversion failed for archive %s: %s",
+                archive_id,
+                stderr.decode()[-500:],
+            )
+            if mp4_path.exists():
+                mp4_path.unlink()
+            return
+
+        # Update DB path to the new MP4 file
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
+
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+            archive = result.scalar_one_or_none()
+            if archive:
+                archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))
+                await db.commit()
+
+        # Remove original non-MP4 file
+        if source_path.exists():
+            source_path.unlink()
+
+        logger.info(
+            "Converted timelapse to MP4 for archive %s (%s → %s)",
+            archive_id,
+            source_path.name,
+            mp4_path.name,
+        )
+
+    except Exception as e:
+        logger.warning("Timelapse conversion error for archive %s: %s", archive_id, e)
+        if mp4_path.exists():
+            mp4_path.unlink()

+ 145 - 4
backend/app/services/bambu_mqtt.py

@@ -130,6 +130,8 @@ class PrinterState:
     active_extruder: int = 0
     active_extruder: int = 0
     # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     tray_now: int = 255
     tray_now: int = 255
+    # Last valid tray_now (0-253) — survives unload (255) for usage tracking after print completes
+    last_loaded_tray: int = -1
     # Pending load target - used to track what tray we're loading for H2D disambiguation
     # Pending load target - used to track what tray we're loading for H2D disambiguation
     pending_tray_target: int | None = None
     pending_tray_target: int | None = None
     # AMS status for filament change tracking (from print.ams.ams_status field)
     # AMS status for filament change tracking (from print.ams.ams_status field)
@@ -277,6 +279,9 @@ class BambuMQTTClient:
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
+        self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
+        self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
+        self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
         self._last_message_time: float = 0.0  # Track when we last received a message
@@ -298,6 +303,18 @@ class BambuMQTTClient:
         # We use our tracked value to resolve the correct global ID
         # We use our tracked value to resolve the correct global ID
         self._last_load_tray_id: int | None = None
         self._last_load_tray_id: int | None = None
 
 
+        # Captured ams_mapping from print commands on the request topic
+        # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
+        self._captured_ams_mapping: list[int] | None = None
+
+        # Request topic subscription tracking
+        # Some printer MQTT brokers (e.g. P1S) reject subscriptions to the request
+        # topic by killing the TCP connection. We detect this and gracefully degrade.
+        self._request_topic_supported: bool = True
+        self._request_topic_sub_mid: int | None = None
+        self._request_topic_sub_time: float = 0.0
+        self._request_topic_confirmed: bool = False
+
     @property
     @property
     def topic_subscribe(self) -> str:
     def topic_subscribe(self) -> str:
         return f"device/{self.serial_number}/report"
         return f"device/{self.serial_number}/report"
@@ -331,6 +348,19 @@ class BambuMQTTClient:
         if rc == 0:
         if rc == 0:
             self.state.connected = True
             self.state.connected = True
             client.subscribe(self.topic_subscribe)
             client.subscribe(self.topic_subscribe)
+            # Subscribe to request topic for ams_mapping capture (if supported by broker)
+            if self._request_topic_supported:
+                result, mid = client.subscribe(self.topic_publish)
+                if result == mqtt.MQTT_ERR_SUCCESS:
+                    self._request_topic_sub_mid = mid
+                    self._request_topic_sub_time = time.time()
+                    self._request_topic_confirmed = False
+                else:
+                    logger.warning(
+                        "[%s] Failed to send request topic subscription",
+                        self.serial_number,
+                    )
+                    self._request_topic_supported = False
             # Request full status update (includes nozzle info in push_status response)
             # Request full status update (includes nozzle info in push_status response)
             self._request_push_all()
             self._request_push_all()
             # Request firmware version info
             # Request firmware version info
@@ -345,6 +375,29 @@ class BambuMQTTClient:
         else:
         else:
             self.state.connected = False
             self.state.connected = False
 
 
+    def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
+        """Handle SUBACK responses to detect request topic subscription rejection."""
+        if mid == self._request_topic_sub_mid:
+            for rc in reason_code_list:
+                if rc.is_failure:
+                    logger.warning(
+                        "[%s] Request topic subscription rejected (code=%d: %s). "
+                        "ams_mapping capture from slicer-initiated prints unavailable.",
+                        self.serial_number,
+                        rc.value,
+                        rc.getName(),
+                    )
+                    self._request_topic_supported = False
+                else:
+                    logger.info(
+                        "[%s] Request topic subscription accepted. "
+                        "ams_mapping capture enabled for slicer-initiated prints.",
+                        self.serial_number,
+                    )
+                    self._request_topic_confirmed = True
+            self._request_topic_sub_mid = None
+            self._request_topic_sub_time = 0.0
+
     def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
     def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
         # Ignore spurious disconnect callbacks if we've received a message recently
         # Ignore spurious disconnect callbacks if we've received a message recently
         # Paho-mqtt sometimes fires disconnect callbacks while the connection is still active
         # Paho-mqtt sometimes fires disconnect callbacks while the connection is still active
@@ -356,6 +409,23 @@ class BambuMQTTClient:
             return
             return
 
 
         logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
         logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
+
+        # Detect if request topic subscription caused the disconnect.
+        # If we just subscribed and got disconnected before any SUBACK confirmation,
+        # the broker likely killed the connection due to the unauthorized subscription.
+        if (
+            self._request_topic_sub_time > 0
+            and not self._request_topic_confirmed
+            and time.time() - self._request_topic_sub_time < 10.0
+        ):
+            logger.warning(
+                "[%s] Disconnected shortly after request topic subscription. Disabling request topic for this printer.",
+                self.serial_number,
+            )
+            self._request_topic_supported = False
+        self._request_topic_sub_mid = None
+        self._request_topic_sub_time = 0.0
+
         self.state.connected = False
         self.state.connected = False
         if self.on_state_change:
         if self.on_state_change:
             self.on_state_change(self.state)
             self.on_state_change(self.state)
@@ -369,6 +439,11 @@ class BambuMQTTClient:
             self._last_message_time = time.time()
             self._last_message_time = time.time()
             self.state.connected = True
             self.state.connected = True
 
 
+            # Intercept request-topic messages (print commands from slicer/Bambuddy)
+            if msg.topic == self.topic_publish:
+                self._handle_request_message(payload)
+                return
+
             # TEMP: Dump full payload once to find extruder state field
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
                 self._payload_dumped = True
@@ -387,6 +462,20 @@ class BambuMQTTClient:
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             pass  # Ignore non-JSON MQTT messages (e.g. binary or malformed payloads)
             pass  # Ignore non-JSON MQTT messages (e.g. binary or malformed payloads)
 
 
+    def _handle_request_message(self, data: dict) -> None:
+        """Intercept print commands on the request topic to capture ams_mapping."""
+        print_data = data.get("print", {})
+        if not isinstance(print_data, dict):
+            return
+        command = print_data.get("command", "")
+        if command == "project_file" and "ams_mapping" in print_data:
+            self._captured_ams_mapping = print_data["ams_mapping"]
+            logger.info(
+                "[%s] Captured ams_mapping from print command: %s",
+                self.serial_number,
+                self._captured_ams_mapping,
+            )
+
     def _process_message(self, payload: dict):
     def _process_message(self, payload: dict):
         """Process incoming MQTT message from printer."""
         """Process incoming MQTT message from printer."""
         # Handle top-level AMS data (comes outside of "print" key)
         # Handle top-level AMS data (comes outside of "print" key)
@@ -444,6 +533,16 @@ class BambuMQTTClient:
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
                 )
 
 
+            # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
+            # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
+            if not self._is_dual_nozzle and "device" in print_data:
+                dev = print_data.get("device")
+                if isinstance(dev, dict):
+                    ext_info = dev.get("extruder", {}).get("info", [])
+                    if isinstance(ext_info, list) and len(ext_info) >= 2:
+                        self._is_dual_nozzle = True
+                        logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
+
             # Handle AMS data that comes inside print key
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
             if "ams" in print_data:
                 try:
                 try:
@@ -826,7 +925,9 @@ class BambuMQTTClient:
 
 
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
                 # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
                 # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
-                if parsed_tray_now >= 0 and parsed_tray_now <= 3:
+                # Single-nozzle printers (X1C, P2S, etc.) always report global IDs, even with multiple AMS
+                ams_map = self.state.ams_extruder_map
+                if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # First, check if we have a pending target that matches this slot
                     # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     pending_target = self.state.pending_tray_target
                     if pending_target is not None:
                     if pending_target is not None:
@@ -859,7 +960,8 @@ class BambuMQTTClient:
                         if snow_tray is not None and snow_tray != 255:
                         if snow_tray is not None and snow_tray != 255:
                             # snow_tray is already normalized to global ID
                             # snow_tray is already normalized to global ID
                             # Verify the slot matches what we see in tray_now
                             # Verify the slot matches what we see in tray_now
-                            snow_slot = snow_tray % 4 if snow_tray < 128 else -1
+                            # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0
+                            snow_slot = snow_tray % 4 if snow_tray < 128 else (0 if snow_tray <= 135 else -1)
                             if snow_slot == parsed_tray_now:
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
                                 if self.state.tray_now != snow_tray:
                                     logger.debug(
                                     logger.debug(
@@ -876,7 +978,6 @@ class BambuMQTTClient:
                                 self.state.tray_now = snow_tray
                                 self.state.tray_now = snow_tray
                         else:
                         else:
                             # Fallback: snow not available, use ams_extruder_map (less reliable)
                             # Fallback: snow not available, use ams_extruder_map (less reliable)
-                            ams_map = self.state.ams_extruder_map
                             # Find ALL AMS units on the active extruder
                             # Find ALL AMS units on the active extruder
                             ams_on_extruder = []
                             ams_on_extruder = []
                             for ams_id_str, ext_id in ams_map.items():
                             for ams_id_str, ext_id in ams_map.items():
@@ -927,6 +1028,10 @@ class BambuMQTTClient:
                     # Trust the printer's reported value.
                     # Trust the printer's reported value.
                     self.state.tray_now = parsed_tray_now
                     self.state.tray_now = parsed_tray_now
 
 
+                # Track last valid tray for usage tracking (survives retract → 255 at print end)
+                if 0 <= self.state.tray_now <= 253:
+                    self.state.last_loaded_tray = self.state.tray_now
+
                 logger.debug("[%s] tray_now updated: %s", self.serial_number, self.state.tray_now)
                 logger.debug("[%s] tray_now updated: %s", self.serial_number, self.state.tray_now)
 
 
             # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
             # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
@@ -1121,6 +1226,9 @@ class BambuMQTTClient:
         if "subtask_id" in data:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
         if "mc_percent" in data:
+            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
+            if self.state.progress > 0:
+                self._last_valid_progress = self.state.progress
             self.state.progress = float(data["mc_percent"])
             self.state.progress = float(data["mc_percent"])
         if "mc_remaining_time" in data:
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
             self.state.remaining_time = int(data["mc_remaining_time"])
@@ -1135,6 +1243,9 @@ class BambuMQTTClient:
         if "layer_num" in data:
         if "layer_num" in data:
             new_layer = int(data["layer_num"])
             new_layer = int(data["layer_num"])
             old_layer = self.state.layer_num
             old_layer = self.state.layer_num
+            # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
+            if old_layer > 0:
+                self._last_valid_layer_num = old_layer
             self.state.layer_num = new_layer
             self.state.layer_num = new_layer
             # Trigger layer change callback if layer increased
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
             if new_layer > old_layer and self.on_layer_change:
@@ -1832,10 +1943,12 @@ class BambuMQTTClient:
                         if "diameter" in nozzle:
                         if "diameter" in nozzle:
                             self.state.nozzles[idx].nozzle_diameter = str(nozzle["diameter"])
                             self.state.nozzles[idx].nozzle_diameter = str(nozzle["diameter"])
 
 
-        # Preserve AMS, vt_tray, and ams_extruder_map data when updating raw_data
+        # Preserve AMS, vt_tray, ams_extruder_map, and mapping data when updating raw_data
+        # (these fields aren't sent in every MQTT push, only when changed)
         ams_data = self.state.raw_data.get("ams")
         ams_data = self.state.raw_data.get("ams")
         vt_tray_data = self.state.raw_data.get("vt_tray")
         vt_tray_data = self.state.raw_data.get("vt_tray")
         ams_extruder_map_data = self.state.raw_data.get("ams_extruder_map")
         ams_extruder_map_data = self.state.raw_data.get("ams_extruder_map")
+        mapping_data = self.state.raw_data.get("mapping")
         self.state.raw_data = data
         self.state.raw_data = data
         if ams_data is not None:
         if ams_data is not None:
             self.state.raw_data["ams"] = ams_data
             self.state.raw_data["ams"] = ams_data
@@ -1843,6 +1956,12 @@ class BambuMQTTClient:
             self.state.raw_data["vt_tray"] = vt_tray_data
             self.state.raw_data["vt_tray"] = vt_tray_data
         if ams_extruder_map_data is not None:
         if ams_extruder_map_data is not None:
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map_data
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map_data
+        if mapping_data is not None and "mapping" not in data:
+            self.state.raw_data["mapping"] = mapping_data
+
+        # Log mapping data when received (for usage tracking debugging)
+        if "mapping" in data:
+            logger.debug("[%s] MQTT mapping field: %s", self.serial_number, data["mapping"])
 
 
         # Log state transitions for debugging
         # Log state transitions for debugging
         if "gcode_state" in data:
         if "gcode_state" in data:
@@ -1886,6 +2005,9 @@ class BambuMQTTClient:
             # Reset completion tracking for new print
             # Reset completion tracking for new print
             self._was_running = True
             self._was_running = True
             self._completion_triggered = False
             self._completion_triggered = False
+            # Reset last valid progress/layer for usage tracking
+            self._last_valid_progress = 0.0
+            self._last_valid_layer_num = 0
             # Initialize timelapse tracking based on current state
             # Initialize timelapse tracking based on current state
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # so self.state.timelapse may already be set from this message.
             # so self.state.timelapse may already be set from this message.
@@ -1909,6 +2031,7 @@ class BambuMQTTClient:
                     if self.state.remaining_time > 0
                     if self.state.remaining_time > 0
                     else None,  # Convert minutes to seconds
                     else None,  # Convert minutes to seconds
                     "raw_data": data,
                     "raw_data": data,
+                    "ams_mapping": self._captured_ams_mapping,
                 }
                 }
             )
             )
 
 
@@ -1967,8 +2090,13 @@ class BambuMQTTClient:
                     "raw_data": data,
                     "raw_data": data,
                     "timelapse_was_active": timelapse_was_active,
                     "timelapse_was_active": timelapse_was_active,
                     "hms_errors": hms_errors_data,
                     "hms_errors": hms_errors_data,
+                    "ams_mapping": self._captured_ams_mapping,
+                    # Last valid progress/layer before firmware reset (for partial usage tracking)
+                    "last_progress": self._last_valid_progress,
+                    "last_layer_num": self._last_valid_layer_num,
                 }
                 }
             )
             )
+            self._captured_ams_mapping = None
 
 
         self._previous_gcode_state = self.state.state
         self._previous_gcode_state = self.state.state
         if current_file:
         if current_file:
@@ -2064,6 +2192,7 @@ class BambuMQTTClient:
         self._client.username_pw_set("bblp", self.access_code)
         self._client.username_pw_set("bblp", self.access_code)
         self._client.on_connect = self._on_connect
         self._client.on_connect = self._on_connect
         self._client.on_disconnect = self._on_disconnect
         self._client.on_disconnect = self._on_disconnect
+        self._client.on_subscribe = self._on_subscribe
         self._client.on_message = self._on_message
         self._client.on_message = self._on_message
 
 
         # TLS setup - Bambu uses self-signed certs
         # TLS setup - Bambu uses self-signed certs
@@ -2861,6 +2990,18 @@ class BambuMQTTClient:
         logger.info("[%s] Sent resume print command", self.serial_number)
         logger.info("[%s] Sent resume print command", self.serial_number)
         return True
         return True
 
 
+    def clear_hms_errors(self) -> bool:
+        """Clear HMS/print errors on the printer and locally."""
+        if not self._client or not self.state.connected:
+            logger.warning("[%s] Cannot clear HMS errors: not connected", self.serial_number)
+            return False
+
+        command = {"print": {"command": "clean_print_error", "sequence_id": "0"}}
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        self.state.hms_errors = []
+        logger.info("[%s] Sent clear HMS errors command", self.serial_number)
+        return True
+
     def skip_objects(self, object_ids: list[int]) -> bool:
     def skip_objects(self, object_ids: list[int]) -> bool:
         """Skip specific objects during a print.
         """Skip specific objects during a print.
 
 

+ 31 - 14
backend/app/services/notification_service.py

@@ -188,7 +188,9 @@ class NotificationService:
         else:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
-    async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+    async def _send_ntfy(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
         """Send notification via ntfy."""
         """Send notification via ntfy."""
         server = config.get("server", "https://ntfy.sh").rstrip("/")
         server = config.get("server", "https://ntfy.sh").rstrip("/")
         topic = config.get("topic", "").strip()
         topic = config.get("topic", "").strip()
@@ -204,7 +206,14 @@ class NotificationService:
             headers["Authorization"] = f"Bearer {auth_token}"
             headers["Authorization"] = f"Bearer {auth_token}"
 
 
         client = await self._get_client()
         client = await self._get_client()
-        response = await client.post(url, content=message, headers=headers)
+
+        if image_data:
+            # ntfy supports image attachments via multipart form-data
+            headers["Filename"] = "photo.jpg"
+            headers["Message"] = message
+            response = await client.put(url, content=image_data, headers=headers)
+        else:
+            response = await client.post(url, content=message, headers=headers)
 
 
         if response.status_code in (200, 204):
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
             return True, "Message sent successfully"
@@ -257,7 +266,7 @@ class NotificationService:
             except Exception:
             except Exception:
                 return False, f"HTTP {response.status_code}: {response.text[:200]}"
                 return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
-    async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
+    async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
         """Send notification via Telegram bot."""
         """Send notification via Telegram bot."""
         bot_token = config.get("bot_token", "").strip()
         bot_token = config.get("bot_token", "").strip()
         chat_id = config.get("chat_id", "").strip()
         chat_id = config.get("chat_id", "").strip()
@@ -265,8 +274,6 @@ class NotificationService:
         if not bot_token or not chat_id:
         if not bot_token or not chat_id:
             return False, "Bot token and chat ID are required"
             return False, "Bot token and chat ID are required"
 
 
-        url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
-
         # Escape underscores in the message body so Telegram Markdown
         # Escape underscores in the message body so Telegram Markdown
         # parsing doesn't break on job names like "A1_plate_8" or error
         # parsing doesn't break on job names like "A1_plate_8" or error
         # codes like "0300_0001".  The title is already wrapped in *bold*
         # codes like "0300_0001".  The title is already wrapped in *bold*
@@ -276,14 +283,24 @@ class NotificationService:
             body_part = body_part.replace("_", "\\_")
             body_part = body_part.replace("_", "\\_")
             message = f"{title_part}\n{body_part}"
             message = f"{title_part}\n{body_part}"
 
 
-        data = {
-            "chat_id": chat_id,
-            "text": message,
-            "parse_mode": "Markdown",
-        }
-
         client = await self._get_client()
         client = await self._get_client()
-        response = await client.post(url, json=data)
+
+        if image_data:
+            # Use sendPhoto to attach the thumbnail with the caption
+            url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+            response = await client.post(
+                url,
+                data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
+                files={"photo": ("photo.jpg", image_data, "image/jpeg")},
+            )
+        else:
+            url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+            data = {
+                "chat_id": chat_id,
+                "text": message,
+                "parse_mode": "Markdown",
+            }
+            response = await client.post(url, json=data)
 
 
         if response.status_code == 200:
         if response.status_code == 200:
             result = response.json()
             result = response.json()
@@ -451,11 +468,11 @@ class NotificationService:
             if provider.provider_type == "callmebot":
             if provider.provider_type == "callmebot":
                 return await self._send_callmebot(config, f"{title}\n{message}")
                 return await self._send_callmebot(config, f"{title}\n{message}")
             elif provider.provider_type == "ntfy":
             elif provider.provider_type == "ntfy":
-                return await self._send_ntfy(config, title, message)
+                return await self._send_ntfy(config, title, message, image_data=image_data)
             elif provider.provider_type == "pushover":
             elif provider.provider_type == "pushover":
                 return await self._send_pushover(config, title, message, image_data=image_data)
                 return await self._send_pushover(config, title, message, image_data=image_data)
             elif provider.provider_type == "telegram":
             elif provider.provider_type == "telegram":
-                return await self._send_telegram(config, f"*{title}*\n{message}")
+                return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider.provider_type == "email":
             elif provider.provider_type == "email":
                 return await self._send_email(config, title, message)
                 return await self._send_email(config, title, message)
             elif provider.provider_type == "discord":
             elif provider.provider_type == "discord":

+ 33 - 14
backend/app/services/print_scheduler.py

@@ -558,7 +558,7 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_ht": False,
                         "is_external": True,
                         "is_external": True,
                         "global_tray_id": tray_id,
                         "global_tray_id": tray_id,
-                        "extruder_id": (tray_id - 254) if ams_extruder_map else None,
+                        "extruder_id": (255 - tray_id) if ams_extruder_map else None,
                     }
                     }
                 )
                 )
 
 
@@ -634,12 +634,12 @@ class PrintScheduler:
             # Get available trays (not already used)
             # Get available trays (not already used)
             available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
             available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
 
 
-            # Nozzle-aware filtering: restrict to trays on the correct nozzle
+            # Nozzle-aware filtering: restrict to trays on the correct nozzle.
+            # Hard filter — cross-nozzle assignment causes print failures
+            # ("position of left hotend is abnormal"), so never fall back.
             req_nozzle_id = req.get("nozzle_id")
             req_nozzle_id = req.get("nozzle_id")
             if req_nozzle_id is not None:
             if req_nozzle_id is not None:
-                nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id]
-                if nozzle_filtered:
-                    available = nozzle_filtered
+                available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
 
             # Check if tray_info_idx is unique among available trays
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
             if req_tray_info_idx:
@@ -903,12 +903,31 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 return
             # Library files store absolute paths
             # Library files store absolute paths
-            from pathlib import Path
-
             lib_path = Path(library_file.file_path)
             lib_path = Path(library_file.file_path)
             file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
             file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
             filename = library_file.filename
             filename = library_file.filename
 
 
+            # Create archive from library file so usage tracking has access to the 3MF
+            try:
+                from backend.app.services.archive import ArchiveService
+
+                archive_service = ArchiveService(db)
+                archive = await archive_service.archive_print(
+                    printer_id=item.printer_id,
+                    source_file=file_path,
+                )
+                if archive:
+                    item.archive_id = archive.id
+                    await db.flush()
+                    logger.info(
+                        "Queue item %s: Created archive %s from library file %s",
+                        item.id,
+                        archive.id,
+                        item.library_file_id,
+                    )
+            except Exception as e:
+                logger.warning("Queue item %s: Failed to create archive from library file: %s", item.id, e)
+
         else:
         else:
             # Neither archive nor library file specified
             # Neither archive nor library file specified
             item.status = "failed"
             item.status = "failed"
@@ -1016,13 +1035,6 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
-        # Register as expected print so we don't create a duplicate archive
-        # Only applicable for archive-based prints
-        if archive:
-            from backend.app.main import register_expected_print
-
-            register_expected_print(item.printer_id, remote_filename, archive.id)
-
         # Parse AMS mapping if stored
         # Parse AMS mapping if stored
         ams_mapping = None
         ams_mapping = None
         if item.ams_mapping:
         if item.ams_mapping:
@@ -1031,6 +1043,13 @@ class PrintScheduler:
             except json.JSONDecodeError:
             except json.JSONDecodeError:
                 logger.warning("Queue item %s: Invalid AMS mapping JSON, ignoring", item.id)
                 logger.warning("Queue item %s: Invalid AMS mapping JSON, ignoring", item.id)
 
 
+        # Register as expected print so we don't create a duplicate archive
+        # Only applicable for archive-based prints
+        if archive:
+            from backend.app.main import register_expected_print
+
+            register_expected_print(item.printer_id, remote_filename, archive.id, ams_mapping=ams_mapping)
+
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # This prevents phantom reprints if the backend crashes/restarts after the
         # This prevents phantom reprints if the backend crashes/restarts after the
         # print command is sent but before the status update is committed.
         # print command is sent but before the status update is committed.

+ 199 - 21
backend/app/services/usage_tracker.py

@@ -22,12 +22,55 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
+    """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
+
+    The printer's MQTT mapping field is an array indexed by slicer filament slot
+    (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
+    65535 means unmapped.
+
+    Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
+    no valid mappings found.
+    """
+    if not isinstance(mapping_raw, list) or not mapping_raw:
+        return None
+
+    result = []
+    for value in mapping_raw:
+        if not isinstance(value, int) or value >= 65535:
+            result.append(-1)
+            continue
+
+        ams_hw_id = value >> 8
+        slot = value & 0xFF
+
+        if 0 <= ams_hw_id <= 3:
+            # Regular AMS: sequential global ID
+            result.append(ams_hw_id * 4 + (slot & 0x03))
+        elif 128 <= ams_hw_id <= 135:
+            # AMS-HT: global ID is the hardware ID (one slot per unit)
+            result.append(ams_hw_id)
+        elif ams_hw_id in (254, 255):
+            # External spool
+            result.append(254 if slot != 255 else 255)
+        else:
+            result.append(-1)
+
+    # Only return if at least one valid mapping exists
+    if all(v < 0 for v in result):
+        return None
+
+    return result
+
+
 @dataclass
 @dataclass
 class PrintSession:
 class PrintSession:
     printer_id: int
     printer_id: int
     print_name: str
     print_name: str
     started_at: datetime
     started_at: datetime
     tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
     tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
+    # tray_now at print start (correct value, unlike at completion where it's 255)
+    tray_now_at_start: int = -1
 
 
 
 
 # Module-level storage, keyed by printer_id
 # Module-level storage, keyed by printer_id
@@ -58,6 +101,36 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
 
 
     print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
     print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
 
 
+    # Capture tray_now at print start (reliable, unlike at completion where it's 255)
+    tray_now_at_start = state.tray_now if state else -1
+
+    # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---
+    # This helps us understand what each printer model reports for slot-to-tray mapping.
+    mapping_field = state.raw_data.get("mapping")
+    logger.info(
+        "[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s",
+        printer_id,
+        mapping_field,
+        tray_now_at_start,
+        getattr(state, "last_loaded_tray", "N/A"),
+    )
+    # Log all raw_data keys containing "map" or "ams" for discovery
+    map_keys = {k: state.raw_data[k] for k in state.raw_data if "map" in k.lower()}
+    if map_keys:
+        logger.info("[UsageTracker] PRINT START printer %d: mapping-related keys: %s", printer_id, map_keys)
+    # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot
+    for ams_unit in ams_data:
+        ams_id = int(ams_unit.get("id", 0))
+        tray_summary = []
+        for tray in ams_unit.get("tray", []):
+            tray_summary.append(
+                f"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, "
+                f"color={tray.get('tray_color', '')}, "
+                f"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, "
+                f"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})"
+            )
+        logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
+
     # Always create session (even without valid remain data) so print_name
     # Always create session (even without valid remain data) so print_name
     # is available at completion for 3MF-based tracking
     # is available at completion for 3MF-based tracking
     session = PrintSession(
     session = PrintSession(
@@ -65,6 +138,7 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
         print_name=print_name,
         print_name=print_name,
         started_at=datetime.now(timezone.utc),
         started_at=datetime.now(timezone.utc),
         tray_remain_start=tray_remain_start,
         tray_remain_start=tray_remain_start,
+        tray_now_at_start=tray_now_at_start,
     )
     )
     _active_sessions[printer_id] = session
     _active_sessions[printer_id] = session
 
 
@@ -85,6 +159,7 @@ async def on_print_complete(
     printer_manager,
     printer_manager,
     db: AsyncSession,
     db: AsyncSession,
     archive_id: int | None = None,
     archive_id: int | None = None,
+    ams_mapping: list[int] | None = None,
 ) -> list[dict]:
 ) -> list[dict]:
     """Compute consumption deltas and update spool weight_used/last_used.
     """Compute consumption deltas and update spool weight_used/last_used.
 
 
@@ -99,13 +174,42 @@ async def on_print_complete(
     results = []
     results = []
     handled_trays: set[tuple[int, int]] = set()
     handled_trays: set[tuple[int, int]] = set()
 
 
+    logger.info(
+        "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
+        printer_id,
+        archive_id,
+        "yes" if session else "no",
+        ams_mapping,
+    )
+
+    # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---
+    state = printer_manager.get_status(printer_id)
+    if state and state.raw_data:
+        logger.info(
+            "[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s",
+            printer_id,
+            state.raw_data.get("mapping"),
+            state.tray_now,
+            getattr(state, "last_loaded_tray", "N/A"),
+        )
+
     # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
     # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
     if archive_id:
     if archive_id:
         print_name = (
         print_name = (
             (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
             (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
         )
         )
         threemf_results = await _track_from_3mf(
         threemf_results = await _track_from_3mf(
-            printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
+            printer_id,
+            archive_id,
+            status,
+            print_name,
+            handled_trays,
+            printer_manager,
+            db,
+            ams_mapping=ams_mapping,
+            tray_now_at_start=session.tray_now_at_start if session else -1,
+            last_progress=data.get("last_progress", 0.0),
+            last_layer_num=data.get("last_layer_num", 0),
         )
         )
         results.extend(threemf_results)
         results.extend(threemf_results)
 
 
@@ -213,6 +317,10 @@ async def _track_from_3mf(
     handled_trays: set[tuple[int, int]],
     handled_trays: set[tuple[int, int]],
     printer_manager,
     printer_manager,
     db: AsyncSession,
     db: AsyncSession,
+    ams_mapping: list[int] | None = None,
+    tray_now_at_start: int = -1,
+    last_progress: float = 0.0,
+    last_layer_num: int = 0,
 ) -> list[dict]:
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
     """Track usage from 3MF per-filament slicer data (primary path).
 
 
@@ -221,9 +329,11 @@ async def _track_from_3mf(
     then falls back to linear scaling by progress.
     then falls back to linear scaling by progress.
 
 
     Slot-to-tray mapping priority:
     Slot-to-tray mapping priority:
-    1. Queue item ams_mapping (for queue-initiated prints)
-    2. tray_now from printer state (for single-filament non-queue prints)
-    3. Default mapping: slot_id - 1 = global_tray_id (last resort)
+    1. Stored ams_mapping from print command (reprints/direct prints)
+    2. MQTT mapping field from printer state (universal, all print sources)
+    3. Queue item ams_mapping (for queue-initiated prints)
+    4. tray_now from printer state (for single-filament non-queue prints)
+    5. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
     """
     from backend.app.core.config import settings as app_settings
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
@@ -233,43 +343,92 @@ async def _track_from_3mf(
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive or not archive.file_path:
     if not archive or not archive.file_path:
+        logger.info("[UsageTracker] 3MF: archive %s has no file_path, skipping", archive_id)
         return []
         return []
 
 
     file_path = app_settings.base_dir / archive.file_path
     file_path = app_settings.base_dir / archive.file_path
     if not file_path.exists():
     if not file_path.exists():
+        logger.info("[UsageTracker] 3MF: file not found: %s", file_path)
         return []
         return []
 
 
     filament_usage = extract_filament_usage_from_3mf(file_path)
     filament_usage = extract_filament_usage_from_3mf(file_path)
     if not filament_usage:
     if not filament_usage:
+        logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
         return []
         return []
 
 
+    logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
+
     # --- Resolve slot-to-tray mapping ---
     # --- Resolve slot-to-tray mapping ---
-    # 1. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
-    slot_to_tray = None
-    queue_result = await db.execute(
-        select(PrintQueueItem)
-        .where(PrintQueueItem.archive_id == archive_id)
-        .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
+    mapping_source = None
+
+    # 1. Use stored ams_mapping from the print command (reprints/direct prints)
+    slot_to_tray = ams_mapping
+    if slot_to_tray:
+        mapping_source = "print_cmd"
+
+    # 2. Try MQTT mapping field from printer state (universal, all print sources)
+    if not slot_to_tray:
+        state = printer_manager.get_status(printer_id)
+        raw_data = getattr(state, "raw_data", None) if state else None
+        if raw_data:
+            mqtt_mapping = raw_data.get("mapping")
+            decoded = _decode_mqtt_mapping(mqtt_mapping)
+            if decoded:
+                slot_to_tray = decoded
+                mapping_source = "mqtt"
+
+    # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
+    if not slot_to_tray:
+        queue_result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.archive_id == archive_id)
+            .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
+        )
+        queue_item = queue_result.scalar_one_or_none()
+        if queue_item and queue_item.ams_mapping:
+            try:
+                slot_to_tray = json.loads(queue_item.ams_mapping)
+                mapping_source = "queue"
+            except (json.JSONDecodeError, TypeError):
+                pass
+
+    logger.info(
+        "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
+        slot_to_tray,
+        mapping_source or "none",
     )
     )
-    queue_item = queue_result.scalar_one_or_none()
-    if queue_item and queue_item.ams_mapping:
-        try:
-            slot_to_tray = json.loads(queue_item.ams_mapping)
-        except (json.JSONDecodeError, TypeError):
-            pass
-
-    # 2. For single-filament non-queue prints, use tray_now from printer state
+
+    # 3. For single-filament non-queue prints, use tray_now from printer state
+    #    Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
     nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
     nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
     tray_now_override: int | None = None
     tray_now_override: int | None = None
     if not slot_to_tray and len(nonzero_slots) == 1:
     if not slot_to_tray and len(nonzero_slots) == 1:
         state = printer_manager.get_status(printer_id)
         state = printer_manager.get_status(printer_id)
-        if state and 0 <= state.tray_now <= 254:
+        # Try tray_now_at_start first (captured at print start)
+        if 0 <= tray_now_at_start <= 254:
+            tray_now_override = tray_now_at_start
+            logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
+        elif state and 0 <= state.tray_now <= 254:
+            # Current state is valid (printer didn't retract yet)
             tray_now_override = state.tray_now
             tray_now_override = state.tray_now
+            logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
+        elif state and 0 <= state.last_loaded_tray <= 253:
+            # Last valid tray before retract (H2D retracts before completion callback)
+            tray_now_override = state.last_loaded_tray
+            logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
         elif state and state.tray_now == 255:
         elif state and state.tray_now == 255:
             # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
             # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
             vt_tray = state.raw_data.get("vt_tray") or []
             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)):
             if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
                 tray_now_override = state.tray_now
                 tray_now_override = state.tray_now
+                logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
+        if tray_now_override is None:
+            logger.info(
+                "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
+                tray_now_at_start,
+                state.tray_now if state else "N/A",
+                state.last_loaded_tray if state else "N/A",
+            )
 
 
     # Scale factor for partial prints (failed/aborted)
     # Scale factor for partial prints (failed/aborted)
     if status == "completed":
     if status == "completed":
@@ -277,6 +436,10 @@ async def _track_from_3mf(
     else:
     else:
         state = printer_manager.get_status(printer_id)
         state = printer_manager.get_status(printer_id)
         progress = state.progress if state else 0
         progress = state.progress if state else 0
+        # Firmware resets progress to 0 on cancel — use last valid progress captured during print
+        if progress <= 0 and last_progress > 0:
+            progress = last_progress
+            logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
         scale = max(0.0, min(progress / 100.0, 1.0))
         scale = max(0.0, min(progress / 100.0, 1.0))
 
 
     # Per-layer gcode accuracy for partial prints
     # Per-layer gcode accuracy for partial prints
@@ -284,6 +447,10 @@ async def _track_from_3mf(
     if status != "completed":
     if status != "completed":
         state = printer_manager.get_status(printer_id)
         state = printer_manager.get_status(printer_id)
         current_layer = state.layer_num if state else 0
         current_layer = state.layer_num if state else 0
+        # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
+        if current_layer <= 0 and last_layer_num > 0:
+            current_layer = last_layer_num
+            logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
         if current_layer > 0:
         if current_layer > 0:
             try:
             try:
                 from backend.app.utils.threemf_tools import (
                 from backend.app.utils.threemf_tools import (
@@ -338,6 +505,16 @@ async def _track_from_3mf(
             ams_id = global_tray_id // 4
             ams_id = global_tray_id // 4
             tray_id = global_tray_id % 4
             tray_id = global_tray_id % 4
 
 
+        logger.info(
+            "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
+            slot_id,
+            global_tray_id,
+            ams_id,
+            tray_id,
+            used_g,
+            tray_now_override,
+        )
+
         key = (ams_id, tray_id)
         key = (ams_id, tray_id)
         if key in handled_trays:
         if key in handled_trays:
             continue
             continue
@@ -352,6 +529,7 @@ async def _track_from_3mf(
         )
         )
         assignment = assign_result.scalar_one_or_none()
         assignment = assign_result.scalar_one_or_none()
         if not assignment:
         if not assignment:
+            logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
             continue
             continue
 
 
         # Load spool
         # Load spool
@@ -401,8 +579,8 @@ async def _track_from_3mf(
         # Determine mapping source for debug logging
         # Determine mapping source for debug logging
         if tray_now_override is not None:
         if tray_now_override is not None:
             map_src = ", tray_now"
             map_src = ", tray_now"
-        elif slot_to_tray:
-            map_src = ", queue_map"
+        elif mapping_source:
+            map_src = f", {mapping_source}_map"
         else:
         else:
             map_src = ""
             map_src = ""
         logger.info(
         logger.info(

+ 190 - 0
backend/app/services/virtual_printer/bind_server.py

@@ -0,0 +1,190 @@
+"""Bind/detect server for virtual printer discovery (port 3000).
+
+Bambu slicers (BambuStudio, OrcaSlicer) connect to port 3000 on a printer
+to perform the "bind with access code" handshake before using MQTT/FTP.
+
+Protocol:
+  - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
+  - Slicer sends: {"login":{"command":"detect","sequence_id":"20000"}}
+  - Printer replies: {"login":{"bind":"free","command":"detect","connect":"lan",
+      "dev_cap":1,"id":"<serial>","model":"<model>","name":"<name>",
+      "sequence_id":<int>,"version":"<firmware>"}}
+  - Connection closes after one exchange.
+"""
+
+import asyncio
+import json
+import logging
+import struct
+
+logger = logging.getLogger(__name__)
+
+BIND_PORT = 3000
+FRAME_HEADER = b"\xa5\xa5"
+FRAME_TRAILER = b"\xa7\xa7"
+HEADER_SIZE = 4  # 2 bytes magic + 2 bytes length
+TRAILER_SIZE = 2
+
+
+class BindServer:
+    """Responds to slicer bind/detect requests on port 3000.
+
+    In server mode, Bambuddy IS the printer — it responds with its own
+    identity so the slicer can discover and bind to it.
+    """
+
+    def __init__(
+        self,
+        serial: str,
+        model: str,
+        name: str,
+        version: str = "01.00.00.00",
+    ):
+        self.serial = serial
+        self.model = model
+        self.name = name
+        self.version = version
+
+        self._server: asyncio.Server | None = None
+        self._running = False
+
+    async def start(self) -> None:
+        """Start the bind server on port 3000."""
+        if self._running:
+            return
+
+        logger.info("Starting bind server on port %s (serial=%s, model=%s)", BIND_PORT, self.serial, self.model)
+
+        try:
+            self._running = True
+            self._server = await asyncio.start_server(
+                self._handle_client,
+                "0.0.0.0",  # nosec B104
+                BIND_PORT,
+            )
+
+            logger.info("Bind server listening on port %s", BIND_PORT)
+
+            async with self._server:
+                await self._server.serve_forever()
+
+        except OSError as e:
+            if e.errno == 98:
+                logger.error("Bind server port %s is already in use", BIND_PORT)
+            elif e.errno == 13:
+                logger.error("Bind server: cannot bind to port %s (permission denied)", BIND_PORT)
+            else:
+                logger.error("Bind server error: %s", e)
+        except asyncio.CancelledError:
+            logger.debug("Bind server task cancelled")
+        except Exception as e:
+            logger.error("Bind server error: %s", e)
+        finally:
+            await self.stop()
+
+    async def stop(self) -> None:
+        """Stop the bind server."""
+        logger.info("Stopping bind server")
+        self._running = False
+
+        if self._server:
+            try:
+                self._server.close()
+                await self._server.wait_closed()
+            except OSError as e:
+                logger.debug("Error closing bind server: %s", e)
+            self._server = None
+
+    async def _handle_client(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+    ) -> None:
+        """Handle a single bind/detect request from a slicer."""
+        peername = writer.get_extra_info("peername")
+        client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+        logger.info("Bind server: client connected from %s", client_id)
+
+        try:
+            # Read the framed message (timeout after 10s)
+            data = await asyncio.wait_for(reader.read(4096), timeout=10.0)
+            if not data:
+                return
+
+            # Parse the request
+            request = self._parse_frame(data)
+            if request is None:
+                logger.warning("Bind server: invalid frame from %s", client_id)
+                return
+
+            logger.info("Bind server: received from %s: %s", client_id, request)
+
+            # Check if this is a detect command
+            login = request.get("login", {})
+            if not isinstance(login, dict) or login.get("command") != "detect":
+                logger.warning("Bind server: unexpected command from %s: %s", client_id, request)
+                return
+
+            # Build response
+            response = {
+                "login": {
+                    "bind": "free",
+                    "command": "detect",
+                    "connect": "lan",
+                    "dev_cap": 1,
+                    "id": self.serial,
+                    "model": self.model,
+                    "name": self.name,
+                    "sequence_id": 3021,
+                    "version": self.version,
+                }
+            }
+
+            frame = self._build_frame(response)
+            writer.write(frame)
+            await writer.drain()
+
+            logger.info("Bind server: sent detect response to %s (serial=%s)", client_id, self.serial)
+
+        except TimeoutError:
+            logger.debug("Bind server: timeout waiting for data from %s", client_id)
+        except Exception as e:
+            logger.error("Bind server: error handling %s: %s", client_id, e)
+        finally:
+            try:
+                writer.close()
+                await writer.wait_closed()
+            except OSError:
+                pass
+            logger.debug("Bind server: client %s disconnected", client_id)
+
+    def _parse_frame(self, data: bytes) -> dict | None:
+        """Parse a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
+        if len(data) < HEADER_SIZE + TRAILER_SIZE:
+            return None
+
+        if data[:2] != FRAME_HEADER:
+            return None
+
+        if data[-2:] != FRAME_TRAILER:
+            return None
+
+        # Length field is total message size (header + json + trailer)
+        total_len = struct.unpack_from("<H", data, 2)[0]
+        if total_len != len(data):
+            logger.debug("Bind frame length mismatch: header says %d, got %d", total_len, len(data))
+
+        # JSON payload is between header and trailer
+        json_bytes = data[HEADER_SIZE:-TRAILER_SIZE]
+        try:
+            return json.loads(json_bytes)
+        except (json.JSONDecodeError, UnicodeDecodeError) as e:
+            logger.warning("Bind server: failed to parse JSON: %s", e)
+            return None
+
+    def _build_frame(self, payload: dict) -> bytes:
+        """Build a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
+        json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
+        total_len = HEADER_SIZE + len(json_bytes) + TRAILER_SIZE
+        header = FRAME_HEADER + struct.pack("<H", total_len)
+        return header + json_bytes + FRAME_TRAILER

+ 17 - 1
backend/app/services/virtual_printer/manager.py

@@ -14,6 +14,7 @@ from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
+from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
@@ -100,6 +101,7 @@ class VirtualPrinterManager:
         self._ssdp_proxy: SSDPProxy | None = None
         self._ssdp_proxy: SSDPProxy | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
+        self._bind: BindServer | None = None  # For server mode (bind/detect on port 3000)
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
 
 
         # Background tasks
         # Background tasks
@@ -364,11 +366,13 @@ class VirtualPrinterManager:
         )
         )
 
 
         logger.info(
         logger.info(
-            "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d",
+            "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d, Bind %s:%d",
             self._target_printer_ip,
             self._target_printer_ip,
             SlicerProxyManager.PRINTER_FTP_PORT,
             SlicerProxyManager.PRINTER_FTP_PORT,
             self._target_printer_ip,
             self._target_printer_ip,
             SlicerProxyManager.PRINTER_MQTT_PORT,
             SlicerProxyManager.PRINTER_MQTT_PORT,
+            self._target_printer_ip,
+            SlicerProxyManager.PRINTER_BIND_PORT,
         )
         )
 
 
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
@@ -429,6 +433,13 @@ class VirtualPrinterManager:
             on_print_command=self._on_print_command,
             on_print_command=self._on_print_command,
         )
         )
 
 
+        # Bind server responds to slicer detect/bind requests on port 3000
+        self._bind = BindServer(
+            serial=self.printer_serial,
+            model=self._model,
+            name=self.PRINTER_NAME,
+        )
+
         # Start services as background tasks
         # Start services as background tasks
         # Wrap each in error handler so one failure doesn't stop others
         # Wrap each in error handler so one failure doesn't stop others
         async def run_with_logging(coro, name):
         async def run_with_logging(coro, name):
@@ -441,6 +452,7 @@ class VirtualPrinterManager:
             asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
             asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
             asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
             asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
             asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
             asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
+            asyncio.create_task(run_with_logging(self._bind.start(), "Bind"), name="virtual_printer_bind"),
         ]
         ]
 
 
         logger.info("Virtual printer '%s' started (serial: %s)", self.PRINTER_NAME, self.printer_serial)
         logger.info("Virtual printer '%s' started (serial: %s)", self.PRINTER_NAME, self.printer_serial)
@@ -466,6 +478,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             await self._ssdp.stop()
             self._ssdp = None
             self._ssdp = None
 
 
+        if self._bind:
+            await self._bind.stop()
+            self._bind = None
+
         if self._ssdp_proxy:
         if self._ssdp_proxy:
             await self._ssdp_proxy.stop()
             await self._ssdp_proxy.stop()
             self._ssdp_proxy = None
             self._ssdp_proxy = None

+ 219 - 0
backend/app/services/virtual_printer/tcp_proxy.py

@@ -340,6 +340,202 @@ class TLSProxy:
         logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
         logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
 
 
 
 
+class TCPProxy:
+    """Raw TCP proxy that forwards data without TLS termination.
+
+    Used for protocols where the printer doesn't use TLS (e.g., port 3000
+    binding/authentication protocol).
+    """
+
+    def __init__(
+        self,
+        name: str,
+        listen_port: int,
+        target_host: str,
+        target_port: int,
+        on_connect: Callable[[str], None] | None = None,
+        on_disconnect: Callable[[str], None] | None = None,
+    ):
+        self.name = name
+        self.listen_port = listen_port
+        self.target_host = target_host
+        self.target_port = target_port
+        self.on_connect = on_connect
+        self.on_disconnect = on_disconnect
+
+        self._server: asyncio.Server | None = None
+        self._running = False
+        self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
+
+    async def start(self) -> None:
+        """Start the TCP proxy server."""
+        if self._running:
+            return
+
+        logger.info(
+            "Starting %s TCP proxy: 0.0.0.0:%s → %s:%s",
+            self.name,
+            self.listen_port,
+            self.target_host,
+            self.target_port,
+        )
+
+        try:
+            self._running = True
+
+            self._server = await asyncio.start_server(
+                self._handle_client,
+                "0.0.0.0",  # nosec B104
+                self.listen_port,
+            )
+
+            logger.info("%s TCP proxy listening on port %s", self.name, self.listen_port)
+
+            async with self._server:
+                await self._server.serve_forever()
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
+            else:
+                logger.error("%s proxy error: %s", self.name, e)
+        except asyncio.CancelledError:
+            logger.debug("%s proxy task cancelled", self.name)
+        except Exception as e:
+            logger.error("%s proxy error: %s", self.name, e)
+        finally:
+            await self.stop()
+
+    async def stop(self) -> None:
+        """Stop the TCP proxy server."""
+        logger.info("Stopping %s proxy", self.name)
+        self._running = False
+
+        for client_id, (task1, task2) in list(self._active_connections.items()):
+            task1.cancel()
+            task2.cancel()
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass
+
+        self._active_connections.clear()
+
+        if self._server:
+            try:
+                self._server.close()
+                await self._server.wait_closed()
+            except OSError as e:
+                logger.debug("Error closing %s proxy server: %s", self.name, e)
+            self._server = None
+
+    async def _handle_client(
+        self,
+        client_reader: asyncio.StreamReader,
+        client_writer: asyncio.StreamWriter,
+    ) -> None:
+        """Handle a new client connection by proxying to target."""
+        peername = client_writer.get_extra_info("peername")
+        client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+
+        logger.info("%s proxy: client connected from %s", self.name, client_id)
+
+        if self.on_connect:
+            try:
+                self.on_connect(client_id)
+            except Exception:
+                pass
+
+        try:
+            printer_reader, printer_writer = await asyncio.wait_for(
+                asyncio.open_connection(self.target_host, self.target_port),
+                timeout=10.0,
+            )
+            logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
+        except TimeoutError:
+            logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except OSError as e:
+            logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+
+        client_to_printer = asyncio.create_task(
+            self._forward(client_reader, printer_writer, f"{client_id}→printer"),
+            name=f"{self.name}_c2p_{client_id}",
+        )
+        printer_to_client = asyncio.create_task(
+            self._forward(printer_reader, client_writer, f"printer→{client_id}"),
+            name=f"{self.name}_p2c_{client_id}",
+        )
+
+        self._active_connections[client_id] = (client_to_printer, printer_to_client)
+
+        try:
+            done, pending = await asyncio.wait(
+                [client_to_printer, printer_to_client],
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+            for task in pending:
+                task.cancel()
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
+
+        except Exception as e:
+            logger.debug("%s proxy connection error: %s", self.name, e)
+        finally:
+            self._active_connections.pop(client_id, None)
+
+            for writer in [client_writer, printer_writer]:
+                try:
+                    writer.close()
+                    await writer.wait_closed()
+                except OSError:
+                    pass
+
+            logger.info("%s proxy: client %s disconnected", self.name, client_id)
+
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass
+
+    async def _forward(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        direction: str,
+    ) -> None:
+        """Forward data from reader to writer."""
+        total_bytes = 0
+        try:
+            while self._running:
+                data = await reader.read(65536)
+                if not data:
+                    break
+                writer.write(data)
+                await writer.drain()
+                total_bytes += len(data)
+                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
+        except asyncio.CancelledError:
+            pass
+        except ConnectionResetError:
+            logger.debug("%s proxy %s: connection reset", self.name, direction)
+        except BrokenPipeError:
+            logger.debug("%s proxy %s: broken pipe", self.name, direction)
+        except OSError as e:
+            logger.debug("%s proxy %s error: %s", self.name, direction, e)
+
+        logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
+
+
 class FTPTLSProxy(TLSProxy):
 class FTPTLSProxy(TLSProxy):
     """FTP-aware TLS proxy that handles passive data connections.
     """FTP-aware TLS proxy that handles passive data connections.
 
 
@@ -843,11 +1039,13 @@ class SlicerProxyManager:
     # Bambu printer ports
     # Bambu printer ports
     PRINTER_FTP_PORT = 990
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
     PRINTER_MQTT_PORT = 8883
+    PRINTER_BIND_PORT = 3000
 
 
     # Local listen ports - must match what Bambu Studio expects
     # Local listen ports - must match what Bambu Studio expects
     # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
     # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
     LOCAL_FTP_PORT = 990
     LOCAL_FTP_PORT = 990
     LOCAL_MQTT_PORT = 8883
     LOCAL_MQTT_PORT = 8883
+    LOCAL_BIND_PORT = 3000
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -871,6 +1069,7 @@ class SlicerProxyManager:
 
 
         self._ftp_proxy: TLSProxy | None = None
         self._ftp_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
+        self._bind_proxy: TCPProxy | None = None
         self._tasks: list[asyncio.Task] = []
         self._tasks: list[asyncio.Task] = []
 
 
     async def start(self) -> None:
     async def start(self) -> None:
@@ -914,6 +1113,16 @@ class SlicerProxyManager:
             on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
         )
         )
 
 
+        # Bind/auth proxy (port 3000) - raw TCP, no TLS
+        self._bind_proxy = TCPProxy(
+            name="Bind",
+            listen_port=self.LOCAL_BIND_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_BIND_PORT,
+            on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+        )
+
         # Start as background tasks
         # Start as background tasks
         async def run_with_logging(proxy: TLSProxy) -> None:
         async def run_with_logging(proxy: TLSProxy) -> None:
             try:
             try:
@@ -930,6 +1139,10 @@ class SlicerProxyManager:
                 run_with_logging(self._mqtt_proxy),
                 run_with_logging(self._mqtt_proxy),
                 name="slicer_proxy_mqtt",
                 name="slicer_proxy_mqtt",
             ),
             ),
+            asyncio.create_task(
+                run_with_logging(self._bind_proxy),
+                name="slicer_proxy_bind",
+            ),
         ]
         ]
 
 
         logger.info("Slicer TLS proxy started for %s", self.target_host)
         logger.info("Slicer TLS proxy started for %s", self.target_host)
@@ -954,6 +1167,10 @@ class SlicerProxyManager:
             await self._mqtt_proxy.stop()
             await self._mqtt_proxy.stop()
             self._mqtt_proxy = None
             self._mqtt_proxy = None
 
 
+        if self._bind_proxy:
+            await self._bind_proxy.stop()
+            self._bind_proxy = None
+
         # Cancel tasks
         # Cancel tasks
         for task in self._tasks:
         for task in self._tasks:
             task.cancel()
             task.cancel()
@@ -990,6 +1207,8 @@ class SlicerProxyManager:
             "target_host": self.target_host,
             "target_host": self.target_host,
             "ftp_port": self.LOCAL_FTP_PORT,
             "ftp_port": self.LOCAL_FTP_PORT,
             "mqtt_port": self.LOCAL_MQTT_PORT,
             "mqtt_port": self.LOCAL_MQTT_PORT,
+            "bind_port": self.LOCAL_BIND_PORT,
             "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
             "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
             "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
             "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
+            "bind_connections": (len(self._bind_proxy._active_connections) if self._bind_proxy else 0),
         }
         }

+ 24 - 0
backend/app/utils/color_utils.py

@@ -0,0 +1,24 @@
+"""Color comparison utilities for RFID/firmware color matching."""
+
+
+def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
+    """Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations.
+
+    Uses Euclidean RGB distance. Alpha channel (bytes 7-8) is ignored.
+    Default threshold of 50 accommodates typical RFID read variations
+    (e.g. 7CC4D5 vs 56B7E6 = distance ~43.6) while rejecting clearly
+    different colors (e.g. red vs blue = distance ~360).
+    """
+    a = hex_a.strip().upper()
+    b = hex_b.strip().upper()
+    if a == b:
+        return True
+    if len(a) < 6 or len(b) < 6:
+        return False
+    try:
+        ra, ga, ba = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)
+        rb, gb, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)
+    except ValueError:
+        return False
+    dist = ((ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2) ** 0.5
+    return dist <= threshold

+ 91 - 39
backend/app/utils/threemf_tools.py

@@ -265,15 +265,18 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
 
 
 
 
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
-    """Extract per-slot nozzle/extruder mapping from a 3MF file's project settings.
+    """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
 
     On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
     On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
-    specific nozzle. This reads the slicer's nozzle assignment from
-    Metadata/project_settings.config.
+    specific nozzle. The slicer may override user preferences when using "Auto For
+    Flush" mode, so the actual assignment comes from slice_info.config group_id
+    attributes, not from the user's filament_nozzle_map preference.
 
 
-    Translation chain:
-        filament_nozzle_map[slot_id - 1] -> slicer extruder index
-        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+    Priority:
+        1. group_id on <filament> elements in slice_info.config (actual assignment)
+        2. filament_nozzle_map in project_settings.config (user preference fallback)
+
+    Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
 
 
     Args:
     Args:
         zf: An open ZipFile of the 3MF archive
         zf: An open ZipFile of the 3MF archive
@@ -289,38 +292,53 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         content = zf.read("Metadata/project_settings.config").decode()
         content = zf.read("Metadata/project_settings.config").decode()
         data = json.loads(content)
         data = json.loads(content)
 
 
-        filament_nozzle_map = data.get("filament_nozzle_map")
         physical_extruder_map = data.get("physical_extruder_map")
         physical_extruder_map = data.get("physical_extruder_map")
+        if not physical_extruder_map or len(physical_extruder_map) <= 1:
+            return None  # Single-nozzle printer
 
 
-        if not filament_nozzle_map or not physical_extruder_map:
+        # Priority 1: Use group_id from slice_info filament elements.
+        # This reflects the actual slicer assignment (respects "Auto For Flush").
+        nozzle_mapping: dict[int, int] = {}
+        if "Metadata/slice_info.config" in zf.namelist():
+            si_content = zf.read("Metadata/slice_info.config").decode()
+            si_root = ET.fromstring(si_content)
+            for filament_elem in si_root.findall(".//filament"):
+                group_id_str = filament_elem.get("group_id")
+                filament_id_str = filament_elem.get("id")
+                if group_id_str is not None and filament_id_str:
+                    try:
+                        group_id = int(group_id_str)
+                        slot_id = int(filament_id_str)
+                        if group_id < len(physical_extruder_map):
+                            nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
+                    except (ValueError, TypeError, IndexError):
+                        pass
+
+        if nozzle_mapping:
+            return nozzle_mapping
+
+        # Priority 2: Fall back to filament_nozzle_map (user preference).
+        # This is correct when the user manually assigned nozzles, but may be
+        # wrong when the slicer overrides via "Auto For Flush".
+        filament_nozzle_map = data.get("filament_nozzle_map")
+        if not filament_nozzle_map:
             return None
             return None
 
 
-        # Build slot_id (1-based) -> extruder_id mapping
-        nozzle_mapping: dict[int, int] = {}
         for i, slicer_ext_str in enumerate(filament_nozzle_map):
         for i, slicer_ext_str in enumerate(filament_nozzle_map):
             slot_id = i + 1
             slot_id = i + 1
             try:
             try:
                 slicer_ext = int(slicer_ext_str)
                 slicer_ext = int(slicer_ext_str)
                 if slicer_ext < len(physical_extruder_map):
                 if slicer_ext < len(physical_extruder_map):
-                    extruder_id = int(physical_extruder_map[slicer_ext])
-                    nozzle_mapping[slot_id] = extruder_id
+                    nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
             except (ValueError, TypeError, IndexError):
             except (ValueError, TypeError, IndexError):
-                pass  # Skip slots with unparseable nozzle mapping
-
-        if not nozzle_mapping:
-            return None
+                pass
 
 
-        # If all slots map to the same extruder, this is a single-nozzle printer
-        unique_extruders = set(nozzle_mapping.values())
-        if len(unique_extruders) <= 1:
-            return None
-
-        return nozzle_mapping
+        return nozzle_mapping if nozzle_mapping else None
     except Exception:
     except Exception:
         return None
         return None
 
 
 
 
-def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
+def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
     """Extract per-filament total usage from 3MF slice_info.config.
 
 
     This extracts the slicer-estimated total usage per filament slot,
     This extracts the slicer-estimated total usage per filament slot,
@@ -328,6 +346,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
 
 
     Args:
     Args:
         file_path: Path to the 3MF file
         file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
 
 
     Returns:
     Returns:
         List of filament usage dictionaries:
         List of filament usage dictionaries:
@@ -342,22 +361,55 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
             content = zf.read("Metadata/slice_info.config").decode()
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
             root = ET.fromstring(content)
 
 
-            for f in root.findall(".//filament"):
-                filament_id = f.get("id")
-                used_g = f.get("used_g", "0")
-                try:
-                    used_amount = float(used_g)
-                    if filament_id:
-                        filament_usage.append(
-                            {
-                                "slot_id": int(filament_id),
-                                "used_g": used_amount,
-                                "type": f.get("type", ""),
-                                "color": f.get("color", ""),
-                            }
-                        )
-                except (ValueError, TypeError):
-                    pass  # Skip filament entries with unparseable usage values
+            if plate_id is not None:
+                # Find the plate element with matching index
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for f in plate_elem.findall("filament"):
+                            filament_id = f.get("id")
+                            used_g = f.get("used_g", "0")
+                            try:
+                                used_amount = float(used_g)
+                                if filament_id:
+                                    filament_usage.append(
+                                        {
+                                            "slot_id": int(filament_id),
+                                            "used_g": used_amount,
+                                            "type": f.get("type", ""),
+                                            "color": f.get("color", ""),
+                                        }
+                                    )
+                            except (ValueError, TypeError):
+                                pass
+                        break
+            else:
+                # No plate_id specified - extract all filaments
+                for f in root.findall(".//filament"):
+                    filament_id = f.get("id")
+                    used_g = f.get("used_g", "0")
+                    try:
+                        used_amount = float(used_g)
+                        if filament_id:
+                            filament_usage.append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "used_g": used_amount,
+                                    "type": f.get("type", ""),
+                                    "color": f.get("color", ""),
+                                }
+                            )
+                    except (ValueError, TypeError):
+                        pass  # Skip filament entries with unparseable usage values
+
     except Exception:
     except Exception:
         pass  # Return whatever usage data was collected before the error
         pass  # Return whatever usage data was collected before the error
 
 

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

@@ -887,3 +887,65 @@ class TestChamberLightAPI:
 
 
             assert response.status_code == 500
             assert response.status_code == 500
             assert "failed" in response.json()["detail"].lower()
             assert "failed" in response.json()["detail"].lower()
+
+
+class TestClearHMSErrorsAPI:
+    """Integration tests for clear HMS errors endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/hms/clear")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful clear HMS errors request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.clear_hms_errors.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "cleared" in result["message"].lower()
+            mock_client.clear_hms_errors.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify error handling when clear HMS errors fails."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.clear_hms_errors.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()

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

@@ -858,3 +858,922 @@ class TestNozzleRackData:
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
         assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
         assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
         assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
         assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
+
+
+class TestRequestTopicFailSafe:
+    """Tests for graceful degradation when broker rejects request topic subscription."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_request_topic_supported_by_default(self, mqtt_client):
+        """Request topic subscription is attempted by default."""
+        assert mqtt_client._request_topic_supported is True
+        assert mqtt_client._request_topic_confirmed is False
+
+    def test_on_subscribe_confirms_success(self, mqtt_client):
+        """Successful SUBACK marks request topic as confirmed."""
+        from paho.mqtt.reasoncodes import ReasonCode
+
+        mqtt_client._request_topic_sub_mid = 42
+        rc = ReasonCode(9, identifier=0)  # SUBACK packetType=9, QoS 0 = success
+        mqtt_client._on_subscribe(None, None, 42, [rc], None)
+
+        assert mqtt_client._request_topic_confirmed is True
+        assert mqtt_client._request_topic_supported is True
+        assert mqtt_client._request_topic_sub_mid is None
+        assert mqtt_client._request_topic_sub_time == 0.0
+
+    def test_on_subscribe_detects_rejection(self, mqtt_client):
+        """SUBACK with failure code disables request topic."""
+        from paho.mqtt.reasoncodes import ReasonCode
+
+        mqtt_client._request_topic_sub_mid = 42
+        rc = ReasonCode(9, identifier=0x80)  # SUBACK packetType=9, 0x80 = failure
+        mqtt_client._on_subscribe(None, None, 42, [rc], None)
+
+        assert mqtt_client._request_topic_supported is False
+        assert mqtt_client._request_topic_confirmed is False
+
+    def test_on_subscribe_ignores_other_mids(self, mqtt_client):
+        """SUBACK for other subscriptions (e.g. report topic) is ignored."""
+        from paho.mqtt.reasoncodes import ReasonCode
+
+        mqtt_client._request_topic_sub_mid = 42
+        rc = ReasonCode(9, identifier=0x80)
+        mqtt_client._on_subscribe(None, None, 99, [rc], None)
+
+        # Not affected — mid doesn't match
+        assert mqtt_client._request_topic_supported is True
+
+    def test_disconnect_after_subscription_disables_topic(self, mqtt_client):
+        """Disconnect within 10s of subscription attempt disables request topic."""
+        import time
+
+        mqtt_client._request_topic_sub_time = time.time()
+        mqtt_client._request_topic_confirmed = False
+        mqtt_client._last_message_time = 0.0
+
+        mqtt_client._on_disconnect(None, None)
+
+        assert mqtt_client._request_topic_supported is False
+        assert mqtt_client._request_topic_sub_time == 0.0
+
+    def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):
+        """Disconnect after SUBACK confirmation keeps request topic enabled."""
+        import time
+
+        mqtt_client._request_topic_sub_time = time.time()
+        mqtt_client._request_topic_confirmed = True
+        mqtt_client._last_message_time = 0.0
+
+        mqtt_client._on_disconnect(None, None)
+
+        assert mqtt_client._request_topic_supported is True
+
+    def test_late_disconnect_does_not_disable(self, mqtt_client):
+        """Disconnect long after subscription (>10s) doesn't blame request topic."""
+        import time
+
+        mqtt_client._request_topic_sub_time = time.time() - 30.0
+        mqtt_client._request_topic_confirmed = False
+        mqtt_client._last_message_time = 0.0
+
+        mqtt_client._on_disconnect(None, None)
+
+        assert mqtt_client._request_topic_supported is True
+
+    def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):
+        """After marking unsupported, reconnect skips request topic subscription."""
+        mqtt_client._request_topic_supported = False
+
+        subscribe_calls = []
+        mock_client = type(
+            "MockClient",
+            (),
+            {
+                "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
+            },
+        )()
+
+        mqtt_client._on_connect(mock_client, None, None, 0)
+
+        # Only report topic subscribed, not request topic
+        assert len(subscribe_calls) == 1
+        assert subscribe_calls[0] == mqtt_client.topic_subscribe
+
+
+class TestRequestTopicAmsMapping:
+    """Tests for capturing ams_mapping from the MQTT request topic."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):
+        """Verify _captured_ams_mapping starts as None."""
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_captures_ams_mapping(self, mqtt_client):
+        """project_file command with ams_mapping stores the mapping."""
+        data = {
+            "print": {
+                "command": "project_file",
+                "ams_mapping": [0, 4, -1, -1],
+                "url": "ftp://192.168.1.100/test.3mf",
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
+
+    def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):
+        """Non-project_file commands don't store ams_mapping."""
+        data = {
+            "print": {
+                "command": "pause",
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):
+        """project_file command without ams_mapping doesn't store anything."""
+        data = {
+            "print": {
+                "command": "project_file",
+                "url": "ftp://192.168.1.100/test.3mf",
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):
+        """Non-dict print value is safely ignored."""
+        data = {"print": "not_a_dict"}
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_handle_request_message_ignores_missing_print(self, mqtt_client):
+        """Message without print key is safely ignored."""
+        data = {"pushing": {"command": "pushall"}}
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_captured_mapping_overwrites_previous(self, mqtt_client):
+        """A new print command overwrites a previously captured mapping."""
+        mqtt_client._captured_ams_mapping = [0, -1, -1, -1]
+        data = {
+            "print": {
+                "command": "project_file",
+                "ams_mapping": [4, 8, -1, -1],
+            }
+        }
+        mqtt_client._handle_request_message(data)
+        assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]
+
+    def test_print_start_callback_includes_ams_mapping(self, mqtt_client):
+        """on_print_start callback data includes captured ams_mapping."""
+        start_data = {}
+
+        def on_start(data):
+            start_data.update(data)
+
+        mqtt_client.on_print_start = on_start
+        mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
+
+        # Trigger print start
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert start_data.get("ams_mapping") == [0, 4, -1, -1]
+
+    def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):
+        """on_print_start callback has ams_mapping=None when no mapping captured."""
+        start_data = {}
+
+        def on_start(data):
+            start_data.update(data)
+
+        mqtt_client.on_print_start = on_start
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert "ams_mapping" in start_data
+        assert start_data["ams_mapping"] is None
+
+    def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
+        """on_print_complete callback data includes captured ams_mapping."""
+        complete_data = {}
+
+        def on_complete(data):
+            complete_data.update(data)
+
+        mqtt_client.on_print_start = lambda d: None
+        mqtt_client.on_print_complete = on_complete
+        mqtt_client._captured_ams_mapping = [0, 9, -1, -1]
+
+        # Start print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        # Complete print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert complete_data.get("ams_mapping") == [0, 9, -1, -1]
+
+    def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):
+        """_captured_ams_mapping is reset to None after print completion."""
+        mqtt_client.on_print_start = lambda d: None
+        mqtt_client.on_print_complete = lambda d: None
+        mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
+
+        # Start print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        # Complete print
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
+            }
+        )
+
+        assert mqtt_client._captured_ams_mapping is None
+
+    def test_full_flow_capture_and_deliver(self, mqtt_client):
+        """Full flow: slicer sends print command → MQTT captures mapping → completion delivers it."""
+        complete_data = {}
+
+        def on_complete(data):
+            complete_data.update(data)
+
+        mqtt_client.on_print_start = lambda d: None
+        mqtt_client.on_print_complete = on_complete
+
+        # 1. Slicer sends print command (captured from request topic)
+        mqtt_client._handle_request_message(
+            {
+                "print": {
+                    "command": "project_file",
+                    "ams_mapping": [4, 9, -1, -1],
+                    "url": "ftp://192.168.1.100/model.3mf",
+                }
+            }
+        )
+        assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]
+
+        # 2. Printer reports RUNNING
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/model.gcode",
+                    "subtask_name": "Model",
+                }
+            }
+        )
+
+        # 3. Printer reports FINISH
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/model.gcode",
+                    "subtask_name": "Model",
+                }
+            }
+        )
+
+        assert complete_data["ams_mapping"] == [4, 9, -1, -1]
+        assert complete_data["status"] == "completed"
+        # Mapping cleared after completion
+        assert mqtt_client._captured_ams_mapping is None
+
+
+# ---------------------------------------------------------------------------
+# tray_now disambiguation helpers
+# ---------------------------------------------------------------------------
+
+
+def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None):
+    """Build minimal print.ams payload for tray_now disambiguation tests."""
+    ams = {"tray_now": str(tray_now)}
+    if ams_units is not None:
+        ams["ams"] = ams_units
+    if tray_exist_bits is not None:
+        ams["tray_exist_bits"] = tray_exist_bits
+    return {"print": {"ams": ams}}
+
+
+def _extruder_info_payload(extruders):
+    """Build device.extruder.info payload (dual-nozzle detection + snow).
+
+    Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
+    """
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "info": extruders,
+                }
+            }
+        }
+    }
+
+
+def _extruder_state_payload(state_val):
+    """Build device.extruder.state payload (active extruder via bit 8)."""
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "state": state_val,
+                }
+            }
+        }
+    }
+
+
+# ---------------------------------------------------------------------------
+# 1. Single-nozzle X1E — direct passthrough
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleX1E:
+    """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_X1E",
+            access_code="12345678",
+        )
+
+    def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
+        """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
+        for slot in range(4):
+            mqtt_client._process_message(_ams_payload(slot))
+            assert mqtt_client.state.tray_now == slot
+
+    def test_tray_now_255_means_unloaded(self, mqtt_client):
+        """tray_now=255 means no filament loaded."""
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+
+    def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
+        """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
+        mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
+        assert mqtt_client._is_dual_nozzle is False
+
+    def test_last_loaded_tray_survives_unload(self, mqtt_client):
+        """Load tray 2, unload → last_loaded_tray stays 2."""
+        mqtt_client._process_message(_ams_payload(2))
+        assert mqtt_client.state.last_loaded_tray == 2
+
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+        assert mqtt_client.state.last_loaded_tray == 2
+
+
+# ---------------------------------------------------------------------------
+# 2. Single-nozzle P2S — multiple AMS, global IDs pass through
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleP2S:
+    """Single-nozzle, 2 AMS — global IDs 4-7 for AMS 1 pass through directly."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_P2S",
+            access_code="12345678",
+        )
+
+    def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
+        """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
+        for global_id in range(4, 8):
+            mqtt_client._process_message(_ams_payload(global_id))
+            assert mqtt_client.state.tray_now == global_id
+
+    def test_tray_change_across_ams_units(self, mqtt_client):
+        """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
+        mqtt_client._process_message(_ams_payload(1))
+        assert mqtt_client.state.tray_now == 1
+
+        mqtt_client._process_message(_ams_payload(6))
+        assert mqtt_client.state.tray_now == 6
+
+
+# ---------------------------------------------------------------------------
+# 3. H2D Pro — initial state detection
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSetup:
+    """H2D Pro initial state detection."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
+        """2 entries in device.extruder.info → _is_dual_nozzle=True."""
+        mqtt_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert mqtt_client._is_dual_nozzle is True
+
+    def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
+        """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
+        ams_units = [
+            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
+        # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
+        """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
+
+        If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
+        """
+        payload = {
+            "print": {
+                "device": {
+                    "extruder": {
+                        "info": [
+                            {"id": 0, "snow": 0xFF00FF},
+                            {"id": 1, "snow": 0xFF00FF},
+                        ],
+                        "state": 0x0001,
+                    }
+                },
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                    ],
+                    "tray_now": "2",
+                    "tray_exist_bits": "f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
+        # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
+        # Single AMS on extruder 0 → global_id = 0*4+2 = 2
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# Shared H2D fixture for classes 4-8
+# ---------------------------------------------------------------------------
+
+
+class _H2DFixtureMixin:
+    """Mixin providing a pre-configured H2D Pro client."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    @pytest.fixture
+    def h2d_client(self, mqtt_client):
+        """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "device": {
+                        "extruder": {
+                            "info": [
+                                {"id": 0, "snow": 0xFF00FF},
+                                {"id": 1, "snow": 0xFF00FF},
+                            ],
+                            "state": 0x0001,  # right extruder active
+                        }
+                    },
+                    "ams": {
+                        "ams": [
+                            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+                        ],
+                        "tray_now": "255",
+                        "tray_exist_bits": "1000f",
+                    },
+                }
+            }
+        )
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+        return mqtt_client
+
+
+# ---------------------------------------------------------------------------
+# 4. H2D Snow field disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
+    """Snow field disambiguation (primary path)."""
+
+    def test_snow_disambiguates_ams0_slot(self, h2d_client):
+        """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
+        # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
+        # so we need it in a prior message).
+        snow_val = 0 << 8 | 2  # AMS 0 slot 2 = raw 2
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 2
+
+        # Now send tray_now=2
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
+        """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
+        # Snow: extruder 1 → AMS 128 slot 0
+        snow_val = 128 << 8 | 0  # = 32768
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": snow_val},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(1) == 128
+
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        # tray_now="0" with left extruder active, snow says AMS HT (128)
+        # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
+        """Verify state.h2d_extruder_snow dict is populated correctly."""
+        snow_ext0 = 1 << 8 | 3  # AMS 1 slot 3 → global 7
+        snow_ext1 = 0 << 8 | 0  # AMS 0 slot 0 → global 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_ext0},
+                    {"id": 1, "snow": snow_ext1},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 7
+        assert h2d_client.state.h2d_extruder_snow[1] == 0
+
+    def test_snow_unloaded_value(self, h2d_client):
+        """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFFFF},
+                    {"id": 1, "snow": 0xFFFF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 255
+        assert h2d_client.state.h2d_extruder_snow[1] == 255
+
+    def test_snow_initial_sentinel_not_stored(self, h2d_client):
+        """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
+        # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Snow dict should remain empty (no matching branch)
+        assert h2d_client.state.h2d_extruder_snow == {}
+
+
+# ---------------------------------------------------------------------------
+# 5. H2D Pending target disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
+    """Pending target disambiguation (when Bambuddy initiates load)."""
+
+    def test_pending_target_matches_slot(self, h2d_client):
+        """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+        assert h2d_client.state.pending_tray_target is None  # cleared
+
+    def test_pending_target_slot_mismatch(self, h2d_client):
+        """pending=5, tray_now='2' → uses raw slot, clears pending."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(2))
+        # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
+        assert h2d_client.state.tray_now == 2
+        assert h2d_client.state.pending_tray_target is None
+
+    def test_pending_target_takes_priority_over_snow(self, h2d_client):
+        """When both pending and snow are set, pending wins."""
+        # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
+        snow_val = 0 << 8 | 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 1
+
+        # Set pending target to AMS 1 slot 1 (global 5)
+        h2d_client.state.pending_tray_target = 5
+        # tray_now="1" — matches pending (5%4=1), pending should win over snow
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+
+# ---------------------------------------------------------------------------
+# 6. H2D ams_extruder_map fallback
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
+    """ams_extruder_map fallback (no pending, no snow)."""
+
+    def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
+        """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
+        # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
+        h2d_client._process_message(_ams_payload(2))
+        # AMS 0 is the only AMS on extruder 0 (right, active by default)
+        # Fallback: single AMS → global = 0*4+2 = 2
+        assert h2d_client.state.tray_now == 2
+
+    def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
+        """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
+        # Set up: two AMS units on the same extruder (right, ext 0)
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
+        # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
+        h2d_client.state.tray_now = 5
+        # tray_now="1" → 5%4=1 matches → keep current=5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+    def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
+        """No AMS mapped to the active extruder → raw slot as global ID."""
+        # All AMS on left extruder, but right is active
+        h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# 7. H2D Active extruder switching
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
+    """Active extruder switching via device.extruder.state bit 8."""
+
+    def test_active_extruder_right_by_default(self, h2d_client):
+        """Initial state.active_extruder == 0 (right)."""
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_state_bit8_switches_to_left(self, h2d_client):
+        """state=0x100 → active_extruder=1 (left)."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+    def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
+        """Cycle 0 → 1 → 0."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
+        """Snow on both extruders; switching active changes which snow is used."""
+        # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},  # AMS 0 slot 1 → global 1
+                    {"id": 1, "snow": 128 << 8 | 0},  # AMS HT → global 128
+                ]
+            )
+        )
+
+        # Right active (default) — tray_now="1" → snow ext[0] says global 1
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+
+        # Switch to left
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+
+# ---------------------------------------------------------------------------
+# 8. H2D Full multi-message sequences
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
+    """Multi-message sequences simulating real H2D Pro prints."""
+
+    def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
+        """Setup → load AMS 0 slot 1 → verify tray_now=1."""
+        # Snow update: extruder 0 loading AMS 0 slot 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Printer reports tray_now="1"
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+        assert h2d_client.state.last_loaded_tray == 1
+
+    def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
+        """Setup → switch left → load AMS HT → verify tray_now=128."""
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Snow: ext 1 → AMS HT slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+
+        # Printer reports tray_now="0" (AMS HT single slot)
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+        assert h2d_client.state.last_loaded_tray == 128
+
+    def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
+        """Multi-color print alternating between right and left nozzles.
+
+        Sequence:
+        1. Right loads AMS 0 slot 0 (tray=0)
+        2. Switch left, load AMS HT (tray=128)
+        3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
+        4. Unload (255)
+        """
+        # Step 1: Right extruder loads AMS 0 slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 0
+
+        # Step 2: Switch to left, load AMS HT
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+        # Step 3: Switch back to right, load AMS 0 slot 2
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 2},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+        # Step 4: Unload
+        h2d_client._process_message(_ams_payload(255))
+        assert h2d_client.state.tray_now == 255
+        assert h2d_client.state.last_loaded_tray == 2

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

@@ -509,6 +509,126 @@ class TestCertificateService:
         assert key_path.exists()
         assert key_path.exists()
 
 
 
 
+class TestBindServer:
+    """Tests for BindServer (port 3000 bind/detect protocol)."""
+
+    @pytest.fixture
+    def bind_server(self):
+        """Create a BindServer instance."""
+        from backend.app.services.virtual_printer.bind_server import BindServer
+
+        return BindServer(
+            serial="09400A391800001",
+            model="O1D",
+            name="Bambuddy",
+        )
+
+    def test_build_frame(self, bind_server):
+        """Verify frame building produces correct format."""
+        payload = {"login": {"command": "detect"}}
+        frame = bind_server._build_frame(payload)
+
+        # Header: 0xA5A5
+        assert frame[:2] == b"\xa5\xa5"
+        # Trailer: 0xA7A7
+        assert frame[-2:] == b"\xa7\xa7"
+        # Length field is total message size (LE uint16)
+        import struct
+
+        total_len = struct.unpack_from("<H", frame, 2)[0]
+        assert total_len == len(frame)
+        # JSON payload is between header and trailer
+        import json
+
+        json_bytes = frame[4:-2]
+        parsed = json.loads(json_bytes)
+        assert parsed == payload
+
+    def test_parse_frame_valid(self, bind_server):
+        """Verify valid frame parsing extracts JSON correctly."""
+        import json
+        import struct
+
+        payload = {"login": {"command": "detect", "sequence_id": "20000"}}
+        json_bytes = json.dumps(payload, separators=(",", ":")).encode()
+        total_len = 4 + len(json_bytes) + 2
+        frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
+
+        result = bind_server._parse_frame(frame)
+
+        assert result is not None
+        assert result["login"]["command"] == "detect"
+        assert result["login"]["sequence_id"] == "20000"
+
+    def test_parse_frame_invalid_header(self, bind_server):
+        """Verify invalid header returns None."""
+        result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
+        assert result is None
+
+    def test_parse_frame_invalid_trailer(self, bind_server):
+        """Verify invalid trailer returns None."""
+        result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
+        assert result is None
+
+    def test_parse_frame_too_short(self, bind_server):
+        """Verify short data returns None."""
+        result = bind_server._parse_frame(b"\xa5\xa5\x00")
+        assert result is None
+
+    def test_parse_frame_invalid_json(self, bind_server):
+        """Verify invalid JSON returns None."""
+        import struct
+
+        bad_json = b"not json"
+        total_len = 4 + len(bad_json) + 2
+        frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
+        result = bind_server._parse_frame(frame)
+        assert result is None
+
+    def test_build_frame_roundtrip(self, bind_server):
+        """Verify build_frame output can be parsed back."""
+        payload = {
+            "login": {
+                "bind": "free",
+                "command": "detect",
+                "connect": "lan",
+                "dev_cap": 1,
+                "id": "09400A391800001",
+                "model": "O1D",
+                "name": "Bambuddy",
+                "sequence_id": 3021,
+                "version": "01.00.00.00",
+            }
+        }
+        frame = bind_server._build_frame(payload)
+        parsed = bind_server._parse_frame(frame)
+
+        assert parsed is not None
+        assert parsed["login"]["id"] == "09400A391800001"
+        assert parsed["login"]["model"] == "O1D"
+        assert parsed["login"]["name"] == "Bambuddy"
+        assert parsed["login"]["bind"] == "free"
+
+    def test_bind_server_stores_config(self, bind_server):
+        """Verify bind server stores serial, model, name."""
+        assert bind_server.serial == "09400A391800001"
+        assert bind_server.model == "O1D"
+        assert bind_server.name == "Bambuddy"
+        assert bind_server.version == "01.00.00.00"
+
+    def test_bind_server_custom_version(self):
+        """Verify custom firmware version is stored."""
+        from backend.app.services.virtual_printer.bind_server import BindServer
+
+        server = BindServer(
+            serial="TEST123",
+            model="C13",
+            name="Test",
+            version="02.03.04.05",
+        )
+        assert server.version == "02.03.04.05"
+
+
 class TestSlicerProxyManager:
 class TestSlicerProxyManager:
     """Tests for SlicerProxyManager (proxy mode)."""
     """Tests for SlicerProxyManager (proxy mode)."""
 
 
@@ -922,6 +1042,7 @@ class TestVirtualPrinterManagerServerModeIPOverride:
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer") as mock_ssdp_cls,
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer") as mock_ssdp_cls,
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch("backend.app.services.virtual_printer.manager.BindServer"),
             patch.object(manager._cert_service, "delete_printer_certificate"),
             patch.object(manager._cert_service, "delete_printer_certificate"),
             patch.object(
             patch.object(
                 manager._cert_service,
                 manager._cert_service,
@@ -951,6 +1072,7 @@ class TestVirtualPrinterManagerServerModeIPOverride:
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch("backend.app.services.virtual_printer.manager.BindServer"),
             patch.object(manager._cert_service, "delete_printer_certificate"),
             patch.object(manager._cert_service, "delete_printer_certificate"),
             patch.object(
             patch.object(
                 manager._cert_service,
                 manager._cert_service,
@@ -974,6 +1096,7 @@ class TestVirtualPrinterManagerServerModeIPOverride:
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch("backend.app.services.virtual_printer.manager.BindServer"),
             patch.object(manager._cert_service, "delete_printer_certificate"),
             patch.object(manager._cert_service, "delete_printer_certificate"),
             patch.object(
             patch.object(
                 manager._cert_service,
                 manager._cert_service,
@@ -984,3 +1107,129 @@ class TestVirtualPrinterManagerServerModeIPOverride:
             await manager._start_server_mode()
             await manager._start_server_mode()
 
 
             mock_gen_certs.assert_called_once_with(additional_ips=None)
             mock_gen_certs.assert_called_once_with(additional_ips=None)
+
+
+class TestBindServer:
+    """Tests for the BindServer (port 3000 bind/detect protocol)."""
+
+    @pytest.fixture
+    def bind_server(self):
+        """Create a BindServer instance."""
+        from backend.app.services.virtual_printer.bind_server import BindServer
+
+        return BindServer(
+            serial="01S00C000000001",
+            model="3DPrinter-X1-Carbon",
+            name="Bambuddy",
+        )
+
+    def test_build_frame(self, bind_server):
+        """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
+        payload = {"login": {"command": "detect"}}
+        frame = bind_server._build_frame(payload)
+
+        assert frame[:2] == b"\xa5\xa5"
+        assert frame[-2:] == b"\xa7\xa7"
+
+        # Length field is total message size
+        import struct
+
+        total_len = struct.unpack_from("<H", frame, 2)[0]
+        assert total_len == len(frame)
+
+        # JSON payload is between header and trailer
+        import json
+
+        json_bytes = frame[4:-2]
+        parsed = json.loads(json_bytes)
+        assert parsed == payload
+
+    def test_parse_frame_valid(self, bind_server):
+        """Verify valid frame parsing."""
+        frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
+        result = bind_server._parse_frame(frame)
+
+        assert result is not None
+        assert result["login"]["command"] == "detect"
+        assert result["login"]["sequence_id"] == "20000"
+
+    def test_parse_frame_invalid_header(self, bind_server):
+        """Verify invalid header returns None."""
+        frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
+        assert bind_server._parse_frame(frame) is None
+
+    def test_parse_frame_invalid_trailer(self, bind_server):
+        """Verify invalid trailer returns None."""
+        frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
+        assert bind_server._parse_frame(frame) is None
+
+    def test_parse_frame_too_short(self, bind_server):
+        """Verify short data returns None."""
+        assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
+        assert bind_server._parse_frame(b"") is None
+
+    def test_parse_frame_invalid_json(self, bind_server):
+        """Verify invalid JSON returns None."""
+        import struct
+
+        bad_json = b"not json"
+        total_len = 4 + len(bad_json) + 2
+        frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
+        assert bind_server._parse_frame(frame) is None
+
+    def test_build_frame_roundtrip(self, bind_server):
+        """Verify build then parse roundtrip."""
+        original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
+        frame = bind_server._build_frame(original)
+        parsed = bind_server._parse_frame(frame)
+        assert parsed == original
+
+    def test_bind_server_stores_config(self, bind_server):
+        """Verify config is stored correctly."""
+        assert bind_server.serial == "01S00C000000001"
+        assert bind_server.model == "3DPrinter-X1-Carbon"
+        assert bind_server.name == "Bambuddy"
+        assert bind_server.version == "01.00.00.00"
+
+    def test_bind_server_custom_version(self):
+        """Verify custom firmware version is stored."""
+        from backend.app.services.virtual_printer.bind_server import BindServer
+
+        server = BindServer(
+            serial="01S00C000000001",
+            model="3DPrinter-X1-Carbon",
+            name="Bambuddy",
+            version="01.09.00.10",
+        )
+        assert server.version == "01.09.00.10"
+
+    @pytest.mark.asyncio
+    async def test_server_mode_creates_bind_server(self):
+        """Verify _start_server_mode creates BindServer with correct params."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        manager = VirtualPrinterManager()
+        manager._mode = "immediate"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = ""
+        manager._model = "3DPrinter-X1-Carbon"
+
+        with (
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
+            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls,
+            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(
+                manager._cert_service,
+                "generate_certificates",
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
+            ),
+        ):
+            await manager._start_server_mode()
+
+            mock_bind_cls.assert_called_once_with(
+                serial=manager.printer_serial,
+                model="3DPrinter-X1-Carbon",
+                name="Bambuddy",
+            )

+ 360 - 21
backend/tests/unit/test_archive_filtering.py

@@ -3,7 +3,7 @@ Unit tests for archive filtering and timelapse snapshot-diff logic.
 
 
 Tests:
 Tests:
 1. Calibration print filtering — /usr/ prefix skips archive creation
 1. Calibration print filtering — /usr/ prefix skips archive creation
-2. Timelapse snapshot-diff — _list_timelapse_mp4s and _scan_for_timelapse_with_retries
+2. Timelapse snapshot-diff — _list_timelapse_videos and _scan_for_timelapse_with_retries
 """
 """
 
 
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
@@ -156,12 +156,12 @@ class TestCalibrationPrintFiltering:
         assert not skip_msgs, "User gcode should not be skipped"
         assert not skip_msgs, "User gcode should not be skipped"
 
 
 
 
-class TestListTimelapseMp4s:
-    """Test the _list_timelapse_mp4s helper function."""
+class TestListTimelapseVideos:
+    """Test the _list_timelapse_videos helper function."""
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_finds_mp4_files_in_timelapse_dir(self):
-        """Should return MP4 files found in /timelapse directory."""
+    async def test_finds_video_files_in_timelapse_dir(self):
+        """Should return MP4 and AVI files found in /timelapse directory."""
         mock_printer = MagicMock()
         mock_printer = MagicMock()
         mock_printer.ip_address = "192.168.1.100"
         mock_printer.ip_address = "192.168.1.100"
         mock_printer.access_code = "12345678"
         mock_printer.access_code = "12345678"
@@ -177,13 +177,13 @@ class TestListTimelapseMp4s:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
             mock_list.return_value = mock_files
             mock_list.return_value = mock_files
 
 
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            videos, path = await _list_timelapse_videos(mock_printer)
 
 
-        assert len(mp4s) == 2
+        assert len(videos) == 3
         assert path == "/timelapse"
         assert path == "/timelapse"
-        assert all(f["name"].endswith(".mp4") for f in mp4s)
+        assert all(f["name"].endswith((".mp4", ".avi")) for f in videos)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_tries_multiple_directories(self):
     async def test_tries_multiple_directories(self):
@@ -199,9 +199,9 @@ class TestListTimelapseMp4s:
             return []
             return []
 
 
         with patch(f"{_FTP_MODULE}.list_files_async", side_effect=mock_list_files):
         with patch(f"{_FTP_MODULE}.list_files_async", side_effect=mock_list_files):
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            mp4s, path = await _list_timelapse_videos(mock_printer)
 
 
         assert len(mp4s) == 1
         assert len(mp4s) == 1
         assert path == "/record"
         assert path == "/record"
@@ -218,9 +218,9 @@ class TestListTimelapseMp4s:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
             mock_list.return_value = []
             mock_list.return_value = []
 
 
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            mp4s, path = await _list_timelapse_videos(mock_printer)
 
 
         assert mp4s == []
         assert mp4s == []
         assert path is None
         assert path is None
@@ -241,9 +241,9 @@ class TestListTimelapseMp4s:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
             mock_list.return_value = mock_files
             mock_list.return_value = mock_files
 
 
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            mp4s, path = await _list_timelapse_videos(mock_printer)
 
 
         assert len(mp4s) == 1
         assert len(mp4s) == 1
         assert mp4s[0]["name"] == "real.mp4"
         assert mp4s[0]["name"] == "real.mp4"
@@ -306,7 +306,7 @@ class TestScanForTimelapseWithRetries:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -346,7 +346,7 @@ class TestScanForTimelapseWithRetries:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -387,7 +387,7 @@ class TestScanForTimelapseWithRetries:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -419,7 +419,7 @@ class TestScanForTimelapseWithRetries:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main._list_timelapse_videos", new_callable=AsyncMock) as mock_list,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
         ):
         ):
@@ -443,7 +443,7 @@ class TestScanForTimelapseWithRetries:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main._list_timelapse_videos", new_callable=AsyncMock) as mock_list,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
         ):
         ):
@@ -469,7 +469,7 @@ class TestScanForTimelapseWithRetries:
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -484,3 +484,342 @@ class TestScanForTimelapseWithRetries:
         assert mock_sleep.call_count == 4
         assert mock_sleep.call_count == 4
         sleep_args = [call.args[0] for call in mock_sleep.call_args_list]
         sleep_args = [call.args[0] for call in mock_sleep.call_args_list]
         assert sleep_args == [5, 10, 20, 30]
         assert sleep_args == [5, 10, 20, 30]
+
+
+class TestListTimelapseVideosAvi:
+    """Test that _list_timelapse_videos finds AVI files (P1S format)."""
+
+    @pytest.mark.asyncio
+    async def test_finds_avi_files(self):
+        """Should return AVI files alongside MP4 files."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "P1S"
+
+        mock_files = [
+            {
+                "name": "video_2026-02-17_10-00-00.avi",
+                "is_directory": False,
+                "size": 50000,
+                "path": "/timelapse/video_2026-02-17_10-00-00.avi",
+            },
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_videos
+
+            videos, path = await _list_timelapse_videos(mock_printer)
+
+        assert len(videos) == 1
+        assert videos[0]["name"].endswith(".avi")
+        assert path == "/timelapse"
+
+    @pytest.mark.asyncio
+    async def test_finds_avi_case_insensitive(self):
+        """Should match .AVI (uppercase) extensions."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "P1S"
+
+        mock_files = [
+            {"name": "VIDEO.AVI", "is_directory": False, "size": 1000, "path": "/timelapse/VIDEO.AVI"},
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_videos
+
+            videos, path = await _list_timelapse_videos(mock_printer)
+
+        assert len(videos) == 1
+
+    @pytest.mark.asyncio
+    async def test_scan_detects_new_avi_file(self):
+        """Snapshot-diff should detect new AVI files just like MP4."""
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.timelapse_path = None
+        mock_archive.printer_id = 1
+        mock_archive.filename = "benchy.gcode.3mf"
+
+        mock_printer = MagicMock()
+        mock_printer.id = 1
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "P1S"
+
+        baseline_files = []
+        new_files = [
+            {
+                "name": "video_2026-02-17.avi",
+                "is_directory": False,
+                "size": 50000,
+                "path": "/timelapse/video_2026-02-17.avi",
+            },
+        ]
+
+        call_count = 0
+
+        async def mock_list_videos(printer):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return baseline_files, "/timelapse"
+            return new_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_session.execute = AsyncMock(
+            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+        )
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_videos),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake avi data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        mock_service.attach_timelapse.assert_called_once()
+        attached_filename = mock_service.attach_timelapse.call_args[0][2]
+        assert attached_filename == "video_2026-02-17.avi"
+
+
+class TestConvertTimelapseToMp4:
+    """Test the background AVI-to-MP4 conversion."""
+
+    @pytest.mark.asyncio
+    async def test_converts_avi_to_mp4(self, tmp_path):
+        """Should call FFmpeg to convert and update the DB path."""
+        source = tmp_path / "video.avi"
+        source.write_bytes(b"fake avi")
+        mp4_path = tmp_path / "video.mp4"
+
+        mock_process = AsyncMock()
+        mock_process.communicate = AsyncMock(return_value=(b"", b""))
+        mock_process.returncode = 0
+
+        mock_archive = MagicMock()
+        mock_archive.id = 42
+        mock_archive.timelapse_path = "archives/42/video.avi"
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = mock_archive
+        mock_session.execute = AsyncMock(return_value=mock_result)
+        mock_session.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.core.database.async_session", return_value=mock_session),
+            patch("backend.app.services.archive.settings") as mock_settings,
+            patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
+        ):
+            mock_settings.base_dir = tmp_path
+            mock_exec.return_value = mock_process
+            # Create the expected output file (as FFmpeg would)
+            mp4_path.write_bytes(b"fake mp4 output")
+
+            from backend.app.services.archive import _convert_timelapse_to_mp4
+
+            await _convert_timelapse_to_mp4(42, source)
+
+        # FFmpeg should have been called
+        mock_exec.assert_called_once()
+        cmd_args = mock_exec.call_args[0]
+        assert "/usr/bin/ffmpeg" in cmd_args
+        assert "-threads" in cmd_args
+        assert "1" in cmd_args
+
+        # DB should have been updated to .mp4 path
+        mock_session.commit.assert_called_once()
+        assert mock_archive.timelapse_path == "video.mp4"
+
+    @pytest.mark.asyncio
+    async def test_skips_when_no_ffmpeg(self, tmp_path):
+        """Should log and return without converting when FFmpeg is unavailable."""
+        source = tmp_path / "video.avi"
+        source.write_bytes(b"fake avi")
+
+        with patch("backend.app.services.camera.get_ffmpeg_path", return_value=None):
+            from backend.app.services.archive import _convert_timelapse_to_mp4
+
+            await _convert_timelapse_to_mp4(1, source)
+
+        # Source file should still exist (not deleted)
+        assert source.exists()
+
+    @pytest.mark.asyncio
+    async def test_cleans_up_on_ffmpeg_failure(self, tmp_path):
+        """Should remove partial MP4 and keep source on conversion failure."""
+        source = tmp_path / "video.avi"
+        source.write_bytes(b"fake avi")
+        mp4_path = tmp_path / "video.mp4"
+
+        mock_process = AsyncMock()
+        mock_process.communicate = AsyncMock(return_value=(b"", b"conversion error"))
+        mock_process.returncode = 1
+
+        with (
+            patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
+        ):
+            mock_exec.return_value = mock_process
+            # Simulate partial output file
+            mp4_path.write_bytes(b"partial")
+
+            from backend.app.services.archive import _convert_timelapse_to_mp4
+
+            await _convert_timelapse_to_mp4(1, source)
+
+        # Partial MP4 should be cleaned up
+        assert not mp4_path.exists()
+        # Source should still exist
+        assert source.exists()
+
+
+class TestAttachTimelapseBackgroundConversion:
+    """Test that attach_timelapse spawns background conversion for non-MP4."""
+
+    @pytest.mark.asyncio
+    async def test_mp4_does_not_spawn_conversion(self, tmp_path):
+        """MP4 files should not trigger background conversion."""
+        from backend.app.services.archive import ArchiveService
+
+        mock_archive = MagicMock()
+        mock_archive.file_path = "archives/1/file.3mf"
+
+        mock_db = AsyncMock()
+        service = ArchiveService(mock_db)
+        service.get_archive = AsyncMock(return_value=mock_archive)
+
+        archive_dir = tmp_path / "archives" / "1"
+        archive_dir.mkdir(parents=True)
+
+        with (
+            patch("backend.app.services.archive.settings") as mock_settings,
+            patch("asyncio.create_task") as mock_create_task,
+        ):
+            mock_settings.base_dir = tmp_path
+
+            result = await service.attach_timelapse(1, b"fake mp4 data", "video.mp4")
+
+        assert result is True
+        mock_create_task.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_avi_spawns_background_conversion(self, tmp_path):
+        """AVI files should trigger background conversion task."""
+        from backend.app.services.archive import ArchiveService
+
+        mock_archive = MagicMock()
+        mock_archive.file_path = "archives/1/file.3mf"
+
+        mock_db = AsyncMock()
+        service = ArchiveService(mock_db)
+        service.get_archive = AsyncMock(return_value=mock_archive)
+
+        archive_dir = tmp_path / "archives" / "1"
+        archive_dir.mkdir(parents=True)
+
+        with (
+            patch("backend.app.services.archive.settings") as mock_settings,
+            patch("asyncio.create_task") as mock_create_task,
+        ):
+            mock_settings.base_dir = tmp_path
+
+            result = await service.attach_timelapse(1, b"fake avi data", "video.avi")
+
+        assert result is True
+        mock_create_task.assert_called_once()
+        # Verify task name includes archive ID
+        assert "timelapse-convert-1" in mock_create_task.call_args[1]["name"]
+
+
+class TestDeleteTimelapse:
+    """Test DELETE /archives/{id}/timelapse endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_delete_timelapse_removes_file_and_clears_db(self, tmp_path):
+        """Deleting a timelapse should remove the file and clear the DB path."""
+        from backend.app.api.routes.archives import delete_timelapse
+
+        timelapse_dir = tmp_path / "archives" / "1"
+        timelapse_dir.mkdir(parents=True)
+        timelapse_file = timelapse_dir / "timelapse.mp4"
+        timelapse_file.write_bytes(b"fake video data")
+
+        mock_archive = MagicMock()
+        mock_archive.timelapse_path = "archives/1/timelapse.mp4"
+
+        mock_db = AsyncMock()
+        mock_db.execute = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = mock_archive
+        mock_db.execute.return_value = mock_result
+
+        with patch("backend.app.api.routes.archives.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            result = await delete_timelapse(archive_id=1, db=mock_db)
+
+        assert result == {"status": "deleted"}
+        assert mock_archive.timelapse_path is None
+        mock_db.commit.assert_awaited_once()
+        assert not timelapse_file.exists()
+
+    @pytest.mark.asyncio
+    async def test_delete_timelapse_404_when_no_timelapse(self):
+        """Should return 404 when archive has no timelapse attached."""
+        from fastapi import HTTPException
+
+        from backend.app.api.routes.archives import delete_timelapse
+
+        mock_archive = MagicMock()
+        mock_archive.timelapse_path = None
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = mock_archive
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        with pytest.raises(HTTPException) as exc_info:
+            await delete_timelapse(archive_id=1, db=mock_db)
+
+        assert exc_info.value.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_delete_timelapse_404_when_archive_not_found(self):
+        """Should return 404 when archive doesn't exist."""
+        from fastapi import HTTPException
+
+        from backend.app.api.routes.archives import delete_timelapse
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = None
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        with pytest.raises(HTTPException) as exc_info:
+            await delete_timelapse(archive_id=999, db=mock_db)
+
+        assert exc_info.value.status_code == 404

+ 54 - 0
backend/tests/unit/test_color_utils.py

@@ -0,0 +1,54 @@
+"""Unit tests for color_utils — hex color similarity comparison."""
+
+from backend.app.utils.color_utils import colors_similar
+
+
+class TestColorsSimilar:
+    """Tests for colors_similar()."""
+
+    def test_exact_match(self):
+        assert colors_similar("FF0000FF", "FF0000FF") is True
+
+    def test_exact_match_case_insensitive(self):
+        assert colors_similar("ff0000ff", "FF0000FF") is True
+
+    def test_similar_colors_within_threshold(self):
+        # Real-world case: RFID read variation (distance ~43.6)
+        assert colors_similar("7CC4D5FF", "56B7E6FF") is True
+
+    def test_different_colors_beyond_threshold(self):
+        # Red vs blue (distance ~360)
+        assert colors_similar("FF0000FF", "0000FFFF") is False
+
+    def test_ignores_alpha_channel(self):
+        # Same RGB, different alpha — should match
+        assert colors_similar("FF000000", "FF0000FF") is True
+
+    def test_six_digit_hex(self):
+        assert colors_similar("FF0000", "FF0000") is True
+
+    def test_short_string_returns_false(self):
+        assert colors_similar("FFF", "FF0000") is False
+        assert colors_similar("", "FF0000") is False
+
+    def test_empty_strings_match(self):
+        """Two empty strings are exact match (both missing data)."""
+        assert colors_similar("", "") is True
+
+    def test_invalid_hex_returns_false(self):
+        assert colors_similar("ZZZZZZ", "FF0000") is False
+
+    def test_whitespace_stripped(self):
+        assert colors_similar(" FF0000 ", "FF0000") is True
+
+    def test_custom_threshold(self):
+        # Distance ~43.6 — within 50 but outside 30
+        assert colors_similar("7CC4D5FF", "56B7E6FF", threshold=30) is False
+        assert colors_similar("7CC4D5FF", "56B7E6FF", threshold=50) is True
+
+    def test_black_and_near_black(self):
+        # (10, 10, 10) distance from (0, 0, 0) = ~17.3
+        assert colors_similar("000000", "0A0A0A") is True
+
+    def test_white_and_off_white(self):
+        assert colors_similar("FFFFFF", "F0F0F0") is True

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

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

+ 161 - 0
backend/tests/unit/test_threemf_tools.py

@@ -4,15 +4,27 @@ Tests G-code parsing, filament length-to-weight conversion,
 and cumulative layer usage lookup.
 and cumulative layer usage lookup.
 """
 """
 
 
+import io
 import math
 import math
+import zipfile
 
 
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    extract_filament_usage_from_3mf,
     get_cumulative_usage_at_layer,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
     parse_gcode_layer_filament_usage,
 )
 )
 
 
 
 
+def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
+    """Create a mock 3MF file (ZIP) with slice_info.config content."""
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", slice_info_content)
+    buffer.seek(0)
+    return buffer
+
+
 class TestParseGcodeLayerFilamentUsage:
 class TestParseGcodeLayerFilamentUsage:
     """Tests for parse_gcode_layer_filament_usage()."""
     """Tests for parse_gcode_layer_filament_usage()."""
 
 
@@ -247,3 +259,152 @@ class TestGetCumulativeUsageAtLayer:
         """Target layer 0."""
         """Target layer 0."""
         data = {0: {0: 10.0}, 1: {0: 20.0}}
         data = {0: {0: 10.0}, 1: {0: 20.0}}
         assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
         assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
+
+
+class TestExtractFilamentUsageFrom3mf:
+    """Tests for extract_filament_usage_from_3mf function."""
+
+    def test_extract_single_filament(self, tmp_path):
+        """Test extracting a single filament."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 1
+        assert result[0]["used_g"] == 50.5
+        assert result[0]["type"] == "PLA"
+        assert result[0]["color"] == "#FF0000"
+
+    def test_extract_multiple_filaments(self, tmp_path):
+        """Test extracting multiple filaments."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
+            <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 3
+        assert result[0]["slot_id"] == 1
+        assert result[1]["slot_id"] == 2
+        assert result[2]["slot_id"] == 3
+
+    def test_extract_filament_with_plate_id(self, tmp_path):
+        """Test extracting filament for a specific plate."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
+            </plate>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path, plate_id=2)
+
+        assert len(result) == 1
+        assert result[0]["used_g"] == 75.0
+        assert result[0]["type"] == "PETG"
+
+    def test_missing_slice_info_returns_empty(self, tmp_path):
+        """Test that missing slice_info.config returns empty list."""
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as zf:
+            zf.writestr("other_file.txt", "content")
+        buffer.seek(0)
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(buffer.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_invalid_file_returns_empty(self, tmp_path):
+        """Test that invalid file returns empty list."""
+        file_path = tmp_path / "invalid.3mf"
+        file_path.write_text("not a zip file")
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_nonexistent_file_returns_empty(self, tmp_path):
+        """Test that nonexistent file returns empty list."""
+        file_path = tmp_path / "nonexistent.3mf"
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_filament_without_id_is_skipped(self, tmp_path):
+        """Test that filament without id is skipped."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament used_g="50.5" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 2
+
+    def test_invalid_used_g_is_skipped(self, tmp_path):
+        """Test that filament with invalid used_g is skipped."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 2
+
+    def test_missing_optional_fields(self, tmp_path):
+        """Test that missing type and color default to empty string."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["type"] == ""
+        assert result[0]["color"] == ""

+ 367 - 2
backend/tests/unit/test_usage_tracker.py

@@ -14,6 +14,7 @@ import pytest
 from backend.app.services.usage_tracker import (
 from backend.app.services.usage_tracker import (
     PrintSession,
     PrintSession,
     _active_sessions,
     _active_sessions,
+    _decode_mqtt_mapping,
     _track_from_3mf,
     _track_from_3mf,
     on_print_complete,
     on_print_complete,
     on_print_start,
     on_print_start,
@@ -104,7 +105,8 @@ class TestOnPrintStart:
         """Captures AMS remain% at print start."""
         """Captures AMS remain% at print start."""
         printer_manager = MagicMock()
         printer_manager = MagicMock()
         printer_manager.get_status.return_value = SimpleNamespace(
         printer_manager.get_status.return_value = SimpleNamespace(
-            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]}
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]},
+            tray_now=5,
         )
         )
 
 
         await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
         await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
@@ -114,12 +116,39 @@ class TestOnPrintStart:
         assert session.print_name == "Benchy"
         assert session.print_name == "Benchy"
         assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
         assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
 
 
+    @pytest.mark.asyncio
+    async def test_captures_tray_now_at_start(self):
+        """Captures tray_now at print start for later use in usage tracking."""
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
+            tray_now=9,
+        )
+
+        await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
+
+        assert _active_sessions[1].tray_now_at_start == 9
+
+    @pytest.mark.asyncio
+    async def test_tray_now_at_start_255_when_unloaded(self):
+        """Captures tray_now=255 when printer has no filament loaded at start."""
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]},
+            tray_now=255,
+        )
+
+        await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
+
+        assert _active_sessions[1].tray_now_at_start == 255
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_creates_session_without_remain(self):
     async def test_creates_session_without_remain(self):
         """Creates session even without valid remain data (for 3MF tracking)."""
         """Creates session even without valid remain data (for 3MF tracking)."""
         printer_manager = MagicMock()
         printer_manager = MagicMock()
         printer_manager.get_status.return_value = SimpleNamespace(
         printer_manager.get_status.return_value = SimpleNamespace(
-            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]},
+            tray_now=255,
         )
         )
 
 
         await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
         await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
@@ -207,6 +236,8 @@ class TestOnPrintComplete:
         printer_manager = MagicMock()
         printer_manager = MagicMock()
         printer_manager.get_status.return_value = SimpleNamespace(
         printer_manager.get_status.return_value = SimpleNamespace(
             raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
             raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            tray_now=0,
+            last_loaded_tray=-1,
         )
         )
 
 
         # db returns assignment then spool
         # db returns assignment then spool
@@ -657,6 +688,340 @@ class TestTrackFrom3mf:
         assert results[0]["ams_id"] == 0
         assert results[0]["ams_id"] == 0
         assert results[0]["tray_id"] == 0
         assert results[0]["tray_id"] == 0
 
 
+    @pytest.mark.asyncio
+    async def test_stored_ams_mapping_overrides_all(self):
+        """Stored ams_mapping from print command takes priority over queue and tray_now."""
+        # Spool at AMS2-T1 (global_tray_id=9)
+        spool = _make_spool(spool_id=10, label_weight=1000)
+        assignment = _make_assignment(spool_id=10, ams_id=2, tray_id=1)
+        archive = _make_archive(archive_id=50)
+
+        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
+        db = _mock_db_sequential([archive, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,  # Different from mapped tray — should be ignored
+            last_loaded_tray=0,
+        )
+
+        filament_usage = [{"slot_id": 2, "used_g": 1.57, "type": "PLA", "color": "#FFFFFF"}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            # ams_mapping: slot 2 (index 1) -> tray 9 (AMS2-T1)
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=50,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                ams_mapping=[-1, 9],
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 10
+        assert results[0]["ams_id"] == 2
+        assert results[0]["tray_id"] == 1
+        assert results[0]["weight_used"] == 1.6  # rounded
+
+    @pytest.mark.asyncio
+    async def test_last_loaded_tray_fallback(self):
+        """Falls back to last_loaded_tray when tray_now_at_start and current tray_now are both 255."""
+        # Spool at AMS2-T1 (global_tray_id=9)
+        spool = _make_spool(spool_id=11, label_weight=1000)
+        assignment = _make_assignment(spool_id=11, ams_id=2, tray_id=1)
+        archive = _make_archive(archive_id=60)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # H2D scenario: tray_now=255 at completion, but last_loaded_tray=9
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+            last_loaded_tray=9,
+        )
+
+        filament_usage = [{"slot_id": 6, "used_g": 1.52, "type": "PLA", "color": "#7CC4D5"}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=60,
+                status="completed",
+                print_name="Cube",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                tray_now_at_start=255,  # H2D: 255 at start too
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 11
+        assert results[0]["ams_id"] == 2
+        assert results[0]["tray_id"] == 1
+
+    @pytest.mark.asyncio
+    async def test_tray_now_at_start_preferred_over_last_loaded(self):
+        """tray_now_at_start is used before last_loaded_tray fallback."""
+        spool = _make_spool(spool_id=3, label_weight=1000)
+        assignment = _make_assignment(spool_id=3, ams_id=1, tray_id=1)
+        archive = _make_archive(archive_id=70)
+
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # tray_now_at_start=5 (valid), last_loaded_tray=9 (different) — should use 5
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+            last_loaded_tray=9,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=70,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                tray_now_at_start=5,  # AMS1-T1
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 1
+
+
+class TestDecodeMqttMapping:
+    """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
+
+    def test_none_input(self):
+        assert _decode_mqtt_mapping(None) is None
+
+    def test_empty_list(self):
+        assert _decode_mqtt_mapping([]) is None
+
+    def test_all_unmapped(self):
+        """All 65535 values → None (no valid mappings)."""
+        assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
+
+    def test_single_ams_slots(self):
+        """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
+        assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
+
+    def test_multi_ams_slots(self):
+        """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
+        assert _decode_mqtt_mapping([256, 257]) == [4, 5]
+
+    def test_ams_ht_slot(self):
+        """AMS-HT (hw_id=128): snow 32768 → global 128."""
+        assert _decode_mqtt_mapping([32768]) == [128]
+
+    def test_external_spool(self):
+        """External spool: ams_hw_id=254, slot=0 → global 254."""
+        # snow = 254 * 256 + 0 = 65024
+        assert _decode_mqtt_mapping([65024]) == [254]
+
+    def test_mixed_with_unmapped(self):
+        """Mix of valid and unmapped (65535) values."""
+        result = _decode_mqtt_mapping([1, 65535, 0])
+        assert result == [1, -1, 0]
+
+    def test_h2c_real_mapping(self):
+        """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
+        mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
+        result = _decode_mqtt_mapping(mapping)
+        assert result == [1, 0, -1, -1, -1, -1, 128]
+
+    def test_non_int_values_treated_as_unmapped(self):
+        """Non-integer values in the mapping are treated as unmapped."""
+        assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
+
+
+class TestMqttMappingIntegration:
+    """Integration tests: MQTT mapping field used in _track_from_3mf."""
+
+    @pytest.mark.asyncio
+    async def test_h2c_multi_filament_uses_mqtt_mapping(self):
+        """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
+        # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
+        spool_white = _make_spool(spool_id=1, label_weight=1000)
+        spool_black = _make_spool(spool_id=2, label_weight=1000)
+        spool_red = _make_spool(spool_id=3, label_weight=1000)
+        assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
+        assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
+        archive = _make_archive(archive_id=12)
+
+        # db: archive, then 3 pairs of (assignment, spool)
+        # No queue lookup because MQTT mapping is found first
+        db = _mock_db_sequential(
+            [
+                archive,
+                assign_white,
+                spool_white,
+                assign_black,
+                spool_black,
+                assign_red,
+                spool_red,
+            ]
+        )
+
+        # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
+        filament_usage = [
+            {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
+            {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
+            {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
+        ]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=12,
+                status="completed",
+                print_name="Cube + Cube + Cube",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 3
+
+        # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
+        assert results[0]["spool_id"] == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 1
+        assert results[0]["weight_used"] == 21.2
+
+        # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
+        assert results[1]["spool_id"] == 2
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 0
+        assert results[1]["weight_used"] == 24.2
+
+        # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
+        assert results[2]["spool_id"] == 3
+        assert results[2]["ams_id"] == 128
+        assert results[2]["tray_id"] == 0
+        assert results[2]["weight_used"] == 18.5
+
+    @pytest.mark.asyncio
+    async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
+        """ams_mapping from print command is used even when MQTT mapping exists."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
+        db = _mock_db_sequential([archive, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [0, 65535]},  # MQTT says slot 0 → AMS0-T0
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                ams_mapping=[2],  # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 2  # From print_cmd mapping, not MQTT
+
 
 
 class TestNotificationVariables:
 class TestNotificationVariables:
     """Tests for filament_details formatting in notifications."""
     """Tests for filament_details formatting in notifications."""

+ 1 - 0
docker-compose.yml

@@ -23,6 +23,7 @@ services:
     # Note: Printer discovery won't work - add printers manually by IP.
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #ports:
     #  - "${PORT:-8000}:8000"
     #  - "${PORT:-8000}:8000"
+    #  - "3000:3000"                  # Virtual printer bind/detect
     #  - "8883:8883"                  # Virtual printer MQTT
     #  - "8883:8883"                  # Virtual printer MQTT
     #  - "9990:9990"                  # Virtual printer FTP control
     #  - "9990:9990"                  # Virtual printer FTP control
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data

+ 1 - 0
frontend/.npmrc

@@ -0,0 +1 @@
+audit-level=high

File diff suppressed because it is too large
+ 146 - 2067
frontend/package-lock.json


+ 0 - 2
frontend/package.json

@@ -32,10 +32,8 @@
     "i18next": "25.6.3",
     "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-http-backend": "^3.0.2",
     "i18next-http-backend": "^3.0.2",
-    "install": "^0.13.0",
     "jszip": "^3.10.1",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
     "lucide-react": "^0.555.0",
-    "npm": "^11.9.0",
     "react": "^19.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
     "react-i18next": "^16.3.5",

+ 146 - 0
frontend/src/__tests__/components/HMSErrorModal.test.tsx

@@ -0,0 +1,146 @@
+/**
+ * Tests for the HMSErrorModal component.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { HMSErrorModal } from '../../components/HMSErrorModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import type { HMSError } from '../../api/client';
+
+// Error code 0300_400C = "The task was canceled." (known code in the database)
+const knownError: HMSError = {
+  attr: 0x0300,
+  code: '0x400C',
+  severity: 2,
+};
+
+// Error code FFFF_FFFF = unknown (not in the database)
+const unknownError: HMSError = {
+  attr: 0xFFFF,
+  code: '0xFFFF',
+  severity: 1,
+};
+
+describe('HMSErrorModal', () => {
+  const defaultProps = {
+    printerName: 'Test Printer',
+    errors: [knownError],
+    onClose: vi.fn(),
+    printerId: 1,
+    hasPermission: vi.fn().mockReturnValue(true) as unknown as (permission: 'printers:control') => boolean,
+  };
+
+  afterEach(() => {
+    cleanup();
+    vi.clearAllMocks();
+  });
+
+  describe('rendering', () => {
+    it('renders the modal title with printer name', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('Errors - Test Printer')).toBeInTheDocument();
+    });
+
+    it('shows error description for known error codes', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
+    });
+
+    it('shows no errors message when all errors are unknown', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
+      expect(screen.getByText('No errors')).toBeInTheDocument();
+    });
+
+    it('shows no errors message when errors array is empty', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[]} />);
+      expect(screen.getByText('No errors')).toBeInTheDocument();
+    });
+  });
+
+  describe('clear errors button', () => {
+    it('shows clear button when there are known errors', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('Clear Errors')).toBeInTheDocument();
+    });
+
+    it('hides clear button when there are no known errors', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[]} />);
+      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
+    });
+
+    it('hides clear button when all errors are unknown codes', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
+      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
+    });
+
+    it('disables clear button when user lacks permission', () => {
+      const noPermission = vi.fn().mockReturnValue(false) as unknown as (permission: 'printers:control') => boolean;
+      render(<HMSErrorModal {...defaultProps} hasPermission={noPermission} />);
+      expect(screen.getByText('Clear Errors').closest('button')).toBeDisabled();
+    });
+
+    it('calls API and closes modal on successful clear', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+
+      server.use(
+        http.post('/api/v1/printers/1/hms/clear', () => {
+          return HttpResponse.json({ success: true, message: 'HMS errors cleared' });
+        })
+      );
+
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      await user.click(screen.getByText('Clear Errors'));
+
+      await waitFor(() => {
+        expect(onClose).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    it('shows error toast on failed clear', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+
+      server.use(
+        http.post('/api/v1/printers/1/hms/clear', () => {
+          return HttpResponse.json({ detail: 'Failed' }, { status: 500 });
+        })
+      );
+
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      await user.click(screen.getByText('Clear Errors'));
+
+      await waitFor(() => {
+        expect(onClose).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('interactions', () => {
+    it('calls onClose when X button is clicked', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      // The X button is the button with the X icon in the header
+      const closeButtons = screen.getAllByRole('button');
+      // First button is the X close button in the header
+      await user.click(closeButtons[0]);
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      const onClose = vi.fn();
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+  });
+});

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

@@ -101,6 +101,28 @@ describe('PrinterQueueWidget - Clear Plate', () => {
         expect(link).toHaveAttribute('href', '/queue');
         expect(link).toHaveAttribute('href', '/queue');
       });
       });
     });
     });
+
+    it('shows passive link when FINISH but plateCleared is true', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" plateCleared={true} />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
+    });
+
+    it('shows passive link when FAILED but plateCleared is true', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" plateCleared={true} />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
+    });
   });
   });
 
 
   describe('clear plate button shows queue info', () => {
   describe('clear plate button shows queue info', () => {

+ 306 - 2
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -430,8 +430,9 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
     expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
   });
   });
 
 
-  it('falls back to all trays when target nozzle has no trays at all', () => {
+  it('returns -1 when target nozzle has no trays (hard filter)', () => {
     // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
     // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
+    // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
     const reqs = {
     const reqs = {
       filaments: [
       filaments: [
         { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
         { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
@@ -447,7 +448,7 @@ describe('computeAmsMapping - nozzle filtering', () => {
 
 
     const result = computeAmsMapping(reqs, status);
     const result = computeAmsMapping(reqs, status);
 
 
-    expect(result).toEqual([0]);  // Falls back to unfiltered (right nozzle PLA)
+    expect(result).toEqual([-1]);  // Hard filter: no fallback to wrong nozzle
   });
   });
 
 
   it('stays restricted when target nozzle has trays but wrong type', () => {
   it('stays restricted when target nozzle has trays but wrong type', () => {
@@ -526,3 +527,306 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
     expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
   });
   });
 });
 });
+
+// ============================================================================
+// MODEL-SPECIFIC TESTS: Real data from actual printers
+// ============================================================================
+
+/**
+ * H2D real data fixture (from live API response 2026-02-18).
+ *
+ * Configuration:
+ *   LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
+ *   RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
+ *   External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
+ *
+ * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
+ */
+function createH2DStatus(): PrinterStatus {
+  const status = createPrinterStatus(
+    [
+      {
+        id: 0, // LEFT nozzle (extruder 1)
+        humidity: 24,
+        temp: 21.4,
+        tray: [
+          { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
+          { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
+        ],
+      },
+      {
+        id: 1, // RIGHT nozzle (extruder 0)
+        humidity: 25,
+        temp: 21.7,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
+          { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
+          { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
+        ],
+      },
+      {
+        id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
+        humidity: 48,
+        temp: 21.4,
+        tray: [
+          { id: 0 }, // empty tray
+        ],
+      },
+      {
+        id: 2, // LEFT nozzle (extruder 1)
+        humidity: 18,
+        temp: 24.0,
+        tray: [
+          { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
+          { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
+          { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
+        ],
+      },
+    ],
+    [
+      { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
+      { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
+    ]
+  );
+  (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
+  return status;
+}
+
+/**
+ * X1C real data fixture (from live API response 2026-02-18).
+ *
+ * Configuration:
+ *   Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
+ *   External: 254 (single)
+ *
+ * ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
+ */
+function createX1CStatus(): PrinterStatus {
+  const status = createPrinterStatus(
+    [
+      {
+        id: 0,
+        humidity: 23,
+        temp: 26.1,
+        tray: [
+          { id: 0 }, // empty (has tray_color but no tray_type)
+          { id: 1 }, // empty
+          { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
+          { id: 3 }, // empty
+        ],
+      },
+      {
+        id: 1,
+        humidity: 20,
+        temp: 25.9,
+        tray: [
+          { id: 0 }, // empty
+          { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
+          { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
+          { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
+        ],
+      },
+    ],
+    [
+      { id: 254, tray_type: '', tray_color: '00000000' }, // empty
+    ]
+  );
+  (status as any).ams_extruder_map = { '0': 0, '1': 0 };
+  return status;
+}
+
+describe('H2D model tests (dual nozzle, real data)', () => {
+  describe('buildLoadedFilaments', () => {
+    it('assigns correct extruderId to all AMS units', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+
+      // AMS 0 trays → extruder 1 (LEFT)
+      const ams0 = result.filter((f) => f.amsId === 0);
+      expect(ams0).toHaveLength(4);
+      ams0.forEach((f) => expect(f.extruderId).toBe(1));
+
+      // AMS 1 trays → extruder 0 (RIGHT)
+      const ams1 = result.filter((f) => f.amsId === 1);
+      expect(ams1).toHaveLength(4);
+      ams1.forEach((f) => expect(f.extruderId).toBe(0));
+
+      // AMS 2 trays → extruder 1 (LEFT)
+      const ams2 = result.filter((f) => f.amsId === 2);
+      expect(ams2).toHaveLength(4);
+      ams2.forEach((f) => expect(f.extruderId).toBe(1));
+    });
+
+    it('computes correct globalTrayId for all AMS types', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+
+      // Regular AMS: amsId * 4 + trayId
+      expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
+      expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
+      expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
+      expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
+    });
+
+    it('skips empty AMS-HT tray (no tray_type)', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      // AMS-HT 128 is empty in real data — should be skipped
+      const ht = result.filter((f) => f.amsId === 128);
+      expect(ht).toHaveLength(0);
+    });
+
+    it('includes loaded external spool with correct extruder', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      const ext = result.filter((f) => f.isExternal);
+      // Only Ext-L (254) has filament, Ext-R (255) is empty
+      expect(ext).toHaveLength(1);
+      expect(ext[0].globalTrayId).toBe(254);
+      expect(ext[0].type).toBe('PLA');
+      // Ext-L (254) should be LEFT nozzle (extruder 1)
+      expect(ext[0].extruderId).toBe(1);
+    });
+
+    it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
+      expect(result).toHaveLength(13);
+    });
+  });
+
+  describe('computeAmsMapping', () => {
+    it('matches left-nozzle filament to left-nozzle AMS only', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
+      expect(result).toEqual([3]);
+    });
+
+    it('matches right-nozzle filament to right-nozzle AMS only', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
+      expect(result).toEqual([4]);
+    });
+
+    it('rejects cross-nozzle assignment (right requires type only on left)', () => {
+      const reqs = {
+        filaments: [
+          // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
+          { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([-1]); // No fallback to wrong nozzle
+    });
+
+    it('maps dual-nozzle multi-filament print correctly', () => {
+      const reqs = {
+        filaments: [
+          // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
+          { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
+          // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
+          { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([0, 4]);
+    });
+
+    it('matches external spool on correct nozzle', () => {
+      const reqs = {
+        filaments: [
+          // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([254]); // External spool on left nozzle
+    });
+  });
+});
+
+describe('X1C model tests (single nozzle, real data)', () => {
+  describe('buildLoadedFilaments', () => {
+    it('assigns all filaments to extruder 0', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      result.forEach((f) => expect(f.extruderId).toBe(0));
+    });
+
+    it('computes correct globalTrayId for regular AMS', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      // AMS 1 T2 (tray id 1) → globalTrayId 5
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
+      // AMS 1 T3 (tray id 2) → globalTrayId 6
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
+      // AMS 1 T4 (tray id 3) → globalTrayId 7
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
+    });
+
+    it('returns only loaded trays (3 from AMS 1)', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
+      expect(result).toHaveLength(3);
+    });
+  });
+
+  describe('computeAmsMapping', () => {
+    it('matches single-nozzle file without nozzle filtering', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
+      expect(result).toEqual([7]);
+    });
+
+    it('matches by tray_info_idx across AMS units', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
+      expect(result).toEqual([5]);
+    });
+
+    it('handles non-unique tray_info_idx with color matching', () => {
+      // P4d64437 appears in both AMS 1 T3 and T4
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
+      expect(result).toEqual([6]);
+    });
+
+    it('does not cross-nozzle filter for single-nozzle printer', () => {
+      // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
+          { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Both should match freely across all AMS units
+      expect(result).toEqual([5, 7]);
+    });
+  });
+});

+ 65 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -307,4 +307,69 @@ describe('ArchivesPage', () => {
       // The plates API is called lazily when hovering
       // The plates API is called lazily when hovering
     });
     });
   });
   });
+
+  describe('timelapse management', () => {
+    it('shows upload timelapse menu item when no timelapse attached', async () => {
+      const archivesWithoutTimelapse = mockArchives.map(a => ({ ...a, timelapse_path: null }));
+      server.use(
+        http.get('/api/v1/archives/', () => {
+          return HttpResponse.json(archivesWithoutTimelapse);
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Context menu items are rendered in the DOM even when not visible
+      // "Upload Timelapse" should be present for archives without timelapse
+      const uploadItems = screen.queryAllByText('Upload Timelapse');
+      expect(uploadItems.length).toBeGreaterThanOrEqual(0);
+    });
+
+    it('shows remove timelapse menu item when timelapse is attached', async () => {
+      const archivesWithTimelapse = mockArchives.map(a => ({
+        ...a,
+        timelapse_path: 'archives/1/timelapse.mp4',
+      }));
+      server.use(
+        http.get('/api/v1/archives/', () => {
+          return HttpResponse.json(archivesWithTimelapse);
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // "Remove Timelapse" should be present for archives with timelapse
+      const removeItems = screen.queryAllByText('Remove Timelapse');
+      expect(removeItems.length).toBeGreaterThanOrEqual(0);
+    });
+
+    it('disables scan for timelapse when timelapse is already attached', async () => {
+      const archivesWithTimelapse = mockArchives.map(a => ({
+        ...a,
+        timelapse_path: 'archives/1/timelapse.mp4',
+      }));
+      server.use(
+        http.get('/api/v1/archives/', () => {
+          return HttpResponse.json(archivesWithTimelapse);
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // "Scan for Timelapse" buttons should be disabled when timelapse exists
+      // Upload Timelapse should also be disabled
+    });
+  });
 });
 });

+ 11 - 0
frontend/src/api/client.ts

@@ -250,6 +250,8 @@ export interface PrinterStatus {
   big_fan2_speed: number | null;     // Chamber/exhaust fan
   big_fan2_speed: number | null;     // Chamber/exhaust fan
   heatbreak_fan_speed: number | null; // Hotend heatbreak fan
   heatbreak_fan_speed: number | null; // Hotend heatbreak fan
   firmware_version: string | null;   // Firmware version from MQTT
   firmware_version: string | null;   // Firmware version from MQTT
+  // Queue: user has acknowledged plate is cleared for next queued print
+  plate_cleared: boolean;
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {
@@ -1236,6 +1238,7 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
+  filament_used_grams?: number | null;  // Estimated print weight from archive or library file
   // User tracking (Issue #206)
   // User tracking (Issue #206)
   created_by_id?: number | null;
   created_by_id?: number | null;
   created_by_username?: string | null;
   created_by_username?: string | null;
@@ -2343,6 +2346,10 @@ export const api = {
       }
       }
     ),
     ),
 
 
+  // HMS Errors
+  clearHMSErrors: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/hms/clear`, { method: 'POST' }),
+
   // AMS Control
   // AMS Control
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(
@@ -2649,6 +2656,10 @@ export const api = {
       `/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,
       `/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
+  deleteArchiveTimelapse: (id: number) =>
+    request<{ status: string }>(`/archives/${id}/timelapse`, {
+      method: 'DELETE',
+    }),
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);

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

@@ -226,7 +226,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
               })}
               })}
             </h3>
             </h3>
             {selectedArchives.length > 0 ? (
             {selectedArchives.length > 0 ? (
-              <div className="space-y-2 max-h-96 overflow-y-auto">
+              <div className="calendar-scroll space-y-2 max-h-96 overflow-y-auto">
                 {selectedArchives.map(archive => {
                 {selectedArchives.map(archive => {
                   const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
                   const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
                   return (
                   return (

+ 34 - 6
frontend/src/components/HMSErrorModal.tsx

@@ -2,13 +2,18 @@
 // Source: https://github.com/greghesp/ha-bambulab
 // Source: https://github.com/greghesp/ha-bambulab
 import { useEffect } from 'react';
 import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
-import type { HMSError } from '../api/client';
+import { useMutation } from '@tanstack/react-query';
+import { X, AlertTriangle, AlertCircle, Info, ExternalLink, Loader2, Trash2 } from 'lucide-react';
+import type { HMSError, Permission } from '../api/client';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
 
 
 interface HMSErrorModalProps {
 interface HMSErrorModalProps {
   printerName: string;
   printerName: string;
   errors: HMSError[];
   errors: HMSError[];
   onClose: () => void;
   onClose: () => void;
+  printerId: number;
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
 // Comprehensive error code database (short format: XXXX_YYYY)
 // Comprehensive error code database (short format: XXXX_YYYY)
@@ -904,11 +909,20 @@ function getHMSHomeUrl(): string {
   return `https://wiki.bambulab.com/en/hms/home`;
   return `https://wiki.bambulab.com/en/hms/home`;
 }
 }
 
 
-export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission }: HMSErrorModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { showToast } = useToast();
 
 
-  // Debug: log errors to see what data we're receiving
-  console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
+  const clearMutation = useMutation({
+    mutationFn: () => api.clearHMSErrors(printerId),
+    onSuccess: () => {
+      showToast(t('hmsErrors.clearSuccess'), 'success');
+      onClose();
+    },
+    onError: () => {
+      showToast(t('hmsErrors.clearFailed'), 'error');
+    },
+  });
 
 
   // Filter to only show errors we have descriptions for (skip unknown codes)
   // Filter to only show errors we have descriptions for (skip unknown codes)
   const knownErrors = errors.filter((error) => {
   const knownErrors = errors.filter((error) => {
@@ -994,10 +1008,24 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         </div>
         </div>
 
 
         {/* Footer */}
         {/* Footer */}
-        <div className="p-4 border-t border-bambu-dark-tertiary">
+        <div className="p-4 border-t border-bambu-dark-tertiary flex items-center justify-between gap-3">
           <p className="text-xs text-bambu-gray">
           <p className="text-xs text-bambu-gray">
             {t('hmsErrors.clearInstructions')}
             {t('hmsErrors.clearInstructions')}
           </p>
           </p>
+          {knownErrors.length > 0 && (
+            <button
+              onClick={() => clearMutation.mutate()}
+              disabled={!hasPermission('printers:control') || clearMutation.isPending}
+              className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
+            >
+              {clearMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Trash2 className="w-4 h-4" />
+              )}
+              {t('hmsErrors.clearErrors')}
+            </button>
+          )}
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 3 - 1
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -159,7 +159,9 @@ export function FilamentMapping({
                 <option value="" className="bg-bambu-dark text-bambu-gray">
                 <option value="" className="bg-bambu-dark text-bambu-gray">
                   -- Select slot --
                   -- Select slot --
                 </option>
                 </option>
-                {loadedFilaments.map((f) => (
+                {loadedFilaments
+                  .filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)
+                  .map((f) => (
                   <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                   <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                     {f.label}: {f.type} ({f.colorName})
                     {f.label}: {f.type} ({f.colorName})
                   </option>
                   </option>

+ 2 - 2
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -1,6 +1,6 @@
 import { Layers, Check, AlertTriangle } from 'lucide-react';
 import { Layers, Check, AlertTriangle } from 'lucide-react';
-import { formatTime } from '../../utils/amsHelpers';
 import type { PlateSelectorProps } from './types';
 import type { PlateSelectorProps } from './types';
+import { formatDuration } from '../../utils/date';
 
 
 /**
 /**
  * Plate selection grid for multi-plate 3MF files.
  * Plate selection grid for multi-plate 3MF files.
@@ -61,7 +61,7 @@ export function PlateSelector({
                   ? plate.objects.slice(0, 3).join(', ') +
                   ? plate.objects.slice(0, 3).join(', ') +
                     (plate.objects.length > 3 ? '...' : '')
                     (plate.objects.length > 3 ? '...' : '')
                   : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
                   : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                {plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
+                {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
               </p>
               </p>
             </div>
             </div>
             {selectedPlate === plate.index && (
             {selectedPlate === plate.index && (

+ 5 - 5
frontend/src/components/PrintModal/index.tsx

@@ -251,9 +251,9 @@ export function PrintModal({
     setPerPrinterConfigs
     setPerPrinterConfigs
   );
   );
 
 
-  // Auto-select first plate for single-plate files
+  // Auto-select first plate when plates load (single or multi-plate)
   useEffect(() => {
   useEffect(() => {
-    if (platesData?.plates?.length === 1 && !selectedPlate) {
+    if (platesData?.plates && platesData.plates.length >= 1 && !selectedPlate) {
       setSelectedPlate(platesData.plates[0].index);
       setSelectedPlate(platesData.plates[0].index);
     }
     }
   }, [platesData, selectedPlate]);
   }, [platesData, selectedPlate]);
@@ -528,11 +528,11 @@ export function PrintModal({
     // Model-based assignment only works in queue modes (not immediate reprint)
     // Model-based assignment only works in queue modes (not immediate reprint)
     if (assignmentMode === 'model' && mode === 'reprint') return false;
     if (assignmentMode === 'model' && mode === 'reprint') return false;
 
 
-    // For multi-plate archive files, need a selected plate (library files skip this)
-    if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
+    // For multi-plate files, need a selected plate
+    if (isMultiPlate && !selectedPlate) return false;
 
 
     return true;
     return true;
-  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
+  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending]);
 
 
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {

+ 5 - 4
frontend/src/components/PrinterQueueWidget.tsx

@@ -10,6 +10,7 @@ import { parseUTCDate } from '../utils/date';
 interface PrinterQueueWidgetProps {
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerId: number;
   printerState?: string | null;
   printerState?: string | null;
+  plateCleared?: boolean;
 }
 }
 
 
 function formatRelativeTime(dateString: string | null): string {
 function formatRelativeTime(dateString: string | null): string {
@@ -26,7 +27,7 @@ function formatRelativeTime(dateString: string | null): string {
   return date.toLocaleDateString();
   return date.toLocaleDateString();
 }
 }
 
 
-export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerState, plateCleared }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -56,7 +57,7 @@ export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidg
     return null;
     return null;
   }
   }
 
 
-  const needsClearPlate = printerState === 'FINISH' || printerState === 'FAILED';
+  const needsClearPlate = (printerState === 'FINISH' || printerState === 'FAILED') && !plateCleared;
 
 
   if (needsClearPlate) {
   if (needsClearPlate) {
     return (
     return (
@@ -66,7 +67,7 @@ export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidg
           <div className="min-w-0 flex-1">
           <div className="min-w-0 flex-1">
             <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-sm text-white truncate">
             <p className="text-sm text-white truncate">
-              {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
+              {nextItem?.archive_name || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}
             </p>
             </p>
           </div>
           </div>
           {totalPending > 1 && (
           {totalPending > 1 && (
@@ -109,7 +110,7 @@ export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidg
           <div className="min-w-0 flex-1">
           <div className="min-w-0 flex-1">
             <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-sm text-white truncate">
             <p className="text-sm text-white truncate">
-              {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
+              {nextItem?.archive_name || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>

+ 99 - 3
frontend/src/components/SkipObjectsModal.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Monitor, AlertCircle, Box } from 'lucide-react';
+import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -31,6 +31,7 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const { hasPermission } = useAuth();
   const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
   const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
+  const [enlarged, setEnlarged] = useState(false);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printerId],
     queryKey: ['printerStatus', printerId],
@@ -63,7 +64,12 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
     <div
     <div
       className="fixed inset-0 z-50 flex items-center justify-center"
       className="fixed inset-0 z-50 flex items-center justify-center"
       onClick={onClose}
       onClick={onClose}
-      onKeyDown={(e) => e.key === 'Escape' && onClose()}
+      onKeyDown={(e) => {
+        if (e.key === 'Escape') {
+          if (enlarged) setEnlarged(false);
+          else onClose();
+        }
+      }}
       tabIndex={-1}
       tabIndex={-1}
       ref={(el) => el?.focus()}
       ref={(el) => el?.focus()}
     >
     >
@@ -127,7 +133,7 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
             <div className="flex flex-1 overflow-hidden">
             <div className="flex flex-1 overflow-hidden">
               {/* Left: Preview Image with object markers */}
               {/* Left: Preview Image with object markers */}
               <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
               <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
-                <div className="relative">
+                <div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
                   {status?.cover_url ? (
                   {status?.cover_url ? (
                     <img
                     <img
                       src={`${status.cover_url}?view=top`}
                       src={`${status.cover_url}?view=top`}
@@ -139,6 +145,10 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
                       <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
                       <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
                     </div>
                     </div>
                   )}
                   )}
+                  {/* Enlarge hint */}
+                  <div className="absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity">
+                    <Maximize2 className="w-3.5 h-3.5 text-white" />
+                  </div>
                   {/* Object ID markers overlay - positioned based on object data */}
                   {/* Object ID markers overlay - positioned based on object data */}
                   {objectsData.objects.length > 0 && (
                   {objectsData.objects.length > 0 && (
                     <div className="absolute inset-0 pointer-events-none">
                     <div className="absolute inset-0 pointer-events-none">
@@ -283,6 +293,92 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
         onCancel={() => setPendingSkip(null)}
         onCancel={() => setPendingSkip(null)}
       />
       />
     )}
     )}
+    {/* Enlarged lightbox overlay */}
+    {enlarged && objectsData && (
+      <div
+        className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
+        onClick={() => setEnlarged(false)}
+      >
+        <button
+          onClick={() => setEnlarged(false)}
+          className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
+        >
+          <X className="w-6 h-6" />
+        </button>
+        <div
+          className="relative max-w-[600px] max-h-[80vh] aspect-square"
+          onClick={(e) => e.stopPropagation()}
+        >
+          {status?.cover_url ? (
+            <img
+              src={`${status.cover_url}?view=top`}
+              alt={t('printers.printPreview')}
+              className="w-full h-full object-contain rounded-lg bg-gray-900"
+            />
+          ) : (
+            <div className="w-full h-full rounded-lg bg-gray-800 flex items-center justify-center">
+              <Box className="w-16 h-16 text-gray-500" />
+            </div>
+          )}
+          {/* Object ID markers overlay */}
+          {objectsData.objects.length > 0 && (
+            <div className="absolute inset-0 pointer-events-none">
+              {objectsData.objects.map((obj, idx) => {
+                let x: number, y: number;
+
+                if (obj.x != null && obj.y != null && objectsData.bbox_all) {
+                  const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
+                  const bboxWidth = xMax - xMin;
+                  const bboxHeight = yMax - yMin;
+                  const padding = 8;
+                  const contentArea = 100 - (padding * 2);
+                  x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
+                  y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
+                  x = Math.max(5, Math.min(95, x));
+                  y = Math.max(5, Math.min(95, y));
+                } else if (obj.x != null && obj.y != null) {
+                  const buildPlate = 256;
+                  x = (obj.x / buildPlate) * 100;
+                  y = 100 - (obj.y / buildPlate) * 100;
+                  x = Math.max(5, Math.min(95, x));
+                  y = Math.max(5, Math.min(95, y));
+                } else {
+                  const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
+                  const row = Math.floor(idx / cols);
+                  const col = idx % cols;
+                  const rows = Math.ceil(objectsData.objects.length / cols);
+                  x = 15 + (col * (70 / cols)) + (35 / cols);
+                  y = 15 + (row * (70 / rows)) + (35 / rows);
+                }
+
+                return (
+                  <div
+                    key={obj.id}
+                    className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
+                      obj.skipped
+                        ? 'bg-red-500 text-white line-through'
+                        : 'bg-bambu-green text-black'
+                    }`}
+                    style={{
+                      left: `${x}%`,
+                      top: `${y}%`,
+                      transform: 'translate(-50%, -50%)'
+                    }}
+                    title={obj.name}
+                  >
+                    {obj.id}
+                  </div>
+                );
+              })}
+            </div>
+          )}
+          {/* Active count badge */}
+          <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
+            {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
+          </div>
+        </div>
+      </div>
+    )}
   </>
   </>
   );
   );
 }
 }

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

@@ -411,7 +411,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         </div>
         </div>
 
 
         {/* Content */}
         {/* Content */}
-        <div className="p-4 overflow-y-auto flex-1">
+        <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
           {activeTab === 'filament' ? (
           {activeTab === 'filament' ? (
             <div className="space-y-6">
             <div className="space-y-6">
               {/* Filament Info Section */}
               {/* Filament Info Section */}

+ 4 - 4
frontend/src/components/spool-form/ColorSection.tsx

@@ -177,7 +177,7 @@ export function ColorSection({
                 key={`${color.hex}-${color.name}`}
                 key={`${color.hex}-${color.name}`}
                 type="button"
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
                 onClick={() => selectColor(color.hex, color.name)}
-                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
                     : 'border-bambu-dark-tertiary'
@@ -185,7 +185,7 @@ export function ColorSection({
                 style={{ backgroundColor: `#${color.hex}` }}
                 style={{ backgroundColor: `#${color.hex}` }}
                 title={color.name}
                 title={color.name}
               >
               >
-                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                   {color.name}
                   {color.name}
                 </span>
                 </span>
               </button>
               </button>
@@ -220,7 +220,7 @@ export function ColorSection({
                 key={color.hex}
                 key={color.hex}
                 type="button"
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
                 onClick={() => selectColor(color.hex, color.name)}
-                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
                     : 'border-bambu-dark-tertiary'
@@ -228,7 +228,7 @@ export function ColorSection({
                 style={{ backgroundColor: `#${color.hex}` }}
                 style={{ backgroundColor: `#${color.hex}` }}
                 title={color.name}
                 title={color.name}
               >
               >
-                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                   {color.name}
                   {color.name}
                 </span>
                 </span>
               </button>
               </button>

+ 8 - 11
frontend/src/hooks/useFilamentMapping.ts

@@ -58,7 +58,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         globalTrayId: trayId,
         globalTrayId: trayId,
         trayInfoIdx: extTray.tray_info_idx || '',
         trayInfoIdx: extTray.tray_info_idx || '',
-        extruderId: hasDualNozzle ? (trayId - 254) : undefined,
+        extruderId: hasDualNozzle ? (255 - trayId) : undefined,
       });
       });
     }
     }
   }
   }
@@ -100,12 +100,11 @@ export function computeAmsMapping(
     // Get available trays (not already used)
     // Get available trays (not already used)
     let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
     let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
 
 
-    // Nozzle-aware filtering: restrict to trays on the correct nozzle
+    // Nozzle-aware filtering: restrict to trays on the correct nozzle.
+    // This is a hard filter — cross-nozzle assignment causes print failures
+    // ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
     if (req.nozzle_id != null) {
     if (req.nozzle_id != null) {
-      const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
-      if (nozzleFiltered.length > 0) {
-        available = nozzleFiltered;
-      }
+      available = available.filter((f) => f.extruderId === req.nozzle_id);
     }
     }
 
 
     let idxMatch: LoadedFilament | undefined;
     let idxMatch: LoadedFilament | undefined;
@@ -336,12 +335,10 @@ export function useFilamentMapping(
       // Get available trays (not already used)
       // Get available trays (not already used)
       let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
       let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
 
 
-      // Nozzle-aware filtering: restrict to trays on the correct nozzle
+      // Nozzle-aware filtering: restrict to trays on the correct nozzle.
+      // This is a hard filter — cross-nozzle assignment causes print failures.
       if (req.nozzle_id != null) {
       if (req.nozzle_id != null) {
-        const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
-        if (nozzleFiltered.length > 0) {
-          available = nozzleFiltered;
-        }
+        available = available.filter((f) => f.extruderId === req.nozzle_id);
       }
       }
 
 
       let idxMatch: LoadedFilament | undefined;
       let idxMatch: LoadedFilament | undefined;

+ 14 - 1
frontend/src/i18n/locales/de.ts

@@ -86,6 +86,7 @@ export default {
     unknown: 'Unbekannt',
     unknown: 'Unbekannt',
     unknownError: 'Unbekannter Fehler',
     unknownError: 'Unbekannter Fehler',
     today: 'Heute',
     today: 'Heute',
+    tomorrow: 'Morgen',
     asap: 'Sofort',
     asap: 'Sofort',
     overdue: 'Überfällig',
     overdue: 'Überfällig',
     now: 'Jetzt',
     now: 'Jetzt',
@@ -517,6 +518,10 @@ export default {
       noMatchingTimelapse: 'Kein passender Zeitraffer gefunden',
       noMatchingTimelapse: 'Kein passender Zeitraffer gefunden',
       failedScanTimelapse: 'Fehler beim Suchen nach Zeitraffer',
       failedScanTimelapse: 'Fehler beim Suchen nach Zeitraffer',
       failedAttachTimelapse: 'Fehler beim Anhängen des Zeitraffers',
       failedAttachTimelapse: 'Fehler beim Anhängen des Zeitraffers',
+      timelapseRemoved: 'Zeitraffer entfernt',
+      failedRemoveTimelapse: 'Fehler beim Entfernen des Zeitraffers',
+      timelapseUploaded: 'Zeitraffer hochgeladen: {{filename}}',
+      failedUploadTimelapse: 'Fehler beim Hochladen des Zeitraffers',
       archiveDeleted: 'Archiv gelöscht',
       archiveDeleted: 'Archiv gelöscht',
       failedDeleteArchive: 'Fehler beim Löschen des Archivs',
       failedDeleteArchive: 'Fehler beim Löschen des Archivs',
       addedToFavorites: 'Zu Favoriten hinzugefügt',
       addedToFavorites: 'Zu Favoriten hinzugefügt',
@@ -542,6 +547,8 @@ export default {
       preview3d: '3D-Vorschau',
       preview3d: '3D-Vorschau',
       viewTimelapse: 'Zeitraffer ansehen',
       viewTimelapse: 'Zeitraffer ansehen',
       scanForTimelapse: 'Nach Zeitraffer suchen',
       scanForTimelapse: 'Nach Zeitraffer suchen',
+      uploadTimelapse: 'Zeitraffer hochladen',
+      removeTimelapse: 'Zeitraffer entfernen',
       downloadSource3mf: 'Quell-3MF herunterladen',
       downloadSource3mf: 'Quell-3MF herunterladen',
       uploadSource3mf: 'Quell-3MF hochladen',
       uploadSource3mf: 'Quell-3MF hochladen',
       replaceSource3mf: 'Quell-3MF ersetzen',
       replaceSource3mf: 'Quell-3MF ersetzen',
@@ -638,6 +645,8 @@ export default {
       removeButton: 'Entfernen',
       removeButton: 'Entfernen',
       removeF3d: 'F3D entfernen',
       removeF3d: 'F3D entfernen',
       removeF3dConfirm: 'Möchten Sie die Fusion 360 Designdatei wirklich von "{{name}}" entfernen?',
       removeF3dConfirm: 'Möchten Sie die Fusion 360 Designdatei wirklich von "{{name}}" entfernen?',
+      removeTimelapse: 'Zeitraffer entfernen',
+      removeTimelapseConfirm: 'Möchten Sie das Zeitraffervideo wirklich von "{{name}}" entfernen?',
       timelapse: '{{name}} - Zeitraffer',
       timelapse: '{{name}} - Zeitraffer',
       selectTimelapse: 'Zeitraffer auswählen',
       selectTimelapse: 'Zeitraffer auswählen',
       selectTimelapseDesc: 'Keine automatische Übereinstimmung gefunden. Wählen Sie den Zeitraffer für diesen Druck:',
       selectTimelapseDesc: 'Keine automatische Übereinstimmung gefunden. Wählen Sie den Zeitraffer für diesen Druck:',
@@ -740,6 +749,7 @@ export default {
     clearPlate: 'Druckplatte freigeben & Nächsten starten',
     clearPlate: 'Druckplatte freigeben & Nächsten starten',
     clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
     clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
+    plateNumber: 'Platte {{index}}',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Aktuell druckend',
       currentlyPrinting: 'Aktuell druckend',
@@ -762,6 +772,7 @@ export default {
       printing: 'Druckt',
       printing: 'Druckt',
       queued: 'In Warteschlange',
       queued: 'In Warteschlange',
       totalTime: 'Gesamte Wartezeit',
       totalTime: 'Gesamte Wartezeit',
+      totalWeight: 'Gesamtgewicht der Warteschlange',
       history: 'Verlauf',
       history: 'Verlauf',
     },
     },
     // Filters
     // Filters
@@ -1601,6 +1612,9 @@ export default {
     noErrors: 'Keine Fehler',
     noErrors: 'Keine Fehler',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
+    clearErrors: 'Fehler löschen',
+    clearSuccess: 'HMS-Fehler gelöscht',
+    clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1874,7 +1888,6 @@ export default {
     cameraStream: 'Kamera-Stream',
     cameraStream: 'Kamera-Stream',
     progress: 'Fortschritt',
     progress: 'Fortschritt',
     eta: 'ETA',
     eta: 'ETA',
-    tomorrow: 'Morgen',
     printerIdle: 'Drucker ist inaktiv',
     printerIdle: 'Drucker ist inaktiv',
     printerOffline: 'Drucker offline',
     printerOffline: 'Drucker offline',
     status: {
     status: {

+ 14 - 1
frontend/src/i18n/locales/en.ts

@@ -86,6 +86,7 @@ export default {
     unknown: 'Unknown',
     unknown: 'Unknown',
     unknownError: 'Unknown error',
     unknownError: 'Unknown error',
     today: 'Today',
     today: 'Today',
+    tomorrow: 'Tomorrow',
     asap: 'ASAP',
     asap: 'ASAP',
     overdue: 'Overdue',
     overdue: 'Overdue',
     now: 'Now',
     now: 'Now',
@@ -517,6 +518,10 @@ export default {
       noMatchingTimelapse: 'No matching timelapse found',
       noMatchingTimelapse: 'No matching timelapse found',
       failedScanTimelapse: 'Failed to scan for timelapse',
       failedScanTimelapse: 'Failed to scan for timelapse',
       failedAttachTimelapse: 'Failed to attach timelapse',
       failedAttachTimelapse: 'Failed to attach timelapse',
+      timelapseRemoved: 'Timelapse removed',
+      failedRemoveTimelapse: 'Failed to remove timelapse',
+      timelapseUploaded: 'Timelapse uploaded: {{filename}}',
+      failedUploadTimelapse: 'Failed to upload timelapse',
       archiveDeleted: 'Archive deleted',
       archiveDeleted: 'Archive deleted',
       failedDeleteArchive: 'Failed to delete archive',
       failedDeleteArchive: 'Failed to delete archive',
       addedToFavorites: 'Added to favorites',
       addedToFavorites: 'Added to favorites',
@@ -542,6 +547,8 @@ export default {
       preview3d: '3D Preview',
       preview3d: '3D Preview',
       viewTimelapse: 'View Timelapse',
       viewTimelapse: 'View Timelapse',
       scanForTimelapse: 'Scan for Timelapse',
       scanForTimelapse: 'Scan for Timelapse',
+      uploadTimelapse: 'Upload Timelapse',
+      removeTimelapse: 'Remove Timelapse',
       downloadSource3mf: 'Download Source 3MF',
       downloadSource3mf: 'Download Source 3MF',
       uploadSource3mf: 'Upload Source 3MF',
       uploadSource3mf: 'Upload Source 3MF',
       replaceSource3mf: 'Replace Source 3MF',
       replaceSource3mf: 'Replace Source 3MF',
@@ -638,6 +645,8 @@ export default {
       removeButton: 'Remove',
       removeButton: 'Remove',
       removeF3d: 'Remove F3D',
       removeF3d: 'Remove F3D',
       removeF3dConfirm: 'Are you sure you want to remove the Fusion 360 design file from "{{name}}"?',
       removeF3dConfirm: 'Are you sure you want to remove the Fusion 360 design file from "{{name}}"?',
+      removeTimelapse: 'Remove Timelapse',
+      removeTimelapseConfirm: 'Are you sure you want to remove the timelapse video from "{{name}}"?',
       timelapse: '{{name}} - Timelapse',
       timelapse: '{{name}} - Timelapse',
       selectTimelapse: 'Select Timelapse',
       selectTimelapse: 'Select Timelapse',
       selectTimelapseDesc: 'No auto-match found. Select the timelapse for this print:',
       selectTimelapseDesc: 'No auto-match found. Select the timelapse for this print:',
@@ -740,6 +749,7 @@ export default {
     clearPlate: 'Clear Plate & Start Next',
     clearPlate: 'Clear Plate & Start Next',
     clearPlateSuccess: 'Plate cleared — ready for next print',
     clearPlateSuccess: 'Plate cleared — ready for next print',
     plateReady: 'Plate cleared — ready for next print',
     plateReady: 'Plate cleared — ready for next print',
+    plateNumber: 'Plate {{index}}',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Currently Printing',
       currentlyPrinting: 'Currently Printing',
@@ -762,6 +772,7 @@ export default {
       printing: 'Printing',
       printing: 'Printing',
       queued: 'Queued',
       queued: 'Queued',
       totalTime: 'Total Queue Time',
       totalTime: 'Total Queue Time',
+      totalWeight: 'Total Queue Weight',
       history: 'History',
       history: 'History',
     },
     },
     // Filters
     // Filters
@@ -1601,6 +1612,9 @@ export default {
     noErrors: 'No errors',
     noErrors: 'No errors',
     viewOnWiki: 'View on Bambu Lab Wiki',
     viewOnWiki: 'View on Bambu Lab Wiki',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
+    clearErrors: 'Clear Errors',
+    clearSuccess: 'HMS errors cleared',
+    clearFailed: 'Failed to clear HMS errors',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1874,7 +1888,6 @@ export default {
     cameraStream: 'Camera stream',
     cameraStream: 'Camera stream',
     progress: 'Progress',
     progress: 'Progress',
     eta: 'ETA',
     eta: 'ETA',
-    tomorrow: 'Tomorrow',
     printerIdle: 'Printer is idle',
     printerIdle: 'Printer is idle',
     printerOffline: 'Printer offline',
     printerOffline: 'Printer offline',
     status: {
     status: {

+ 6 - 1
frontend/src/i18n/locales/fr.ts

@@ -86,6 +86,7 @@ export default {
     unknown: 'Inconnu',
     unknown: 'Inconnu',
     unknownError: 'Erreur inconnue',
     unknownError: 'Erreur inconnue',
     today: 'Aujourd\'hui',
     today: 'Aujourd\'hui',
+    tomorrow: 'Demain',
     asap: 'Dès que possible',
     asap: 'Dès que possible',
     overdue: 'En retard',
     overdue: 'En retard',
     now: 'Maintenant',
     now: 'Maintenant',
@@ -740,6 +741,7 @@ export default {
     clearPlate: 'Vider plateau & lancer suivant',
     clearPlate: 'Vider plateau & lancer suivant',
     clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
     clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
     plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
     plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
+    plateNumber: 'Plateau {{index}}',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'En cours',
       currentlyPrinting: 'En cours',
@@ -762,6 +764,7 @@ export default {
       printing: 'Impressions',
       printing: 'Impressions',
       queued: 'En attente',
       queued: 'En attente',
       totalTime: 'Temps total estimé',
       totalTime: 'Temps total estimé',
+      totalWeight: 'Poids total estimé',
       history: 'Historique',
       history: 'Historique',
     },
     },
     // Filters
     // Filters
@@ -1597,6 +1600,9 @@ export default {
     noErrors: 'Aucune erreur',
     noErrors: 'Aucune erreur',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
+    clearErrors: 'Effacer les erreurs',
+    clearSuccess: 'Erreurs HMS effacées',
+    clearFailed: 'Échec de l\'effacement des erreurs HMS',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1870,7 +1876,6 @@ export default {
     cameraStream: 'Flux caméra',
     cameraStream: 'Flux caméra',
     progress: 'Progression',
     progress: 'Progression',
     eta: 'Fin estimée',
     eta: 'Fin estimée',
-    tomorrow: 'Demain',
     printerIdle: 'Imprimante inactive',
     printerIdle: 'Imprimante inactive',
     printerOffline: 'Imprimante hors ligne',
     printerOffline: 'Imprimante hors ligne',
     status: {
     status: {

+ 6 - 1
frontend/src/i18n/locales/it.ts

@@ -83,6 +83,7 @@ export default {
     unknown: 'Sconosciuto',
     unknown: 'Sconosciuto',
     unknownError: 'Errore sconosciuto',
     unknownError: 'Errore sconosciuto',
     today: 'Oggi',
     today: 'Oggi',
+    tomorrow: 'Domani',
     asap: 'ASAP',
     asap: 'ASAP',
     overdue: 'Scaduto',
     overdue: 'Scaduto',
     now: 'Ora',
     now: 'Ora',
@@ -727,6 +728,7 @@ export default {
     dragToReorder: 'Trascina per riordinare (solo ASAP)',
     dragToReorder: 'Trascina per riordinare (solo ASAP)',
     reorderHint: 'La posizione influisce solo sugli elementi ASAP. Quelli programmati partono all\'orario.',
     reorderHint: 'La posizione influisce solo sugli elementi ASAP. Quelli programmati partono all\'orario.',
     addedBy: 'Aggiunto da {{name}}',
     addedBy: 'Aggiunto da {{name}}',
+    plateNumber: 'Piatto {{index}}',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'In stampa',
       currentlyPrinting: 'In stampa',
@@ -749,6 +751,7 @@ export default {
       printing: 'In stampa',
       printing: 'In stampa',
       queued: 'In coda',
       queued: 'In coda',
       totalTime: 'Tempo totale coda',
       totalTime: 'Tempo totale coda',
+      totalWeight: 'Peso totale della coda',
       history: 'Cronologia',
       history: 'Cronologia',
     },
     },
     // Filters
     // Filters
@@ -1430,6 +1433,9 @@ export default {
     noErrors: 'Nessun errore',
     noErrors: 'Nessun errore',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
+    clearErrors: 'Cancella errori',
+    clearSuccess: 'Errori HMS cancellati',
+    clearFailed: 'Impossibile cancellare gli errori HMS',
   },
   },
 
 
   // MQTT Debug modal
   // MQTT Debug modal
@@ -1687,7 +1693,6 @@ export default {
     cameraStream: 'Stream camera',
     cameraStream: 'Stream camera',
     progress: 'Avanzamento',
     progress: 'Avanzamento',
     eta: 'ETA',
     eta: 'ETA',
-    tomorrow: 'Domani',
     printerIdle: 'Stampante inattiva',
     printerIdle: 'Stampante inattiva',
     printerOffline: 'Stampante offline',
     printerOffline: 'Stampante offline',
     status: {
     status: {

+ 14 - 1
frontend/src/i18n/locales/ja.ts

@@ -74,6 +74,7 @@ export default {
     unknown: '不明',
     unknown: '不明',
     unknownError: '不明なエラー',
     unknownError: '不明なエラー',
     today: '今日',
     today: '今日',
+    tomorrow: '明日',
     asap: '即時',
     asap: '即時',
     now: '今すぐ',
     now: '今すぐ',
     collapse: '折りたたむ',
     collapse: '折りたたむ',
@@ -538,6 +539,10 @@ export default {
       noMatchingTimelapse: '一致するタイムラプスが見つかりません',
       noMatchingTimelapse: '一致するタイムラプスが見つかりません',
       failedScanTimelapse: 'タイムラプスのスキャンに失敗しました',
       failedScanTimelapse: 'タイムラプスのスキャンに失敗しました',
       failedAttachTimelapse: 'タイムラプスの添付に失敗しました',
       failedAttachTimelapse: 'タイムラプスの添付に失敗しました',
+      timelapseRemoved: 'タイムラプスを削除しました',
+      failedRemoveTimelapse: 'タイムラプスの削除に失敗しました',
+      timelapseUploaded: 'タイムラプスをアップロードしました: {{filename}}',
+      failedUploadTimelapse: 'タイムラプスのアップロードに失敗しました',
       archiveDeleted: 'アーカイブを削除しました',
       archiveDeleted: 'アーカイブを削除しました',
       failedDeleteArchive: 'アーカイブの削除に失敗しました',
       failedDeleteArchive: 'アーカイブの削除に失敗しました',
       projectUpdated: 'プロジェクトを更新しました',
       projectUpdated: 'プロジェクトを更新しました',
@@ -574,6 +579,8 @@ export default {
       print: '印刷',
       print: '印刷',
       openInBambuStudio: 'スライサーで開く',
       openInBambuStudio: 'スライサーで開く',
       scanForTimelapse: 'タイムラプスをスキャン',
       scanForTimelapse: 'タイムラプスをスキャン',
+      uploadTimelapse: 'タイムラプスをアップロード',
+      removeTimelapse: 'タイムラプスを削除',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       viewPhotosCount: '写真を表示 ({{count}})',
       viewPhotosCount: '写真を表示 ({{count}})',
       addToFavorites: 'お気に入りに追加',
       addToFavorites: 'お気に入りに追加',
@@ -641,6 +648,8 @@ export default {
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeF3d: 'F3Dを削除',
       removeF3d: 'F3Dを削除',
       removeF3dConfirm: '"{{name}}"からFusion 360デザインファイルを削除してもよろしいですか?',
       removeF3dConfirm: '"{{name}}"からFusion 360デザインファイルを削除してもよろしいですか?',
+      removeTimelapse: 'タイムラプスを削除',
+      removeTimelapseConfirm: '"{{name}}"からタイムラプス動画を削除してもよろしいですか?',
       selectTimelapse: 'タイムラプスを選択',
       selectTimelapse: 'タイムラプスを選択',
       selectTimelapseDesc: '自動一致が見つかりませんでした。この印刷のタイムラプスを選択してください:',
       selectTimelapseDesc: '自動一致が見つかりませんでした。この印刷のタイムラプスを選択してください:',
       deleteArchives: '印刷アーカイブを削除',
       deleteArchives: '印刷アーカイブを削除',
@@ -810,6 +819,7 @@ export default {
     clearPlate: 'プレートをクリアして次を開始',
     clearPlate: 'プレートをクリアして次を開始',
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
     plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
+    plateNumber: 'プレート {{index}}',
     sections: {
     sections: {
       currentlyPrinting: '印刷中',
       currentlyPrinting: '印刷中',
       queued: 'キュー中',
       queued: 'キュー中',
@@ -830,6 +840,7 @@ export default {
       queued: 'キュー中',
       queued: 'キュー中',
       history: '履歴',
       history: '履歴',
       totalTime: 'キュー合計時間',
       totalTime: 'キュー合計時間',
+      totalWeight: 'キュー合計重量',
     },
     },
     filter: {
     filter: {
       allPrinters: 'すべてのプリンター',
       allPrinters: 'すべてのプリンター',
@@ -1855,7 +1866,6 @@ export default {
     },
     },
     title: 'ストリームオーバーレイ',
     title: 'ストリームオーバーレイ',
     progress: '進捗',
     progress: '進捗',
-    tomorrow: '明日',
     printerIdle: 'プリンター待機中',
     printerIdle: 'プリンター待機中',
     printerOffline: 'プリンターオフライン',
     printerOffline: 'プリンターオフライン',
   },
   },
@@ -2940,6 +2950,9 @@ export default {
     noErrors: 'エラーなし',
     noErrors: 'エラーなし',
     viewOnWiki: 'Bambu Lab Wikiで表示',
     viewOnWiki: 'Bambu Lab Wikiで表示',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
+    clearErrors: 'エラーをクリア',
+    clearSuccess: 'HMSエラーをクリアしました',
+    clearFailed: 'HMSエラーのクリアに失敗しました',
   },
   },
   plateAlert: {
   plateAlert: {
     title: '印刷が一時停止されました!',
     title: '印刷が一時停止されました!',

+ 24 - 0
frontend/src/index.css

@@ -391,3 +391,27 @@ body {
 .card-shadow {
 .card-shadow {
   box-shadow: var(--card-shadow);
   box-shadow: var(--card-shadow);
 }
 }
+
+/* Calendar selected-day list scrollbar theming */
+.calendar-scroll {
+  scrollbar-width: thin;
+  scrollbar-color: color-mix(in srgb, var(--text-muted) 60%, transparent) transparent;
+}
+
+.calendar-scroll::-webkit-scrollbar {
+  width: 8px;
+}
+
+.calendar-scroll::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.calendar-scroll::-webkit-scrollbar-thumb {
+  background-color: color-mix(in srgb, var(--text-muted) 60%, transparent);
+  border-radius: 999px;
+  border: 2px solid color-mix(in srgb, var(--bg-secondary) 70%, transparent);
+}
+
+.calendar-scroll::-webkit-scrollbar-thumb:hover {
+  background-color: color-mix(in srgb, var(--text-muted) 80%, transparent);
+}

+ 183 - 37
frontend/src/pages/ArchivesPage.tsx

@@ -46,11 +46,12 @@ import {
   ChevronRight,
   ChevronRight,
   Settings,
   Settings,
   User,
   User,
+  Play,
   ClipboardList,
   ClipboardList,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
-import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
+import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
@@ -82,13 +83,6 @@ function formatFileSize(bytes: number): string {
   return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
   return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 }
 }
 
 
-function formatDuration(seconds: number): string {
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-}
-
 /**
 /**
  * Check if an archive filename represents a sliced/printable file.
  * Check if an archive filename represents a sliced/printable file.
  * Matches: .gcode, .gcode.3mf, .gcode.anything
  * Matches: .gcode, .gcode.3mf, .gcode.anything
@@ -156,11 +150,13 @@ function ArchiveCard({
   const [showSchedule, setShowSchedule] = useState(false);
   const [showSchedule, setShowSchedule] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
+  const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
   const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
   const [showPlateNav, setShowPlateNav] = useState(false);
   const [showPlateNav, setShowPlateNav] = useState(false);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
+  const timelapseInputRef = useRef<HTMLInputElement>(null);
 
 
   // Fetch plates data for multi-plate browsing (lazy - only when hovering)
   // Fetch plates data for multi-plate browsing (lazy - only when hovering)
   const { data: platesData } = useQuery({
   const { data: platesData } = useQuery({
@@ -174,6 +170,28 @@ function ArchiveCard({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const displayPlateIndex = currentPlateIndex ?? 0;
   const displayPlateIndex = currentPlateIndex ?? 0;
 
 
+  const timelapseDeleteMutation = useMutation({
+    mutationFn: () => api.deleteArchiveTimelapse(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseRemoved'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');
+    },
+  });
+
+  const timelapseUploadMutation = useMutation({
+    mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');
+    },
+  });
+
   const source3mfUploadMutation = useMutation({
   const source3mfUploadMutation = useMutation({
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
     onSuccess: (data) => {
     onSuccess: (data) => {
@@ -364,6 +382,21 @@ function ArchiveCard({
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
     },
     },
+    {
+      label: t('archives.menu.uploadTimelapse'),
+      icon: <Upload className="w-4 h-4" />,
+      onClick: () => timelapseInputRef.current?.click(),
+      disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    },
+    ...(archive.timelapse_path ? [{
+      label: t('archives.menu.removeTimelapse'),
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteTimelapseConfirm(true),
+      danger: true,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
       label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
       label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
@@ -690,7 +723,7 @@ function ArchiveCard({
         {/* Duplicate badge */}
         {/* Duplicate badge */}
         {archive.duplicate_count > 0 && (
         {archive.duplicate_count > 0 && (
           <div
           <div
-            className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
+            className="absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
             title={t('archives.card.duplicateTitle')}
             title={t('archives.card.duplicateTitle')}
           >
           >
             <Copy className="w-3 h-3" />
             <Copy className="w-3 h-3" />
@@ -729,10 +762,21 @@ function ArchiveCard({
             <Box className="w-4 h-4 text-cyan-400" />
             <Box className="w-4 h-4 text-cyan-400" />
           </button>
           </button>
         )}
         )}
+        {/* 3D preview badge */}
+        <button
+          className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
+          onClick={(e) => {
+            e.stopPropagation();
+            setShowViewer(true);
+          }}
+          title={t('archives.card.preview3d')}
+        >
+          <Layers className="w-4 h-4 text-white" />
+        </button>
         {/* Timelapse badge */}
         {/* Timelapse badge */}
         {archive.timelapse_path && (
         {archive.timelapse_path && (
           <button
           <button
-            className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
+            className="absolute bottom-2 right-12 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
             onClick={(e) => {
             onClick={(e) => {
               e.stopPropagation();
               e.stopPropagation();
               setShowTimelapse(true);
               setShowTimelapse(true);
@@ -745,7 +789,7 @@ function ArchiveCard({
         {/* Photos badge */}
         {/* Photos badge */}
         {archive.photos && archive.photos.length > 0 && (
         {archive.photos && archive.photos.length > 0 && (
           <button
           <button
-            className={`absolute bottom-2 ${archive.timelapse_path ? 'right-12' : 'right-2'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
+            className={`absolute bottom-2 ${archive.timelapse_path ? 'right-[5.5rem]' : 'right-12'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
             onClick={(e) => {
             onClick={(e) => {
               e.stopPropagation();
               e.stopPropagation();
               setShowPhotos(true);
               setShowPhotos(true);
@@ -776,9 +820,21 @@ function ArchiveCard({
 
 
       <CardContent className="p-4 flex-1 flex flex-col">
       <CardContent className="p-4 flex-1 flex flex-col">
         {/* Title */}
         {/* Title */}
-        <h3 className="font-medium text-white mb-1 truncate">
-          {archive.print_name || archive.filename}
-        </h3>
+        <div className="flex items-center justify-between gap-2 mb-1">
+          <h3 className="min-w-0 font-medium text-white truncate">
+            {archive.print_name || archive.filename}
+          </h3>
+          <Button
+            variant="ghost"
+            size="sm"
+            className="p-1 sm:p-1.5 shrink-0"
+            onClick={() => setShowEdit(true)}
+            disabled={!canModify('archives', 'update', archive.created_by_id)}
+            title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.card.noPermissionEdit') : t('archives.card.edit')}
+          >
+            <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
+          </Button>
+        </div>
         <div className="flex items-center gap-2 mb-3 flex-wrap">
         <div className="flex items-center gap-2 mb-3 flex-wrap">
           <p className="text-xs text-bambu-gray">{printerName}</p>
           <p className="text-xs text-bambu-gray">{printerName}</p>
           {/* File type badge */}
           {/* File type badge */}
@@ -996,15 +1052,6 @@ function ArchiveCard({
           >
           >
             <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
             <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
           </Button>
           </Button>
-          <Button
-            variant="secondary"
-            size="sm"
-            className="min-w-0 p-1 sm:p-1.5"
-            onClick={() => setShowViewer(true)}
-            title={t('archives.card.preview3d')}
-          >
-            <Box className="w-3 h-3 sm:w-4 sm:h-4" />
-          </Button>
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
@@ -1018,16 +1065,6 @@ function ArchiveCard({
           >
           >
             <Download className="w-3 h-3 sm:w-4 sm:h-4" />
             <Download className="w-3 h-3 sm:w-4 sm:h-4" />
           </Button>
           </Button>
-          <Button
-            variant="ghost"
-            size="sm"
-            className="min-w-0 p-1 sm:p-1.5"
-            onClick={() => setShowEdit(true)}
-            disabled={!canModify('archives', 'update', archive.created_by_id)}
-            title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.card.noPermissionEdit') : t('archives.card.edit')}
-          >
-            <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
-          </Button>
           <Button
           <Button
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
@@ -1114,6 +1151,21 @@ function ArchiveCard({
         />
         />
       )}
       )}
 
 
+      {/* Delete Timelapse Confirmation */}
+      {showDeleteTimelapseConfirm && (
+        <ConfirmModal
+          title={t('archives.modal.removeTimelapse')}
+          message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}
+          confirmText={t('archives.modal.removeButton')}
+          variant="danger"
+          onConfirm={() => {
+            timelapseDeleteMutation.mutate();
+            setShowDeleteTimelapseConfirm(false);
+          }}
+          onCancel={() => setShowDeleteTimelapseConfirm(false)}
+        />
+      )}
+
       {/* Context Menu */}
       {/* Context Menu */}
       {contextMenu && (
       {contextMenu && (
         <ContextMenu
         <ContextMenu
@@ -1145,9 +1197,9 @@ function ArchiveCard({
           <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
           <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
             <div className="flex items-center justify-between p-4 border-b border-gray-700">
             <div className="flex items-center justify-between p-4 border-b border-gray-700">
               <div>
               <div>
-                <h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
+                <h3 className="text-lg font-semibold text-white">{t('archives.modal.selectTimelapse')}</h3>
                 <p className="text-sm text-gray-400 mt-1">
                 <p className="text-sm text-gray-400 mt-1">
-                  No auto-match found. Select the timelapse for this print:
+                  {t('archives.modal.selectTimelapseDesc')}
                 </p>
                 </p>
               </div>
               </div>
               <button
               <button
@@ -1269,6 +1321,20 @@ function ArchiveCard({
           e.target.value = '';
           e.target.value = '';
         }}
         }}
       />
       />
+      {/* Hidden file input for timelapse upload */}
+      <input
+        ref={timelapseInputRef}
+        type="file"
+        accept=".mp4,.avi,.mkv"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0];
+          if (file) {
+            timelapseUploadMutation.mutate(file);
+          }
+          e.target.value = '';
+        }}
+      />
     </Card>
     </Card>
   );
   );
 }
 }
@@ -1310,9 +1376,33 @@ function ArchiveListRow({
   const [showProjectPage, setShowProjectPage] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
+  const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
+  const timelapseInputRef = useRef<HTMLInputElement>(null);
+
+  const timelapseDeleteMutation = useMutation({
+    mutationFn: () => api.deleteArchiveTimelapse(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseRemoved'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');
+    },
+  });
+
+  const timelapseUploadMutation = useMutation({
+    mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');
+    },
+  });
 
 
   const source3mfUploadMutation = useMutation({
   const source3mfUploadMutation = useMutation({
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
@@ -1501,6 +1591,21 @@ function ArchiveListRow({
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
     },
     },
+    {
+      label: t('archives.menu.uploadTimelapse'),
+      icon: <Upload className="w-4 h-4" />,
+      onClick: () => timelapseInputRef.current?.click(),
+      disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    },
+    ...(archive.timelapse_path ? [{
+      label: t('archives.menu.removeTimelapse'),
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteTimelapseConfirm(true),
+      danger: true,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
       label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
       label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
@@ -1778,6 +1883,18 @@ function ArchiveListRow({
           {formatFileSize(archive.file_size)}
           {formatFileSize(archive.file_size)}
         </div>
         </div>
         <div className="col-span-2 flex justify-end gap-1">
         <div className="col-span-2 flex justify-end gap-1">
+          {isSlicedFile(archive.filename) && (
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => setShowReprint(true)}
+              disabled={!canModify('archives', 'reprint', archive.created_by_id)}
+              title={!canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : t('archives.card.reprint')}
+              className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
+            >
+              <Play className="w-4 h-4" />
+            </Button>
+          )}
           <Button
           <Button
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
@@ -1917,6 +2034,21 @@ function ArchiveListRow({
         />
         />
       )}
       )}
 
 
+      {/* Delete Timelapse Confirmation */}
+      {showDeleteTimelapseConfirm && (
+        <ConfirmModal
+          title={t('archives.modal.removeTimelapse')}
+          message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}
+          confirmText={t('archives.modal.removeButton')}
+          variant="danger"
+          onConfirm={() => {
+            timelapseDeleteMutation.mutate();
+            setShowDeleteTimelapseConfirm(false);
+          }}
+          onCancel={() => setShowDeleteTimelapseConfirm(false)}
+        />
+      )}
+
       {/* Context Menu */}
       {/* Context Menu */}
       {contextMenu && (
       {contextMenu && (
         <ContextMenu
         <ContextMenu
@@ -1948,9 +2080,9 @@ function ArchiveListRow({
           <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
           <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
             <div className="flex items-center justify-between p-4 border-b border-gray-700">
             <div className="flex items-center justify-between p-4 border-b border-gray-700">
               <div>
               <div>
-                <h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
+                <h3 className="text-lg font-semibold text-white">{t('archives.modal.selectTimelapse')}</h3>
                 <p className="text-sm text-gray-400 mt-1">
                 <p className="text-sm text-gray-400 mt-1">
-                  No auto-match found. Select the timelapse for this print:
+                  {t('archives.modal.selectTimelapseDesc')}
                 </p>
                 </p>
               </div>
               </div>
               <button
               <button
@@ -2060,6 +2192,20 @@ function ArchiveListRow({
           e.target.value = '';
           e.target.value = '';
         }}
         }}
       />
       />
+      {/* Hidden file input for timelapse upload */}
+      <input
+        ref={timelapseInputRef}
+        type="file"
+        accept=".mp4,.avi,.mkv"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0];
+          if (file) {
+            timelapseUploadMutation.mutate(file);
+          }
+          e.target.value = '';
+        }}
+      />
     </>
     </>
   );
   );
 }
 }

+ 1 - 9
frontend/src/pages/FileManagerPage.tsx

@@ -57,6 +57,7 @@ import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { formatDuration } from '../utils/date';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -70,15 +71,6 @@ function formatFileSize(bytes: number): string {
   return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
   return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
 }
 }
 
 
-// Utility to format duration
-function formatDuration(seconds: number | null): string {
-  if (!seconds) return '-';
-  const hours = Math.floor(seconds / 3600);
-  const mins = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${mins}m`;
-  return `${mins}m`;
-}
-
 // New Folder Modal
 // New Folder Modal
 interface NewFolderModalProps {
 interface NewFolderModalProps {
   parentId: number | null;
   parentId: number | null;

+ 9 - 43
frontend/src/pages/PrintersPage.tsx

@@ -46,7 +46,7 @@ import {
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { api, discoveryApi, firmwareApi } from '../api/client';
-import { formatDateOnly } from '../utils/date';
+import { formatDateOnly, formatETA, formatDuration } from '../utils/date';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -64,6 +64,7 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
+import { getGlobalTrayId } from '../utils/amsHelpers';
 
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -1087,42 +1088,6 @@ function getSpoolmanFillLevel(
   ));
   ));
 }
 }
 
 
-function formatTime(seconds: number): string {
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
-}
-
-function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string {
-  const now = new Date();
-  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
-  const today = new Date();
-  today.setHours(0, 0, 0, 0);
-  const etaDay = new Date(eta);
-  etaDay.setHours(0, 0, 0, 0);
-
-  // Build time format options based on setting
-  const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
-  if (timeFormat === '12h') {
-    timeOptions.hour12 = true;
-  } else if (timeFormat === '24h') {
-    timeOptions.hour12 = false;
-  }
-  // 'system' leaves hour12 undefined, letting the browser decide
-
-  const timeStr = eta.toLocaleTimeString([], timeOptions);
-
-  // Check if it's tomorrow or later
-  const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
-  if (dayDiff === 0) {
-    return timeStr;
-  } else if (dayDiff === 1) {
-    return `Tomorrow ${timeStr}`;
-  } else {
-    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
-  }
-}
-
 function getPrinterImage(model: string | null | undefined): string {
 function getPrinterImage(model: string | null | undefined): string {
   if (!model) return '/img/printers/default.png';
   if (!model) return '/img/printers/default.png';
 
 
@@ -1348,7 +1313,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
                 />
                 />
               </div>
               </div>
               <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
               <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
-              <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
+              <span className="text-bambu-gray">({formatDuration(nextFinish.remainingMin * 60)})</span>
             </div>
             </div>
           </div>
           </div>
         </>
         </>
@@ -2455,10 +2420,10 @@ function PrinterCard({
                               <>
                               <>
                                 <span className="flex items-center gap-1">
                                 <span className="flex items-center gap-1">
                                   <Clock className="w-3 h-3" />
                                   <Clock className="w-3 h-3" />
-                                  {formatTime(status.remaining_time * 60)}
+                                  {formatDuration(status.remaining_time * 60)}
                                 </span>
                                 </span>
                                 <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
                                 <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
-                                  ETA {formatETA(status.remaining_time, timeFormat)}
+                                  ETA {formatETA(status.remaining_time, timeFormat, t)}
                                 </span>
                                 </span>
                               </>
                               </>
                             )}
                             )}
@@ -2507,7 +2472,7 @@ function PrinterCard({
                 </div>
                 </div>
 
 
                 {/* Queue Widget - always visible when there are pending items */}
                 {/* Queue Widget - always visible when there are pending items */}
-                <PrinterQueueWidget printerId={printer.id} printerState={status.state} />
+                <PrinterQueueWidget printerId={printer.id} printerState={status.state} plateCleared={status.plate_cleared} />
               </>
               </>
             )}
             )}
 
 
@@ -3013,8 +2978,7 @@ function PrinterCard({
                         const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                         const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                         const isEmpty = !tray?.tray_type;
                         const isEmpty = !tray?.tray_type;
                         // Check if this is the currently loaded tray
                         // Check if this is the currently loaded tray
-                        // Global tray ID = ams.id * 4 + tray.id
-                        const globalTrayId = ams.id * 4 + (tray?.id ?? 0);
+                        const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                         const isActive = effectiveTrayNow === globalTrayId;
                         const isActive = effectiveTrayNow === globalTrayId;
                         // Get cloud preset info if available
                         // Get cloud preset info if available
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
@@ -4008,6 +3972,8 @@ function PrinterCard({
           printerName={printer.name}
           printerName={printer.name}
           errors={status?.hms_errors || []}
           errors={status?.hms_errors || []}
           onClose={() => setShowHMSModal(false)}
           onClose={() => setShowHMSModal(false)}
+          printerId={printer.id}
+          hasPermission={hasPermission}
         />
         />
       )}
       )}
 
 

+ 88 - 12
frontend/src/pages/QueuePage.tsx

@@ -47,9 +47,10 @@ import {
   Square,
   Square,
   User,
   User,
   Pause,
   Pause,
+  Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
+import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -58,12 +59,9 @@ import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 
 
-function formatDuration(seconds: number | null | undefined): string {
-  if (!seconds) return '--';
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
+function formatWeight(g: number, useKg = false): string {
+  if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
+  return `${Math.round(g)}g`;
 }
 }
 
 
 function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
 function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
@@ -316,6 +314,34 @@ function SortableQueueItem({
   printerState?: string | null;
   printerState?: string | null;
   t: (key: string, options?: Record<string, unknown>) => string;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
 }) {
+  // Fetch printer status every 30 seconds while printing to monitor progress
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', item.printer_id],
+    queryFn: () => api.getPrinterStatus(item.printer_id!),
+    refetchInterval: 30000,
+    enabled: item.printer_id != null && printerState === 'printing',
+  });
+
+  // Determine if we're printing a library file
+  const isLibraryFile = !!item.library_file_id && !item.archive_id;
+  // Fetch archive plate details
+  const { data: archivePlatesData } = useQuery({
+    queryKey: ['archive-plates', item.archive_id],
+    queryFn: () => api.getArchivePlates(item.archive_id!),
+    enabled: !!item.archive_id && !isLibraryFile,
+  });
+
+  // Fetch library file plate details
+  const { data: libraryPlatesData } = useQuery({
+    queryKey: ['library-file-plates', item.library_file_id],
+    queryFn: () => api.getLibraryFilePlates(item.library_file_id!),
+    enabled: isLibraryFile && !!item.library_file_id,
+  });
+
+  // Combine plates data from either source
+  const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
+  const plates = platesData?.plates ?? [];
+
   const canReorder = hasPermission('queue:reorder');
   const canReorder = hasPermission('queue:reorder');
   const {
   const {
     attributes,
     attributes,
@@ -415,6 +441,7 @@ function SortableQueueItem({
           <div className="flex items-center gap-2 mb-1">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-white font-medium truncate">
             <p className="text-white font-medium truncate">
               {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
               {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
+              {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
             </p>
             </p>
             {item.archive_id ? (
             {item.archive_id ? (
               <Link
               <Link
@@ -450,6 +477,12 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
                 {formatDuration(item.print_time_seconds)}
               </span>
               </span>
             )}
             )}
+            {item.filament_used_grams && (
+              <span className="flex items-center gap-1.5">
+                <Weight className="w-3.5 h-3.5" />
+                {formatWeight(item.filament_used_grams)}
+              </span>
+            )}
             {item.created_by_username && (
             {item.created_by_username && (
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
                 <User className="w-3.5 h-3.5" />
@@ -486,12 +519,36 @@ function SortableQueueItem({
           </div>
           </div>
 
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
-          {isPrinting && (
+          {isPrinting && status && (
             <div className="mt-3">
             <div className="mt-3">
-              <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
-                <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
+              <div className="flex items-center justify-between text-sm">
+                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+                  <div
+                    className="bg-bambu-green h-2 rounded-full transition-all"
+                    style={{ width: `${status.progress || 0}%` }}
+                  />
+                </div>
+                <span className="text-white">{Math.round(status.progress || 0)}%</span>
+              </div>
+              <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+                {status.remaining_time != null && status.remaining_time > 0 && (
+                  <>
+                    <span className="flex items-center gap-1">
+                      <Clock className="w-3 h-3" />
+                      {formatDuration(status.remaining_time * 60)}
+                    </span>
+                    <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
+                      ETA {formatETA(status.remaining_time, timeFormat, t)}
+                    </span>
+                  </>
+                )}
+                {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                  <span className="flex items-center gap-1">
+                    <Layers className="w-3 h-3" />
+                    {status.layer_num}/{status.total_layers}
+                  </span>
+                )}
               </div>
               </div>
-              <p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
             </div>
             </div>
           )}
           )}
 
 
@@ -894,6 +951,11 @@ export function QueuePage() {
     return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
     return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
   }, [pendingItems]);
   }, [pendingItems]);
 
 
+  // Calculate total material weight
+  const totalWeight = useMemo(() => {
+    return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
+  }, [pendingItems]);
+
   const handleDragEnd = (event: DragEndEvent) => {
   const handleDragEnd = (event: DragEndEvent) => {
     const { active, over } = event;
     const { active, over } = event;
     if (!over || active.id === over.id) return;
     if (!over || active.id === over.id) return;
@@ -925,7 +987,7 @@ export function QueuePage() {
       </div>
       </div>
 
 
       {/* Summary Cards */}
       {/* Summary Cards */}
-      <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
+      <div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
           <CardContent className="p-4">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
@@ -968,6 +1030,20 @@ export function QueuePage() {
           </CardContent>
           </CardContent>
         </Card>
         </Card>
 
 
+        <Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
+                <Weight className="w-5 h-5 text-purple-500" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
           <CardContent className="p-4">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">

+ 11 - 27
frontend/src/pages/StreamOverlayPage.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { PrinterStatus } from '../api/client';
 import type { PrinterStatus } from '../api/client';
+import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
 
@@ -46,31 +47,6 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
   };
   };
 }
 }
 
 
-function formatTime(seconds: number): string {
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
-}
-
-function formatETA(remainingMinutes: number, t: TFunction): string {
-  const now = new Date();
-  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
-  const today = new Date();
-  today.setHours(0, 0, 0, 0);
-  const etaDay = new Date(eta);
-  etaDay.setHours(0, 0, 0, 0);
-
-  const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-
-  if (etaDay.getTime() === today.getTime()) {
-    return timeStr;
-  } else if (etaDay.getTime() === today.getTime() + 86400000) {
-    return `${t('streamOverlay.tomorrow')} ${timeStr}`;
-  } else {
-    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
-  }
-}
-
 function getStatusText(status: PrinterStatus, t: TFunction): string {
 function getStatusText(status: PrinterStatus, t: TFunction): string {
   if (status.stg_cur_name) return status.stg_cur_name;
   if (status.stg_cur_name) return status.stg_cur_name;
 
 
@@ -146,6 +122,14 @@ export function StreamOverlayPage() {
     refetchInterval: 2000,
     refetchInterval: 2000,
   });
   });
 
 
+  // Fetch settings info
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+
   // WebSocket for real-time updates
   // WebSocket for real-time updates
   useEffect(() => {
   useEffect(() => {
     if (!id) return;
     if (!id) return;
@@ -298,14 +282,14 @@ export function StreamOverlayPage() {
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Timer className={sizes.icon} />
                     <Timer className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
                     <span className={`${sizes.text} text-white`}>
-                      {formatTime(status.remaining_time * 60)}
+                      {formatDuration(status.remaining_time * 60)}
                     </span>
                     </span>
                   </div>
                   </div>
 
 
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Clock className={sizes.icon} />
                     <Clock className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
                     <span className={`${sizes.text} text-white`}>
-                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
+                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
                     </span>
                     </span>
                   </div>
                   </div>
                 </>
                 </>

+ 0 - 11
frontend/src/utils/amsHelpers.ts

@@ -94,17 +94,6 @@ export function getGlobalTrayId(
   return amsId * 4 + trayId;
   return amsId * 4 + trayId;
 }
 }
 
 
-/**
- * Format seconds to human readable time string.
- */
-export function formatTime(seconds: number | null | undefined): string {
-  if (!seconds) return '';
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-}
-
 /**
 /**
  * Get minimum datetime for scheduling (now + 1 minute).
  * Get minimum datetime for scheduling (now + 1 minute).
  * Returns ISO string format for datetime-local input.
  * Returns ISO string format for datetime-local input.

+ 48 - 0
frontend/src/utils/date.ts

@@ -376,3 +376,51 @@ export function formatTimeOnly(
   const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
   const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
   return date.toLocaleTimeString([], finalOptions);
   return date.toLocaleTimeString([], finalOptions);
 }
 }
+
+/**
+ * Calculate and format an ETA based on remaining minutes from now.
+ *
+ * @param remainingMinutes - Minutes until completion
+ * @param timeFormat - Time format setting ('system', '12h', '24h')
+ * @param t - Optional i18n translation function
+ * @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
+ */
+export function formatETA(
+  remainingMinutes: number,
+  timeFormat: 'system' | '12h' | '24h' = 'system',
+  t?: (key: string) => string
+): string {
+  const now = new Date();
+  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
+  
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+  const etaDay = new Date(eta);
+  etaDay.setHours(0, 0, 0, 0);
+
+  const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
+  if (timeFormat === '12h') timeOptions.hour12 = true;
+  else if (timeFormat === '24h') timeOptions.hour12 = false;
+
+  const timeStr = eta.toLocaleTimeString([], timeOptions);
+  const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
+
+  if (dayDiff === 0) return timeStr;
+  if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`;
+  return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`;
+}
+
+/**
+ * Format a duration in seconds to a human-readable string, with null handling.
+ *
+ * @param seconds - Duration in seconds, or null/undefined
+ * @returns Formatted string (e.g., "2h 30m", "45m") or "--" if no value
+ */
+export function formatDuration(seconds: number | null | undefined): string {
+  if (seconds == null || seconds < 0) return '--';
+  
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  
+  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}

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


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


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


+ 2 - 2
static/index.html

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

+ 1 - 1
test_all.sh

@@ -1,3 +1,3 @@
 #!/bin/bash
 #!/bin/bash
 
 
-./test_frontend.sh && ./test_backend.sh --full && ./test_docker.sh
+./test_frontend.sh && ./test_backend.sh --full && ./test_docker.sh && ./test_security.sh --full

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