Browse Source

v0.2.2.2 (#830)

v0.2.2.2 (#830)
MartinNYHC 2 months ago
parent
commit
67f91d94de
100 changed files with 8833 additions and 850 deletions
  1. 3 0
      .gitignore
  2. 51 1
      CHANGELOG.md
  3. 1 0
      Dockerfile
  4. 3 19
      README.md
  5. 65 15
      backend/app/api/routes/camera.py
  6. 52 8
      backend/app/api/routes/inventory.py
  7. 332 21
      backend/app/api/routes/library.py
  8. 2 0
      backend/app/api/routes/notifications.py
  9. 1 0
      backend/app/api/routes/printers.py
  10. 276 62
      backend/app/api/routes/spoolbuddy.py
  11. 2 2
      backend/app/core/config.py
  12. 41 7
      backend/app/core/database.py
  13. 16 0
      backend/app/core/websocket.py
  14. 32 5
      backend/app/main.py
  15. 1 0
      backend/app/models/notification.py
  16. 6 0
      backend/app/models/notification_template.py
  17. 1 0
      backend/app/models/smart_plug.py
  18. 3 0
      backend/app/models/spoolbuddy_device.py
  19. 19 0
      backend/app/schemas/library.py
  20. 5 0
      backend/app/schemas/notification.py
  21. 15 0
      backend/app/schemas/notification_template.py
  22. 1 0
      backend/app/schemas/printer.py
  23. 2 0
      backend/app/schemas/smart_plug.py
  24. 28 4
      backend/app/schemas/spoolbuddy.py
  25. 4 0
      backend/app/services/background_dispatch.py
  26. 136 20
      backend/app/services/bambu_mqtt.py
  27. 41 0
      backend/app/services/notification_service.py
  28. 2 0
      backend/app/services/print_scheduler.py
  29. 18 4
      backend/app/services/printer_manager.py
  30. 7 3
      backend/app/services/smart_plug_manager.py
  31. 169 0
      backend/app/services/spool_assignment_notifications.py
  32. 178 10
      backend/app/services/spool_tag_matcher.py
  33. 247 0
      backend/app/services/spoolbuddy_ssh.py
  34. 99 47
      backend/app/services/usage_tracker.py
  35. 31 0
      backend/app/services/virtual_printer/tcp_proxy.py
  36. 24 0
      backend/app/utils/tag_normalization.py
  37. 1 0
      backend/tests/conftest.py
  38. 378 0
      backend/tests/integration/test_external_folders_api.py
  39. 36 0
      backend/tests/integration/test_notifications_api.py
  40. 71 45
      backend/tests/integration/test_spoolbuddy.py
  41. 485 0
      backend/tests/unit/services/test_bambu_mqtt.py
  42. 12 6
      backend/tests/unit/services/test_printer_manager.py
  43. 143 0
      backend/tests/unit/services/test_smart_plug_manager.py
  44. 77 0
      backend/tests/unit/services/test_spool_assignment_notifications.py
  45. 385 0
      backend/tests/unit/services/test_spool_tag_matcher.py
  46. 340 0
      backend/tests/unit/services/test_spoolbuddy_ssh.py
  47. 7 3
      backend/tests/unit/services/test_usage_tracker.py
  48. 10 0
      backend/tests/unit/services/test_virtual_printer.py
  49. 122 0
      backend/tests/unit/test_spoolbuddy_system_stats.py
  50. 102 1
      backend/tests/unit/test_usage_tracker.py
  51. 1 0
      docker-compose.yml
  52. 4 0
      docker-publish-daily-beta.sh
  53. 22 10
      frontend/index.html
  54. 4 4
      frontend/package-lock.json
  55. 10 8
      frontend/public/sw.js
  56. 2 0
      frontend/src/App.tsx
  57. 46 0
      frontend/src/__tests__/components/SmartPlugCard.test.tsx
  58. 56 17
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  59. 302 0
      frontend/src/__tests__/pages/FileManagerExternalFolder.test.tsx
  60. 143 3
      frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx
  61. 5 1
      frontend/src/__tests__/utils/currency.test.ts
  62. 66 3
      frontend/src/api/client.ts
  63. 1 0
      frontend/src/components/AssignSpoolModal.tsx
  64. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  65. 19 0
      frontend/src/components/SmartPlugCard.tsx
  66. 56 14
      frontend/src/components/SpoolFormModal.tsx
  67. 11 5
      frontend/src/components/VirtualKeyboard.tsx
  68. 176 4
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  69. 172 0
      frontend/src/components/spoolbuddy/DiagnosticModal.tsx
  70. 10 0
      frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx
  71. 97 14
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  72. 4 1
      frontend/src/contexts/ToastContext.tsx
  73. 51 1
      frontend/src/hooks/useWebSocket.ts
  74. 32 0
      frontend/src/i18n/locales/de.ts
  75. 32 0
      frontend/src/i18n/locales/en.ts
  76. 19 0
      frontend/src/i18n/locales/fr.ts
  77. 19 0
      frontend/src/i18n/locales/it.ts
  78. 19 0
      frontend/src/i18n/locales/ja.ts
  79. 19 0
      frontend/src/i18n/locales/pt-BR.ts
  80. 19 0
      frontend/src/i18n/locales/zh-CN.ts
  81. 32 0
      frontend/src/index.css
  82. 211 2
      frontend/src/pages/FileManagerPage.tsx
  83. 1 1
      frontend/src/pages/InventoryPage.tsx
  84. 10 10
      frontend/src/pages/PrintersPage.tsx
  85. 0 2
      frontend/src/pages/SettingsPage.tsx
  86. 298 10
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  87. 31 13
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  88. 481 0
      frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx
  89. 418 127
      frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx
  90. 596 150
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  91. 1 0
      frontend/src/utils/currency.ts
  92. 1 1
      install/install.sh
  93. 11 30
      spoolbuddy/README.md
  94. 51 9
      spoolbuddy/daemon/api_client.py
  95. 206 118
      spoolbuddy/daemon/main.py
  96. 238 0
      spoolbuddy/daemon/nau7802.py
  97. 18 7
      spoolbuddy/daemon/nfc_reader.py
  98. 570 0
      spoolbuddy/daemon/pn5180.py
  99. 8 2
      spoolbuddy/daemon/scale_reader.py
  100. 137 0
      spoolbuddy/daemon/system_stats.py

+ 3 - 0
.gitignore

@@ -64,6 +64,9 @@ data/
 # JWT secret file (should be in data dir, but protect project root too)
 # JWT secret file (should be in data dir, but protect project root too)
 .jwt_secret
 .jwt_secret
 
 
+# SpoolBuddy SSH keys (generated at runtime for remote updates)
+spoolbuddy/ssh/
+
 # Security scan output
 # Security scan output
 *.sarif
 *.sarif
 
 

+ 51 - 1
CHANGELOG.md

@@ -2,6 +2,54 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.2.2.2] - 2026-03-27
+
+### New Features
+- **Persistent Auto-Off for Smart Plugs** ([#826](https://github.com/maziggy/bambuddy/issues/826)) — Smart plugs now have a "Keep Enabled" toggle under Auto Off settings. When enabled, auto-off stays active between prints instead of requiring manual re-enablement after each print (one-shot). Useful for accessories like BentoBox filters on Home Assistant switches that should always power off when a print completes. Default behavior (one-shot) is unchanged. Requested by @AeroMaestro.
+- **Missing Spool Assignment Notification** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — When a print starts and the AMS mapping references tray slots without assigned spools, Bambuddy now shows a warning toast in the frontend and can send push notifications via any configured notification provider. The notification includes the printer name, missing slot labels (e.g. A2, Ext-L), and expected material profile. A new "Missing Spool Assignment" toggle is available under Print Events in notification provider settings (off by default). Fully integrated with i18n (all 7 locales). Contributed by @Keybored02.
+- **Mid-Print Spool Reassignment Tracking** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — Usage tracking now correctly handles spool changes during a print. If a spool assignment is changed after a print starts, the system uses the live assignment for filament deduction; otherwise it falls back to the snapshot taken at print start. This ensures accurate filament tracking even when swapping spools mid-print. Contributed by @Keybored02.
+- **Auto-Link Untagged Inventory Spools on AMS Insert** ([#538](https://github.com/maziggy/bambuddy/issues/538)) — When a Bambu Lab spool is inserted into the AMS and no existing tag match is found, the system now checks if there is an untagged inventory spool with the same material, subtype, and color. If found, the RFID tag is automatically linked to that existing spool instead of creating a duplicate entry. Uses FIFO ordering (oldest spool first) so spools are consumed in purchase order. Matching is case-insensitive. Requested by @wreuel.
+- **Ukrainian Hryvnia Currency** ([#815](https://github.com/maziggy/bambuddy/issues/815)) — Added Ukrainian Hryvnia (UAH/₴) to the list of known currencies for filament cost tracking. Requested by @vlad-bil.
+- **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
+
+### Improved
+- **SpoolBuddy AMS Slot Action Picker** — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), and either Assign Spool / Link to Spoolman (when no spool is mapped) or Unassign / Unlink (when one is). Works with both internal inventory and Spoolman. Previously the slot click went straight to the configure modal with no way to manage spool assignments.
+- **Unassign Button in Edit Spool Modal** — The edit spool modal now has an "Unassign" button next to "Delete Tag" that removes the spool's AMS slot assignment, clearing the location column in the inventory table.
+- **SpoolBuddy Settings Device Tab No Longer Scrolls** — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.
+- **Spool Notes in Assign Spool Modal** ([#793](https://github.com/maziggy/bambuddy/issues/793)) — Spool cards in the Assign Spool modal now show the spool's note as a hover tooltip, making it easier to identify spools by tracking IDs or other metadata stored in notes. Works with both internal inventory and Spoolman-synced spools. Requested by @LegionCanadian.
+- **WiFi Safeguard for SpoolBuddy Pi** — The install script now drops an APT hook (`/etc/apt/apt.conf.d/80-preserve-wifi`) that backs up NetworkManager WiFi connections before every `apt upgrade` and restores them if they get wiped. Prevents headless SpoolBuddy Pis from losing WiFi connectivity after Raspberry Pi OS package upgrades (observed with Bookworm kernel/raspi-config updates that clear `/etc/NetworkManager/system-connections/`).
+- **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
+- **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
+- **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
+- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool circles (matching AMS page style), material/subtype labels, color dots, fill level bars, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and touch-friendly inline filter pills ("All", "In AMS", per-material). Tapping a spool opens a full-screen detail view with spool icon, remaining bar, AMS assignment, weight breakdown, slicer filament, PA K-profiles (name and value), temperature range, cost, tag ID, and notes. Detail view updates live from query data. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.
+- **SpoolBuddy Auto-Navigate on Tag Scan** — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.
+- **SpoolBuddy Swipe to Switch Printers** — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.
+- **SpoolBuddy Virtual Keyboard Layout Fix** — The virtual keyboard now participates in the flex layout instead of overlaying as a fixed element. When the keyboard opens, the bottom nav and status bar are hidden and the content area shrinks to fit, eliminating the dead space gap between content and keyboard on the Inventory page. Number inputs (e.g. Weight field on Write Tag) now accept virtual keyboard input.
+- **Removed Diagnostic Buttons from Write Tag Page** — Removed the "NFC Diag" and "Scale Diag" buttons from the NFC status panel on the Write Tag page. These diagnostics are accessible from the Settings page and don't belong on the tag writing flow.
+- **SpoolBuddy Assign Spool Modal No Longer Clips Display** — The shared Assign Spool modal overflowed off-screen on the small SpoolBuddy touchscreen, hiding the footer buttons. Added scoped CSS in the SpoolBuddy AMS page that caps the modal at 90vh with a scrollable spool list, without affecting the main Bambuddy frontend.
+- **SpoolBuddy System Tab** — Added a "System" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from `/proc` and `/sys` — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.
+- **SpoolBuddy Boot Splash Polished** — New splash image displays only the SpoolBuddy logo (removed Bambuddy branding) with green glow bloom, radial gradient background, light rays, and vignette. A generator script (`generate_splash.py`) is included for easy customization. Also reduced redundant initramfs rebuilds during install by deferring the rebuild until after the Plymouth theme is configured.
+
+### Fixed
+- **Print Fails on Files With Spaces in Name** ([#824](https://github.com/maziggy/bambuddy/issues/824)) — Printing files with spaces in their filename (e.g. "Junktion Box PRO 90.3mf") caused the printer to silently ignore the print command and remain IDLE. The FTP upload succeeded, but the MQTT print command's `url` field (`ftp://file name.3mf`) contained unencoded spaces that the firmware couldn't parse. Fixed by replacing spaces with underscores in the remote filename before upload. Reported by @benjamdev.
+- **SpoolBuddy Low Filament Warning Missing Slot Number** — The status bar low filament warning showed "AMS B" instead of the specific slot like "B2". Now uses `formatSlotLabel` to display the full slot label (e.g. "Low Filament: PLA (B2) - 4% remaining").
+- **SpoolBuddy Read Tag Diagnostic Fails on NTAG Tags** — The `read_tag.py` diagnostic script had five issues preventing NTAG reads: (1) SAK `0x04` (MIFARE Ultralight family) was rejected as "unsupported tag type" — now accepts both `0x00` and `0x04`. (2) `ntag_read_pages` had TX CRC off (should be on per NTAG spec), no Crypto1 clear, and no IDLE→TRANSCEIVE state reset. (3) The PN5180 enters an unrecoverable state after an NTAG READ command — added full GPIO hardware reset between each 4-page batch. (4) Reading past the end of smaller tags (MIFARE Ultralight has 16 pages vs NTAG's 44+) caused a hard failure — now returns partial data gracefully. (5) `ntag_write_page`/`ntag_write_pages` had the same stale CRC/state issues plus unreliable ACK checking and post-write verification — synced with daemon.
+- **Delete Tag Leaves Stale Tag Type** — The "Delete Tag" button in the spool edit modal only cleared `tag_uid` but left `tray_uuid`, `tag_type`, and `data_origin` intact. All tag-related fields are now cleared together.
+- **SpoolBuddy NFC Write Fails on NTAG Tags** — Multiple issues prevented writing to NTAG 213/215/216 tags. (1) Some chips report SAK `0x04` (MIFARE Ultralight family) instead of `0x00` during anticollision — both `0x00` and `0x04` are now accepted. (2) TX CRC was disabled for NTAG commands but the spec requires it — enabled for both WRITE and READ. (3) The PN5180 state machine needed IDLE→TRANSCEIVE resets (not just `set_transceive_mode()`) and Crypto1 cleared before NTAG operations. (4) The 4-bit WRITE ACK cannot be captured by the PN5180 (SOF detected but no RX_IRQ) — removed per-page ACK checking. (5) Post-write read-back verification also failed (second READ command gets no response from the PN5180) — removed verification since the tag reliably ACKs each write.
+- **Database Connection Pool Exhaustion on Large Printer Farms** — Users with 100+ printers connected simultaneously experienced `QueuePool limit of size 10 overflow 20 reached, connection timed out` errors. Increased the SQLAlchemy connection pool from 30 total (10 base + 20 overflow) to 220 (20 base + 200 overflow), and raised the SQLite busy_timeout from 5 to 15 seconds to reduce write contention under heavy concurrent MQTT updates.
+- **SpoolBuddy Update Check Always Shows "Up to Date"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.
+- **SpoolBuddy Updates Now Use SSH** — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on `.git/`, hardcoded `main` branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it to `authorized_keys` on first connect. The install script creates the `spoolbuddy` user with a bash shell and sudoers entries for daemon and kiosk restart. A "Force Update" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed.
+- **Frontend Not Updating After Deploy** — The service worker used stale-while-revalidate for JS/CSS assets, serving the old cached bundle even after a new build was deployed. Changed to network-first for JS/CSS (Vite content-hashes filenames so cache-busting is built in), bumped SW cache version, and added `Cache-Control: no-cache` to the `sw.js` endpoint so browsers always pick up new service worker versions immediately. The SpoolBuddy kiosk now skips SW registration entirely and unregisters any existing SW — a touchscreen kiosk has no use for offline caching and it was the main source of stale frontend issues after updates.
+- **SpoolBuddy Kiosk Starts Before Network Is Ready** — On fresh installs, the kiosk browser launched before the network was fully up, showing a connection error for 10-15 seconds until connectivity was restored. The getty@tty1 autologin override now waits for `network-online.target` so Chromium has connectivity when it starts.
+- **SpoolBuddy Update UI Stale After Restart** — After a SpoolBuddy update, the UI permanently showed the old version and "update available" because: (1) the SSH update set status to `"complete"` after the daemon had already re-registered, overwriting the cleared state; (2) the kiosk restart navigated away from the updates page; (3) query cache served stale data. Fixed by letting daemon re-registration clear all update status, removing the kiosk restart in favor of a frontend-driven `window.location.reload()` triggered via WebSocket when the daemon comes back online, and adding proper loading states to Check/Force Update buttons.
+- **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Also added ports 2024-2026 to the docker-compose.yml bridge-mode port mapping. Reported by @Utility9298.
+- **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Truly empty slots now hide both buttons, while slots with a spool inserted but filament not loaded still show configure/assign. Also fixed stale AMS slot data on H2D and other printers that only send `{id, state}` in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect. Reported by @RosdasHH.
+- **Spoolman Sidebar Opens Root URL Instead of Spool Page** — When Spoolman is enabled, clicking the Filament sidebar item embedded Spoolman at its root URL instead of the spool management page. The iframe now navigates to `<spoolman_url>/spool`.
+- **Log Flood: "State is FINISH but completion NOT triggered"** ([#790](https://github.com/maziggy/bambuddy/issues/790)) — A diagnostic log message introduced in 0.2.2.1 fired on every MQTT update while a printer sat in FINISH or FAILED state, flooding logs with thousands of lines per minute in printer farms. Fixed by only logging once on the initial state transition, and marking `_completion_triggered = True` when a terminal state is first seen without a prior RUNNING state so the flag is clean for the next print cycle. Reported by @user.
+- **H2D External Spool Print Fails With "Failed to get AMS mapping table"** ([#797](https://github.com/maziggy/bambuddy/issues/797)) — Printing from an external spool on H2D (and H2D Pro) through Bambuddy failed with `0700_8012 "Failed to get AMS mapping table"`, while the same print worked fine from BambuStudio. Bambuddy was passing raw virtual tray IDs (254/255) in the flat `ams_mapping` array, but BambuStudio converts these to -1 and relies on `ams_mapping2` for external spool routing. The H2D firmware rejects raw 254/255 in the flat array. Also fixed the `ams_mapping2` format for external trays — each virtual tray is its own AMS unit with `slot_id: 0`, not a shared unit differentiated by slot. Reported by @Lukas-ESG.
+- **SpoolBuddy Scale First Reading Always Wrong** — The NAU7802 ADC always returns a stale max-scale value (`0x7FFFFF`) on its first conversion after power-up, which polluted the moving average and made the initial weight report wildly inaccurate. Fixed by flushing the first reading during `init()` so all subsequent reads return valid data. Also extracted both hardware drivers out of diagnostic scripts into proper modules — the NAU7802 scale driver from `scripts/scale_diag.py` into `daemon/nau7802.py`, and the PN5180 NFC driver from `scripts/read_tag.py` into `daemon/pn5180.py`. The production daemon was importing driver classes from test scripts since the original SpoolBuddy commit. Removed the now-unnecessary `sys.path` hack from `main.py`.
+- **ffmpeg Process Leak Causing Memory Growth** ([#776](https://github.com/maziggy/bambuddy/issues/776)) — Camera stream ffmpeg processes accumulated over time, consuming several GB of RAM. When a user closed the camera viewer, the frontend sent a stop signal that killed the ffmpeg process, but the backend stream generator interpreted the dead process as a dropped connection and respawned ffmpeg — up to 30 reconnection attempts per stream. The orphan cleanup couldn't catch these because they were tracked as "active". Fixed by signaling the generator's disconnect event from the stop endpoint before killing the process, checking for stream removal before reconnecting, and tracking frame timestamps per-stream instead of per-printer so stale detection works correctly when multiple streams exist. Reported by @ChrisTheDBA, confirmed by @peter-k-de.
+
 ## [0.2.2.1] - 2026-03-22
 ## [0.2.2.1] - 2026-03-22
 
 
 ### New Features
 ### New Features
@@ -14,6 +62,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Daemon Reports Stale Version** — The SpoolBuddy daemon maintained its own hardcoded `__version__` that was never bumped to `0.2.3b1`, causing the update check to incorrectly show an update from `0.2.2b1` to the latest release. Fixed by reading the version at import time from the backend's `APP_VERSION` in `backend/app/core/config.py` — the single source of truth — so the daemon version is always in sync.
 - **SpoolBuddy Daemon Reports Stale Version** — The SpoolBuddy daemon maintained its own hardcoded `__version__` that was never bumped to `0.2.3b1`, causing the update check to incorrectly show an update from `0.2.2b1` to the latest release. Fixed by reading the version at import time from the backend's `APP_VERSION` in `backend/app/core/config.py` — the single source of truth — so the daemon version is always in sync.
 - **SpoolBuddy Update Columns Missing from Database** — The OTA update feature added `update_status` and `update_message` to the device model but was missing the database migration, causing "no such column" errors on existing installations.
 - **SpoolBuddy Update Columns Missing from Database** — The OTA update feature added `update_status` and `update_message` to the device model but was missing the database migration, causing "no such column" errors on existing installations.
 - **Queue Print Command Not Reaching Printer** ([#778](https://github.com/maziggy/bambuddy/issues/778)) — When a queue item targeted a specific printer and the scheduler's power-on-wait loop triggered, each reconnection attempt created a new MQTT client that re-attempted subscribing to the request topic. On printers whose broker rejects this subscription (e.g. A1), this caused repeated connect/disconnect cycles for up to 170 seconds, leaving the MQTT connection in a fragile state where the print command could silently fail to reach the printer. Fixed by caching request topic support state per serial number at the class level, so new client instances skip the subscription immediately instead of rediscovering the rejection. Reported by @RubenKremer.
 - **Queue Print Command Not Reaching Printer** ([#778](https://github.com/maziggy/bambuddy/issues/778)) — When a queue item targeted a specific printer and the scheduler's power-on-wait loop triggered, each reconnection attempt created a new MQTT client that re-attempted subscribing to the request topic. On printers whose broker rejects this subscription (e.g. A1), this caused repeated connect/disconnect cycles for up to 170 seconds, leaving the MQTT connection in a fragile state where the print command could silently fail to reach the printer. Fixed by caching request topic support state per serial number at the class level, so new client instances skip the subscription immediately instead of rediscovering the rejection. Reported by @RubenKremer.
+- **Stale MQTT Connection Not Recovering** ([#813](https://github.com/maziggy/bambuddy/issues/813)) — When a printer's MQTT connection went stale (no messages for 60+ seconds), Bambuddy marked it as disconnected but did not force the underlying TCP socket closed, so paho-mqtt's auto-reconnect never triggered and print commands were silently published into a dead connection. Fixed by force-closing the socket on stale detection so paho's loop thread detects the break and auto-reconnects. The initial fix caused rapid connected/disconnected bouncing in the UI because frontend status polls triggered repeated socket force-closes before paho could finish reconnecting; added a 30-second cooldown between stale reconnect attempts so paho has time to re-establish the connection. Also uses a flag to suppress the redundant disconnect callback broadcast. Relaxed MQTT keepalive from 15s to 30s — the aggressive 15s keepalive caused spurious disconnects on transient network hiccups. Added reconnect backoff (1-30s) and unique-per-process MQTT client IDs to prevent broker session takeovers. Error disconnects (`rc.is_failure`) are never suppressed by the spurious-disconnect filter. The disconnect event used by `disconnect()` is fired unconditionally at the top of the callback so that no early-return filter can prevent it from unblocking callers. Reported by @inkdawgz.
+- **P1S/P1P Printer Card Shows "Printing" When Idle** ([#813](https://github.com/maziggy/bambuddy/issues/813)) — Some P1S and P1P firmware versions report `stg_cur=0` when idle, which maps to the "Printing" stage name and overrides the correct "Idle" gcode_state on the printer card. The System Info page was unaffected because it displays the raw gcode_state. Extended the existing A1/A1 Mini workaround for this firmware bug to also cover P1S and P1P models. Reported by @inkdawgz.
 - **AMS Slot Search Shows Unrelated Profiles** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Searching for a non-existent filament profile in the AMS slot configuration showed unrelated profiles instead of an empty result. The saved preset bypassed the search filter entirely, so stale mappings (e.g. a slot previously configured with "Bambu PLA Matte" that now holds a Silk spool) would always appear regardless of the search query. The saved preset now only bypasses the printer model filter, not the search filter. Reported by @RosdasHH.
 - **AMS Slot Search Shows Unrelated Profiles** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Searching for a non-existent filament profile in the AMS slot configuration showed unrelated profiles instead of an empty result. The saved preset bypassed the search filter entirely, so stale mappings (e.g. a slot previously configured with "Bambu PLA Matte" that now holds a Silk spool) would always appear regardless of the search query. The saved preset now only bypasses the printer model filter, not the search filter. Reported by @RosdasHH.
 - **Virtual Printer FTP Routed to Wrong VP** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables `REDIRECT` rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires `CAP_NET_BIND_SERVICE` (already set in the systemd service and Docker image). Also removed a global `set_exception_handler()` in the MQTT server that caused spurious error messages when running multiple VPs. See `docs/migration-vp-ftp-port.md` for migration steps. Reported by @VREmma.
 - **Virtual Printer FTP Routed to Wrong VP** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables `REDIRECT` rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires `CAP_NET_BIND_SERVICE` (already set in the systemd service and Docker image). Also removed a global `set_exception_handler()` in the MQTT server that caused spurious error messages when running multiple VPs. See `docs/migration-vp-ftp-port.md` for migration steps. Reported by @VREmma.
 - **X1C Virtual Printer Not Accepting Sends** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (`3DPrinter-X1-Carbon` / `3DPrinter-X1`) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (`BL-P001` / `BL-P002`). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. `X1C`) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.
 - **X1C Virtual Printer Not Accepting Sends** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (`3DPrinter-X1-Carbon` / `3DPrinter-X1`) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (`BL-P001` / `BL-P002`). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. `X1C`) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.
@@ -88,7 +138,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
-- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`.
+- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`. Fixed auto-generated "Contributors" section appearing in GitHub release notes by stripping `@mentions` from changelog text before creating the release.
 - **Inventory Scale Weight Check Column** — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.
 - **Inventory Scale Weight Check Column** — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.
 
 
 ### Fixed
 ### Fixed

+ 1 - 0
Dockerfile

@@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     ffmpeg \
     ffmpeg \
     iproute2 \
     iproute2 \
     libcap2-bin \
     libcap2-bin \
+    openssh-client \
     && rm -rf /var/lib/apt/lists/*
     && rm -rf /var/lib/apt/lists/*
 
 
 # Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.
 # Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.

+ 3 - 19
README.md

@@ -122,6 +122,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 
 
 ### 📁 File Manager (Library)
 ### 📁 File Manager (Library)
 - Upload and organize sliced files (3MF, gcode, STL)
 - Upload and organize sliced files (3MF, gcode, STL)
+- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files
 - **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - ZIP file extraction with folder structure preservation
 - ZIP file extraction with folder structure preservation
 - Option to create folder from ZIP filename
 - Option to create folder from ZIP filename
@@ -153,6 +154,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Customizable message templates with per-filament usage details
 - Customizable message templates with per-filament usage details
 - Print finish photo URL in notifications
 - Print finish photo URL in notifications
 - Filament usage and progress in failed/cancelled print notifications
 - Filament usage and progress in failed/cancelled print notifications
+- **Missing spool assignment warning** — Toast and push notification when a print starts with unassigned AMS trays
 - HMS error alerts (AMS, nozzle, etc.)
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
 - Build plate detection alerts
 - First layer complete alert (with camera snapshot)
 - First layer complete alert (with camera snapshot)
@@ -162,6 +164,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 ### 🧵 Spool Inventory
 ### 🧵 Spool Inventory
 - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
 - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
 - Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
 - Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
+- Mid-print spool reassignment support: uses live assignment if changed during print, snapshot otherwise
 - Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
 - Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
 - **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
 - **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
@@ -502,25 +505,6 @@ services:
 
 
 </details>
 </details>
 
 
-#### Windows (Portable Launcher)
-
-The easiest way to run Bambuddy on Windows - no installation required:
-
-```batch
-git clone https://github.com/maziggy/bambuddy.git
-cd bambuddy
-start_bambuddy.bat
-```
-
-Double-click `start_bambuddy.bat` and it will:
-- Download Python and Node.js automatically (portable, no system changes)
-- Install dependencies and build the frontend
-- Open your browser to http://localhost:8000
-
-Everything is stored in the `.portable\` folder. Use `start_bambuddy.bat reset` to clean up.
-
-> **Custom port:** `set PORT=9000 & start_bambuddy.bat`
-
 #### Manual Installation (Linux/macOS)
 #### Manual Installation (Linux/macOS)
 
 
 ```bash
 ```bash

+ 65 - 15
backend/app/api/routes/camera.py

@@ -52,6 +52,13 @@ _active_external_streams: set[int] = set()
 # Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
 # Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
 _spawned_ffmpeg_pids: dict[int, float] = {}
 _spawned_ffmpeg_pids: dict[int, float] = {}
 
 
+# Track disconnect events per stream_id — allows stop endpoint and cleanup
+# to signal generators to stop reconnecting instead of just killing the process
+_disconnect_events: dict[str, asyncio.Event] = {}
+
+# Track last frame time per stream_id (not just per printer_id) for stale detection
+_stream_last_frame_times: dict[str, float] = {}
+
 
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
     """Get the last buffered frame for a printer from an active stream.
@@ -85,6 +92,10 @@ async def generate_chamber_mjpeg_stream(
     """
     """
     logger.info("Starting chamber image stream for %s (stream_id=%s, model=%s)", ip_address, stream_id, model)
     logger.info("Starting chamber image stream for %s (stream_id=%s, model=%s)", ip_address, stream_id, model)
 
 
+    # Register disconnect event so stop endpoint can signal us
+    if stream_id and disconnect_event:
+        _disconnect_events[stream_id] = disconnect_event
+
     connection = await generate_chamber_image_stream(ip_address, access_code, fps)
     connection = await generate_chamber_image_stream(ip_address, access_code, fps)
     if connection is None:
     if connection is None:
         logger.error("Failed to connect to chamber image stream for %s", ip_address)
         logger.error("Failed to connect to chamber image stream for %s", ip_address)
@@ -145,9 +156,11 @@ async def generate_chamber_mjpeg_stream(
     except Exception as e:
     except Exception as e:
         logger.exception("Chamber image stream error: %s", e)
         logger.exception("Chamber image stream error: %s", e)
     finally:
     finally:
-        # Remove from active streams
-        if stream_id and stream_id in _active_chamber_streams:
-            del _active_chamber_streams[stream_id]
+        # Remove from active streams and disconnect events
+        if stream_id:
+            _active_chamber_streams.pop(stream_id, None)
+            _disconnect_events.pop(stream_id, None)
+            _stream_last_frame_times.pop(stream_id, None)
 
 
         # Clean up frame buffer and timestamps
         # Clean up frame buffer and timestamps
         if printer_id is not None:
         if printer_id is not None:
@@ -263,6 +276,10 @@ async def generate_rtsp_mjpeg_stream(
         "-",  # Output to stdout
         "-",  # Output to stdout
     ]
     ]
 
 
+    # Register disconnect event so stop endpoint can signal us
+    if stream_id and disconnect_event:
+        _disconnect_events[stream_id] = disconnect_event
+
     logger.info(
     logger.info(
         "Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s)", ip_address, stream_id, model, fps
         "Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s)", ip_address, stream_id, model, fps
     )
     )
@@ -377,6 +394,8 @@ async def generate_rtsp_mjpeg_stream(
 
 
                             _last_frames[printer_id] = frame
                             _last_frames[printer_id] = frame
                             _last_frame_times[printer_id] = time.time()
                             _last_frame_times[printer_id] = time.time()
+                            if stream_id:
+                                _stream_last_frame_times[stream_id] = time.time()
 
 
                         yield (
                         yield (
                             b"--frame\r\n"
                             b"--frame\r\n"
@@ -408,6 +427,11 @@ async def generate_rtsp_mjpeg_stream(
             if client_gone:
             if client_gone:
                 break
                 break
 
 
+            # Check if stream was explicitly stopped (e.g., by stop endpoint)
+            if stream_id and stream_id not in _active_streams:
+                logger.info("Stream %s removed from active streams, stopping reconnect", stream_id)
+                break
+
             if stream_ended:
             if stream_ended:
                 reconnect_count += 1
                 reconnect_count += 1
                 continue
                 continue
@@ -433,9 +457,11 @@ async def generate_rtsp_mjpeg_stream(
     except Exception as e:
     except Exception as e:
         logger.exception("Camera stream error: %s", e)
         logger.exception("Camera stream error: %s", e)
     finally:
     finally:
-        # Remove from active streams
-        if stream_id and stream_id in _active_streams:
-            del _active_streams[stream_id]
+        # Remove from active streams and disconnect events
+        if stream_id:
+            _active_streams.pop(stream_id, None)
+            _disconnect_events.pop(stream_id, None)
+            _stream_last_frame_times.pop(stream_id, None)
 
 
         # Clean up frame buffer and timestamps
         # Clean up frame buffer and timestamps
         if printer_id is not None:
         if printer_id is not None:
@@ -639,6 +665,10 @@ async def stop_camera_stream(
     for stream_id, process in list(_active_streams.items()):
     for stream_id, process in list(_active_streams.items()):
         if stream_id.startswith(f"{printer_id}-"):
         if stream_id.startswith(f"{printer_id}-"):
             to_remove.append(stream_id)
             to_remove.append(stream_id)
+            # Signal the generator to stop reconnecting BEFORE killing the process
+            event = _disconnect_events.get(stream_id)
+            if event:
+                event.set()
             if process.returncode is None:
             if process.returncode is None:
                 try:
                 try:
                     process.terminate()
                     process.terminate()
@@ -658,12 +688,18 @@ async def stop_camera_stream(
 
 
     for stream_id in to_remove:
     for stream_id in to_remove:
         _active_streams.pop(stream_id, None)
         _active_streams.pop(stream_id, None)
+        _disconnect_events.pop(stream_id, None)
+        _stream_last_frame_times.pop(stream_id, None)
 
 
     # Stop chamber image streams
     # Stop chamber image streams
     to_remove_chamber = []
     to_remove_chamber = []
     for stream_id, (_reader, writer) in list(_active_chamber_streams.items()):
     for stream_id, (_reader, writer) in list(_active_chamber_streams.items()):
         if stream_id.startswith(f"{printer_id}-"):
         if stream_id.startswith(f"{printer_id}-"):
             to_remove_chamber.append(stream_id)
             to_remove_chamber.append(stream_id)
+            # Signal the generator to stop
+            event = _disconnect_events.get(stream_id)
+            if event:
+                event.set()
             try:
             try:
                 writer.close()
                 writer.close()
                 stopped += 1
                 stopped += 1
@@ -673,6 +709,8 @@ async def stop_camera_stream(
 
 
     for stream_id in to_remove_chamber:
     for stream_id in to_remove_chamber:
         _active_chamber_streams.pop(stream_id, None)
         _active_chamber_streams.pop(stream_id, None)
+        _disconnect_events.pop(stream_id, None)
+        _stream_last_frame_times.pop(stream_id, None)
 
 
     logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
     logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
     return {"stopped": stopped}
     return {"stopped": stopped}
@@ -1311,24 +1349,36 @@ async def cleanup_orphaned_streams():
             _spawned_ffmpeg_pids.pop(proc.pid, None)
             _spawned_ffmpeg_pids.pop(proc.pid, None)
         cleaned += 1
         cleaned += 1
 
 
-    # 4. Kill stale active streams (alive but no frames for >60s)
+    # 4. Kill stale active streams (alive but no frames for >30s)
+    # Uses per-stream timestamps to avoid false "fresh" readings from newer streams
     for sid, proc in list(_active_streams.items()):
     for sid, proc in list(_active_streams.items()):
         if proc.returncode is not None:
         if proc.returncode is not None:
             continue
             continue
-        try:
-            printer_id = int(sid.split("-", 1)[0])
-        except (ValueError, IndexError):
-            continue
-        start_time = _stream_start_times.get(printer_id, now)
-        last_frame = _last_frame_times.get(printer_id, start_time)
-        if now - start_time > 120 and now - last_frame > 60:
-            logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - last_frame)
+        # Per-stream frame time is authoritative; fall back to per-printer
+        stream_last_frame = _stream_last_frame_times.get(sid)
+        if stream_last_frame is None:
+            try:
+                printer_id = int(sid.split("-", 1)[0])
+            except (ValueError, IndexError):
+                continue
+            stream_last_frame = _last_frame_times.get(printer_id)
+        spawn_time = _spawned_ffmpeg_pids.get(proc.pid, now)
+        if stream_last_frame is None:
+            stream_last_frame = spawn_time
+        if now - spawn_time > 60 and now - stream_last_frame > 30:
+            logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - stream_last_frame)
+            # Signal the generator to stop reconnecting
+            event = _disconnect_events.get(sid)
+            if event:
+                event.set()
             try:
             try:
                 proc.kill()
                 proc.kill()
                 await proc.wait()
                 await proc.wait()
             except (ProcessLookupError, OSError):
             except (ProcessLookupError, OSError):
                 pass
                 pass
             _active_streams.pop(sid, None)
             _active_streams.pop(sid, None)
+            _disconnect_events.pop(sid, None)
+            _stream_last_frame_times.pop(sid, None)
             _spawned_ffmpeg_pids.pop(proc.pid, None)
             _spawned_ffmpeg_pids.pop(proc.pid, None)
             cleaned += 1
             cleaned += 1
 
 

+ 52 - 8
backend/app/api/routes/inventory.py

@@ -13,6 +13,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
@@ -32,6 +33,7 @@ from backend.app.schemas.spool import (
 )
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
 from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
+from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -1099,6 +1101,16 @@ async def assign_spool(
     resp = result.scalar_one()
     resp = result.scalar_one()
     response = SpoolAssignmentResponse.model_validate(resp)
     response = SpoolAssignmentResponse.model_validate(resp)
     response.configured = configured
     response.configured = configured
+
+    await ws_manager.broadcast(
+        {
+            "type": "spool_assignment_changed",
+            "printer_id": data.printer_id,
+            "ams_id": data.ams_id,
+            "tray_id": data.tray_id,
+        }
+    )
+
     return response
     return response
 
 
 
 
@@ -1124,6 +1136,16 @@ async def unassign_spool(
 
 
     await db.delete(assignment)
     await db.delete(assignment)
     await db.commit()
     await db.commit()
+
+    await ws_manager.broadcast(
+        {
+            "type": "spool_assignment_changed",
+            "printer_id": printer_id,
+            "ams_id": ams_id,
+            "tray_id": tray_id,
+        }
+    )
+
     return {"status": "deleted"}
     return {"status": "deleted"}
 
 
 
 
@@ -1137,6 +1159,22 @@ class LinkTagRequest(BaseModel):
     data_origin: str | None = "nfc_link"
     data_origin: str | None = "nfc_link"
 
 
 
 
+def _validate_tag_input(
+    raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
+) -> None:
+    if raw_value is None:
+        return
+    raw = str(raw_value).strip()
+    if not raw:
+        return
+    if normalized_value is None:
+        raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
+    if len(normalized_value) % 2 != 0:
+        raise HTTPException(422, f"{field_name} must have an even number of hex characters")
+    if exact_len is not None and len(normalized_value) != exact_len:
+        raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
+
+
 @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
 @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
 async def link_tag_to_spool(
 async def link_tag_to_spool(
     spool_id: int,
     spool_id: int,
@@ -1152,11 +1190,17 @@ async def link_tag_to_spool(
     if spool.archived_at:
     if spool.archived_at:
         raise HTTPException(400, "Cannot link tag to archived spool")
         raise HTTPException(400, "Cannot link tag to archived spool")
 
 
+    normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
+    normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
+
+    _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
+    _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
+
     # Check for conflicts: tag already linked to another active spool
     # Check for conflicts: tag already linked to another active spool
-    if data.tag_uid:
+    if normalized_tag_uid:
         conflict = await db.execute(
         conflict = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tag_uid == data.tag_uid,
+                func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
                 Spool.archived_at.is_(None),
             )
             )
@@ -1166,7 +1210,7 @@ async def link_tag_to_spool(
         # Auto-clear from archived spools (tag recycling)
         # Auto-clear from archived spools (tag recycling)
         archived_with_tag = await db.execute(
         archived_with_tag = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tag_uid == data.tag_uid,
+                func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_not(None),
                 Spool.archived_at.is_not(None),
             )
             )
@@ -1174,10 +1218,10 @@ async def link_tag_to_spool(
         for old_spool in archived_with_tag.scalars().all():
         for old_spool in archived_with_tag.scalars().all():
             old_spool.tag_uid = None
             old_spool.tag_uid = None
 
 
-    if data.tray_uuid:
+    if normalized_tray_uuid:
         conflict = await db.execute(
         conflict = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tray_uuid == data.tray_uuid,
+                func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
                 Spool.archived_at.is_(None),
             )
             )
@@ -1186,7 +1230,7 @@ async def link_tag_to_spool(
             raise HTTPException(409, "Tray UUID already linked to another active spool")
             raise HTTPException(409, "Tray UUID already linked to another active spool")
         archived_with_uuid = await db.execute(
         archived_with_uuid = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tray_uuid == data.tray_uuid,
+                func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_not(None),
                 Spool.archived_at.is_not(None),
             )
             )
@@ -1195,9 +1239,9 @@ async def link_tag_to_spool(
             old_spool.tray_uuid = None
             old_spool.tray_uuid = None
 
 
     if data.tag_uid is not None:
     if data.tag_uid is not None:
-        spool.tag_uid = data.tag_uid
+        spool.tag_uid = normalized_tag_uid
     if data.tray_uuid is not None:
     if data.tray_uuid is not None:
-        spool.tray_uuid = data.tray_uuid
+        spool.tray_uuid = normalized_tray_uuid
     if data.tag_type is not None:
     if data.tag_type is not None:
         spool.tag_type = data.tag_type
         spool.tag_type = data.tag_type
     if data.data_origin is not None:
     if data.data_origin is not None:

+ 332 - 21
backend/app/api/routes/library.py

@@ -39,6 +39,7 @@ from backend.app.schemas.library import (
     BatchThumbnailResult,
     BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteRequest,
     BulkDeleteResponse,
     BulkDeleteResponse,
+    ExternalFolderCreate,
     FileDuplicate,
     FileDuplicate,
     FileListResponse,
     FileListResponse,
     FileMoveRequest,
     FileMoveRequest,
@@ -278,6 +279,9 @@ async def list_folders(
             archive_id=folder.archive_id,
             archive_id=folder.archive_id,
             project_name=project_name,
             project_name=project_name,
             archive_name=archive_name,
             archive_name=archive_name,
+            is_external=folder.is_external,
+            external_path=folder.external_path,
+            external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
             file_count=file_counts.get(folder.id, 0),
             children=[],
             children=[],
         )
         )
@@ -326,6 +330,10 @@ async def get_folders_by_project(
                 archive_id=folder.archive_id,
                 archive_id=folder.archive_id,
                 project_name=project_name,
                 project_name=project_name,
                 archive_name=None,
                 archive_name=None,
+                is_external=folder.is_external,
+                external_path=folder.external_path,
+                external_readonly=folder.external_readonly,
+                external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
                 file_count=file_count,
                 created_at=folder.created_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
                 updated_at=folder.updated_at,
@@ -367,6 +375,10 @@ async def get_folders_by_archive(
                 archive_id=folder.archive_id,
                 archive_id=folder.archive_id,
                 project_name=None,
                 project_name=None,
                 archive_name=archive_name,
                 archive_name=archive_name,
+                is_external=folder.is_external,
+                external_path=folder.external_path,
+                external_readonly=folder.external_readonly,
+                external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
                 file_count=file_count,
                 created_at=folder.created_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
                 updated_at=folder.updated_at,
@@ -426,6 +438,10 @@ async def create_folder(
         archive_id=folder.archive_id,
         archive_id=folder.archive_id,
         project_name=project_name,
         project_name=project_name,
         archive_name=archive_name,
         archive_name=archive_name,
+        is_external=folder.is_external,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
         file_count=0,
         file_count=0,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
@@ -464,6 +480,10 @@ async def get_folder(
         archive_id=folder.archive_id,
         archive_id=folder.archive_id,
         project_name=project_name,
         project_name=project_name,
         archive_name=archive_name,
         archive_name=archive_name,
+        is_external=folder.is_external,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
         file_count=file_count,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
@@ -556,6 +576,10 @@ async def update_folder(
         archive_id=folder.archive_id,
         archive_id=folder.archive_id,
         project_name=project_name,
         project_name=project_name,
         archive_name=archive_name,
         archive_name=archive_name,
+        is_external=folder.is_external,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
         file_count=file_count,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
@@ -579,6 +603,9 @@ async def delete_folder(
     if not folder:
     if not folder:
         raise HTTPException(status_code=404, detail="Folder not found")
         raise HTTPException(status_code=404, detail="Folder not found")
 
 
+    # External folders: only remove DB records, never delete files from external path
+    is_ext = folder.is_external
+
     # Get all files in this folder and subfolders to delete from disk
     # Get all files in this folder and subfolders to delete from disk
     async def get_all_file_ids(fid: int) -> list[int]:
     async def get_all_file_ids(fid: int) -> list[int]:
         """Recursively get all file IDs in a folder tree."""
         """Recursively get all file IDs in a folder tree."""
@@ -586,20 +613,21 @@ async def delete_folder(
 
 
         # Get files in this folder
         # Get files in this folder
         files_result = await db.execute(
         files_result = await db.execute(
-            select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path).where(
+            select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path, LibraryFile.is_external).where(
                 LibraryFile.folder_id == fid
                 LibraryFile.folder_id == fid
             )
             )
         )
         )
-        for file_id, file_path, thumb_path in files_result.all():
-            file_ids.append(file_id)
-            # Delete actual files
-            try:
-                if file_path and os.path.exists(file_path):
-                    os.remove(file_path)
-                if thumb_path and os.path.exists(thumb_path):
-                    os.remove(thumb_path)
-            except OSError as e:
-                logger.warning("Failed to delete file: %s", e)
+        for fid_val, file_path, thumb_path, file_is_ext in files_result.all():
+            file_ids.append(fid_val)
+            # Only delete non-external files from disk
+            if not is_ext and not file_is_ext:
+                try:
+                    if file_path and os.path.exists(file_path):
+                        os.remove(file_path)
+                    if thumb_path and os.path.exists(thumb_path):
+                        os.remove(thumb_path)
+                except OSError as e:
+                    logger.warning("Failed to delete file: %s", e)
 
 
         # Get child folders and recurse
         # Get child folders and recurse
         children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
         children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
@@ -616,6 +644,272 @@ async def delete_folder(
     return {"status": "success", "message": "Folder deleted"}
     return {"status": "success", "message": "Folder deleted"}
 
 
 
 
+# ============ External Folder Endpoints ============
+
+# Blocked system directories that cannot be mounted
+_BLOCKED_PREFIXES = (
+    "/proc",
+    "/sys",
+    "/dev",
+    "/run",
+    "/boot",
+    "/sbin",
+    "/bin",
+    "/usr/sbin",
+    "/usr/bin",
+    "/lib",
+    "/etc",
+)
+
+# Supported file extensions for external folder scanning
+_SCANNABLE_EXTENSIONS = {
+    ".3mf",
+    ".gcode",
+    ".gcode.3mf",
+    ".stl",
+    ".obj",
+    ".step",
+    ".stp",
+    ".png",
+    ".jpg",
+    ".jpeg",
+    ".gif",
+    ".webp",
+    ".svg",
+}
+
+
+def _validate_external_path(path_str: str) -> Path:
+    """Validate an external path is safe to mount."""
+    path = Path(path_str).resolve()
+
+    if not path.is_absolute():
+        raise HTTPException(status_code=400, detail="Path must be absolute")
+
+    for prefix in _BLOCKED_PREFIXES:
+        if str(path).startswith(prefix):
+            raise HTTPException(status_code=400, detail=f"Cannot mount system directory: {prefix}")
+
+    if not path.exists():
+        raise HTTPException(status_code=400, detail=f"Path does not exist: {path}")
+
+    if not path.is_dir():
+        raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")
+
+    # Check readability
+    if not os.access(path, os.R_OK):
+        raise HTTPException(status_code=400, detail=f"Path is not readable: {path}")
+
+    return path
+
+
+@router.post("/folders/external", response_model=FolderResponse)
+async def create_external_folder(
+    data: ExternalFolderCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
+    """Create an external folder that points to a host directory."""
+    resolved = _validate_external_path(data.external_path)
+
+    # Check no other external folder already points to this path
+    existing = await db.execute(
+        select(LibraryFolder).where(
+            LibraryFolder.is_external.is_(True),
+            LibraryFolder.external_path == str(resolved),
+        )
+    )
+    if existing.scalar_one_or_none():
+        raise HTTPException(status_code=409, detail="An external folder already exists for this path")
+
+    # Verify parent exists if specified
+    if data.parent_id is not None:
+        parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id))
+        if not parent_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Parent folder not found")
+
+    folder = LibraryFolder(
+        name=data.name,
+        parent_id=data.parent_id,
+        is_external=True,
+        external_path=str(resolved),
+        external_readonly=data.readonly,
+        external_show_hidden=data.show_hidden,
+    )
+    db.add(folder)
+    await db.commit()
+    await db.refresh(folder)
+
+    return FolderResponse(
+        id=folder.id,
+        name=folder.name,
+        parent_id=folder.parent_id,
+        project_id=None,
+        archive_id=None,
+        is_external=True,
+        external_path=folder.external_path,
+        external_readonly=folder.external_readonly,
+        external_show_hidden=folder.external_show_hidden,
+        file_count=0,
+        created_at=folder.created_at,
+        updated_at=folder.updated_at,
+    )
+
+
+@router.post("/folders/{folder_id}/scan")
+async def scan_external_folder(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
+    """Scan an external folder and sync files to the database.
+
+    Discovers new files, removes DB entries for deleted files.
+    Does not copy files — stores the external path directly.
+    """
+    result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
+    folder = result.scalar_one_or_none()
+
+    if not folder:
+        raise HTTPException(status_code=404, detail="Folder not found")
+    if not folder.is_external or not folder.external_path:
+        raise HTTPException(status_code=400, detail="Not an external folder")
+
+    ext_path = Path(folder.external_path)
+    if not ext_path.exists() or not ext_path.is_dir():
+        raise HTTPException(status_code=400, detail=f"External path is not accessible: {folder.external_path}")
+
+    # Get existing DB files for this folder
+    existing_result = await db.execute(
+        select(LibraryFile).where(LibraryFile.folder_id == folder_id, LibraryFile.is_external.is_(True))
+    )
+    existing_files = {f.file_path: f for f in existing_result.scalars().all()}
+
+    # Scan the directory
+    added = 0
+    removed = 0
+    found_paths = set()
+
+    for dirpath, _dirnames, filenames in os.walk(ext_path):
+        for filename in filenames:
+            # Skip hidden files unless configured
+            if not folder.external_show_hidden and filename.startswith("."):
+                continue
+
+            filepath = Path(dirpath) / filename
+            ext = filepath.suffix.lower()
+
+            # Check for compound extensions like .gcode.3mf
+            if ext not in _SCANNABLE_EXTENSIONS:
+                # Check compound
+                compound = "".join(filepath.suffixes[-2:]).lower() if len(filepath.suffixes) >= 2 else ""
+                if compound not in _SCANNABLE_EXTENSIONS:
+                    continue
+
+            # Resolve symlinks and ensure still under external_path
+            try:
+                real_path = filepath.resolve()
+                real_path.relative_to(ext_path.resolve())
+            except (ValueError, OSError):
+                continue  # Symlink escapes the external dir
+
+            file_path_str = str(filepath)
+            found_paths.add(file_path_str)
+
+            if file_path_str in existing_files:
+                continue  # Already tracked
+
+            # Get file info
+            try:
+                stat = filepath.stat()
+            except OSError:
+                continue
+
+            file_type = ext[1:] if ext else "unknown"
+            # For compound extensions, use the meaningful part
+            if file_type in ("3mf",) and len(filepath.suffixes) >= 2:
+                inner = filepath.suffixes[-2].lower()
+                if inner == ".gcode":
+                    file_type = "gcode.3mf"
+
+            # Extract thumbnail for 3mf files
+            thumbnail_path = None
+            file_metadata = None
+            if file_type == "3mf":
+                try:
+                    parser = ThreeMFParser(str(filepath))
+                    meta = parser.parse()
+                    if meta:
+                        file_metadata = meta
+                    thumb_data = parser.extract_thumbnail()
+                    if thumb_data:
+                        thumb_dir = get_library_thumbnails_dir()
+                        thumb_filename = f"{uuid.uuid4().hex}.png"
+                        thumb_full = thumb_dir / thumb_filename
+                        thumb_full.write_bytes(thumb_data)
+                        thumbnail_path = to_relative_path(thumb_full)
+                except Exception as e:
+                    logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
+
+            # Generate thumbnail for STL files
+            if file_type == "stl" and thumbnail_path is None:
+                try:
+                    thumb_dir = get_library_thumbnails_dir()
+                    thumb_result = generate_stl_thumbnail(str(filepath), str(thumb_dir))
+                    if thumb_result:
+                        thumbnail_path = to_relative_path(Path(thumb_result))
+                except Exception as e:
+                    logger.debug("Failed to generate STL thumbnail for external %s: %s", filepath, e)
+
+            # Extract gcode thumbnail
+            if file_type == "gcode" and thumbnail_path is None:
+                thumb_data = extract_gcode_thumbnail(filepath)
+                if thumb_data:
+                    thumb_dir = get_library_thumbnails_dir()
+                    thumb_filename = f"{uuid.uuid4().hex}.png"
+                    thumb_full = thumb_dir / thumb_filename
+                    thumb_full.write_bytes(thumb_data)
+                    thumbnail_path = to_relative_path(thumb_full)
+
+            # Create thumbnail for image files
+            if ext.lower() in IMAGE_EXTENSIONS and thumbnail_path is None:
+                thumbnail_path_str = create_image_thumbnail(filepath, get_library_thumbnails_dir())
+                if thumbnail_path_str:
+                    thumbnail_path = to_relative_path(Path(thumbnail_path_str))
+
+            db_file = LibraryFile(
+                folder_id=folder_id,
+                is_external=True,
+                filename=filename,
+                file_path=file_path_str,
+                file_type=file_type,
+                file_size=stat.st_size,
+                file_hash=None,  # Skip hashing external files for performance
+                thumbnail_path=thumbnail_path,
+                file_metadata=file_metadata,
+            )
+            db.add(db_file)
+            added += 1
+
+    # Remove DB entries for files that no longer exist on disk
+    for path_str, db_file in existing_files.items():
+        if path_str not in found_paths:
+            # Clean up thumbnail if we generated one
+            if db_file.thumbnail_path:
+                try:
+                    abs_thumb = to_absolute_path(db_file.thumbnail_path)
+                    if abs_thumb and abs_thumb.exists():
+                        abs_thumb.unlink()
+                except OSError:
+                    pass
+            await db.delete(db_file)
+            removed += 1
+
+    await db.commit()
+
+    return {"status": "success", "added": added, "removed": removed}
+
+
 # ============ File Endpoints ============
 # ============ File Endpoints ============
 
 
 
 
@@ -678,6 +972,7 @@ async def list_files(
             FileListResponse(
             FileListResponse(
                 id=f.id,
                 id=f.id,
                 folder_id=f.folder_id,
                 folder_id=f.folder_id,
+                is_external=f.is_external,
                 filename=f.filename,
                 filename=f.filename,
                 file_type=f.file_type,
                 file_type=f.file_type,
                 file_size=f.file_size,
                 file_size=f.file_size,
@@ -719,8 +1014,11 @@ async def upload_file(
         # Verify folder exists if specified
         # Verify folder exists if specified
         if folder_id is not None:
         if folder_id is not None:
             folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
             folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
-            if not folder_result.scalar_one_or_none():
+            target_folder = folder_result.scalar_one_or_none()
+            if not target_folder:
                 raise HTTPException(status_code=404, detail="Folder not found")
                 raise HTTPException(status_code=404, detail="Folder not found")
+            if target_folder.is_external and target_folder.external_readonly:
+                raise HTTPException(status_code=403, detail="Cannot upload to a read-only external folder")
 
 
         # Generate unique filename for storage
         # Generate unique filename for storage
         unique_filename = f"{uuid.uuid4().hex}{ext}"
         unique_filename = f"{uuid.uuid4().hex}{ext}"
@@ -859,8 +1157,11 @@ async def extract_zip_file(
     # Verify target folder exists if specified
     # Verify target folder exists if specified
     if folder_id is not None:
     if folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
-        if not folder_result.scalar_one_or_none():
+        target_folder = folder_result.scalar_one_or_none()
+        if not target_folder:
             raise HTTPException(status_code=404, detail="Target folder not found")
             raise HTTPException(status_code=404, detail="Target folder not found")
+        if target_folder.is_external and target_folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
 
 
     # Save ZIP to temp file
     # Save ZIP to temp file
     try:
     try:
@@ -1994,12 +2295,14 @@ async def delete_file(
         if file.created_by_id != user.id:
         if file.created_by_id != user.id:
             raise HTTPException(status_code=403, detail="You can only delete your own files")
             raise HTTPException(status_code=403, detail="You can only delete your own files")
 
 
-    # Delete actual files
+    # External files: only remove DB entry and thumbnail, never delete the actual file
     try:
     try:
-        abs_file_path = to_absolute_path(file.file_path)
+        if not file.is_external:
+            abs_file_path = to_absolute_path(file.file_path)
+            if abs_file_path and abs_file_path.exists():
+                abs_file_path.unlink()
+        # Always clean up thumbnails we generated
         abs_thumb_path = to_absolute_path(file.thumbnail_path)
         abs_thumb_path = to_absolute_path(file.thumbnail_path)
-        if abs_file_path and abs_file_path.exists():
-            abs_file_path.unlink()
         if abs_thumb_path and abs_thumb_path.exists():
         if abs_thumb_path and abs_thumb_path.exists():
             abs_thumb_path.unlink()
             abs_thumb_path.unlink()
     except OSError as e:
     except OSError as e:
@@ -2180,8 +2483,11 @@ async def move_files(
     # Verify folder exists if specified
     # Verify folder exists if specified
     if data.folder_id is not None:
     if data.folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
-        if not folder_result.scalar_one_or_none():
+        target_folder = folder_result.scalar_one_or_none()
+        if not target_folder:
             raise HTTPException(status_code=404, detail="Folder not found")
             raise HTTPException(status_code=404, detail="Folder not found")
+        if target_folder.is_external and target_folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot move files to a read-only external folder")
 
 
     # Update files
     # Update files
     moved = 0
     moved = 0
@@ -2194,6 +2500,10 @@ async def move_files(
             if not can_modify_all and file.created_by_id != user.id:
             if not can_modify_all and file.created_by_id != user.id:
                 skipped += 1
                 skipped += 1
                 continue
                 continue
+            # Cannot move external files out of their folder
+            if file.is_external:
+                skipped += 1
+                continue
             file.folder_id = data.folder_id
             file.folder_id = data.folder_id
             moved += 1
             moved += 1
 
 
@@ -2231,10 +2541,11 @@ async def bulk_delete(
                 continue
                 continue
 
 
             try:
             try:
-                abs_file_path = to_absolute_path(file.file_path)
+                if not file.is_external:
+                    abs_file_path = to_absolute_path(file.file_path)
+                    if abs_file_path and abs_file_path.exists():
+                        abs_file_path.unlink()
                 abs_thumb_path = to_absolute_path(file.thumbnail_path)
                 abs_thumb_path = to_absolute_path(file.thumbnail_path)
-                if abs_file_path and abs_file_path.exists():
-                    abs_file_path.unlink()
                 if abs_thumb_path and abs_thumb_path.exists():
                 if abs_thumb_path and abs_thumb_path.exists():
                     abs_thumb_path.unlink()
                     abs_thumb_path.unlink()
             except OSError as e:
             except OSError as e:

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

@@ -43,6 +43,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_print_failed": provider.on_print_failed,
         "on_print_failed": provider.on_print_failed,
         "on_print_stopped": provider.on_print_stopped,
         "on_print_stopped": provider.on_print_stopped,
         "on_print_progress": provider.on_print_progress,
         "on_print_progress": provider.on_print_progress,
+        "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
         # Printer status events
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
         "on_printer_error": provider.on_printer_error,
@@ -122,6 +123,7 @@ async def create_notification_provider(
         on_print_failed=provider_data.on_print_failed,
         on_print_failed=provider_data.on_print_failed,
         on_print_stopped=provider_data.on_print_stopped,
         on_print_stopped=provider_data.on_print_stopped,
         on_print_progress=provider_data.on_print_progress,
         on_print_progress=provider_data.on_print_progress,
+        on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
         # Printer status events
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
         on_printer_error=provider_data.on_printer_error,

+ 1 - 0
backend/app/api/routes/printers.py

@@ -418,6 +418,7 @@ async def get_printer_status(
                         nozzle_temp_max=tray_data.get("nozzle_temp_max"),
                         nozzle_temp_max=tray_data.get("nozzle_temp_max"),
                         drying_temp=tray_data.get("drying_temp"),
                         drying_temp=tray_data.get("drying_temp"),
                         drying_time=tray_data.get("drying_time"),
                         drying_time=tray_data.get("drying_time"),
+                        state=tray_data.get("state"),
                     )
                     )
                 )
                 )
             # Prefer humidity_raw (percentage) over humidity (index 1-5)
             # Prefer humidity_raw (percentage) over humidity (index 1-5)

+ 276 - 62
backend/app/api/routes/spoolbuddy.py

@@ -1,7 +1,11 @@
 """SpoolBuddy device management API routes."""
 """SpoolBuddy device management API routes."""
 
 
+import asyncio
+import json
 import logging
 import logging
+import time
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
+from urllib.parse import urlparse
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
@@ -17,12 +21,15 @@ from backend.app.schemas.spoolbuddy import (
     CalibrationResponse,
     CalibrationResponse,
     DeviceRegisterRequest,
     DeviceRegisterRequest,
     DeviceResponse,
     DeviceResponse,
+    DiagnosticResultRequest,
     DisplaySettingsRequest,
     DisplaySettingsRequest,
     HeartbeatRequest,
     HeartbeatRequest,
     HeartbeatResponse,
     HeartbeatResponse,
     ScaleReadingRequest,
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
     SetCalibrationFactorRequest,
     SetTareRequest,
     SetTareRequest,
+    SystemCommandResultRequest,
+    SystemConfigRequest,
     TagRemovedRequest,
     TagRemovedRequest,
     TagScannedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
     UpdateSpoolWeightRequest,
@@ -36,6 +43,9 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 
 
 OFFLINE_THRESHOLD_SECONDS = 30
 OFFLINE_THRESHOLD_SECONDS = 30
+ONLINE_BROADCAST_INTERVAL_SECONDS = 10
+_spoolbuddy_online_last_broadcast: dict[str, float] = {}
+_diagnostic_results: dict[tuple[str, str], dict] = {}
 
 
 
 
 def _is_online(device: SpoolBuddyDevice) -> bool:
 def _is_online(device: SpoolBuddyDevice) -> bool:
@@ -59,6 +69,7 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         calibration_factor=device.calibration_factor,
         calibration_factor=device.calibration_factor,
         nfc_reader_type=device.nfc_reader_type,
         nfc_reader_type=device.nfc_reader_type,
         nfc_connection=device.nfc_connection,
         nfc_connection=device.nfc_connection,
+        backend_url=device.backend_url,
         display_brightness=device.display_brightness,
         display_brightness=device.display_brightness,
         display_blank_timeout=device.display_blank_timeout,
         display_blank_timeout=device.display_blank_timeout,
         has_backlight=device.has_backlight,
         has_backlight=device.has_backlight,
@@ -70,12 +81,26 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         uptime_s=device.uptime_s,
         uptime_s=device.uptime_s,
         update_status=device.update_status,
         update_status=device.update_status,
         update_message=device.update_message,
         update_message=device.update_message,
+        system_stats=json.loads(device.system_stats) if device.system_stats else None,
         online=_is_online(device),
         online=_is_online(device),
         created_at=device.created_at,
         created_at=device.created_at,
         updated_at=device.updated_at,
         updated_at=device.updated_at,
     )
     )
 
 
 
 
+def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
+    if force:
+        _spoolbuddy_online_last_broadcast[device_id] = time.time()
+        return True
+
+    now_ts = time.time()
+    last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)
+    if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:
+        _spoolbuddy_online_last_broadcast[device_id] = now_ts
+        return True
+    return False
+
+
 # --- Device endpoints ---
 # --- Device endpoints ---
 
 
 
 
@@ -98,8 +123,14 @@ async def register_device(
         device.has_scale = req.has_scale
         device.has_scale = req.has_scale
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_connection = req.nfc_connection
         device.nfc_connection = req.nfc_connection
+        if req.backend_url:
+            device.backend_url = req.backend_url
         device.has_backlight = req.has_backlight
         device.has_backlight = req.has_backlight
         device.last_seen = now
         device.last_seen = now
+        # Clear stale update status on re-registration (daemon restarted after update)
+        if device.update_status in ("pending", "updating", "complete", "error"):
+            device.update_status = None
+            device.update_message = None
         logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
         logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
     else:
     else:
         device = SpoolBuddyDevice(
         device = SpoolBuddyDevice(
@@ -114,6 +145,7 @@ async def register_device(
             nfc_reader_type=req.nfc_reader_type,
             nfc_reader_type=req.nfc_reader_type,
             nfc_connection=req.nfc_connection,
             nfc_connection=req.nfc_connection,
             has_backlight=req.has_backlight,
             has_backlight=req.has_backlight,
+            backend_url=req.backend_url,
             last_seen=now,
             last_seen=now,
         )
         )
         db.add(device)
         db.add(device)
@@ -122,6 +154,7 @@ async def register_device(
     await db.commit()
     await db.commit()
     await db.refresh(device)
     await db.refresh(device)
 
 
+    _spoolbuddy_online_last_broadcast[device.device_id] = time.time()
     await ws_manager.broadcast(
     await ws_manager.broadcast(
         {
         {
             "type": "spoolbuddy_online",
             "type": "spoolbuddy_online",
@@ -130,7 +163,17 @@ async def register_device(
         }
         }
     )
     )
 
 
-    return _device_to_response(device)
+    response = _device_to_response(device)
+
+    # Include SSH public key so the daemon can auto-deploy it
+    try:
+        from backend.app.services.spoolbuddy_ssh import get_public_key
+
+        response.ssh_public_key = await get_public_key()
+    except Exception:
+        pass  # Key not generated yet — daemon can still work without it
+
+    return response
 
 
 
 
 @router.get("/devices", response_model=list[DeviceResponse])
 @router.get("/devices", response_model=list[DeviceResponse])
@@ -172,25 +215,39 @@ async def device_heartbeat(
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_reader_type = req.nfc_reader_type
     if req.nfc_connection:
     if req.nfc_connection:
         device.nfc_connection = req.nfc_connection
         device.nfc_connection = req.nfc_connection
+    if req.backend_url:
+        device.backend_url = req.backend_url
+    if req.system_stats is not None:
+        device.system_stats = json.dumps(req.system_stats)
 
 
     # Return and clear pending command
     # Return and clear pending command
     pending = device.pending_command
     pending = device.pending_command
     pending_write = None
     pending_write = None
+    pending_system = None
     if pending == "write_tag" and device.pending_write_payload:
     if pending == "write_tag" and device.pending_write_payload:
         # Parse the stored JSON payload to include in response
         # Parse the stored JSON payload to include in response
-        import json
-
         try:
         try:
             pending_write = json.loads(device.pending_write_payload)
             pending_write = json.loads(device.pending_write_payload)
         except (json.JSONDecodeError, TypeError):
         except (json.JSONDecodeError, TypeError):
             pending_write = None
             pending_write = None
         # Don't clear write_tag command — it gets cleared by write-result
         # Don't clear write_tag command — it gets cleared by write-result
+    elif pending == "apply_system_config" and device.pending_system_payload:
+        try:
+            pending_system = json.loads(device.pending_system_payload)
+        except (json.JSONDecodeError, TypeError):
+            pending_system = None
+        # Don't clear config command — it gets cleared by daemon command-result callback
+    elif pending and pending.startswith("run_") and pending.endswith("_diag"):
+        # Don't clear diagnostic commands — they get cleared by the device reporting results
+        pass
     else:
     else:
         device.pending_command = None
         device.pending_command = None
 
 
     await db.commit()
     await db.commit()
 
 
-    if was_offline:
+    # Emit online presence on offline->online transitions immediately, and
+    # periodically while online so newly connected UIs can bootstrap state.
+    if _should_broadcast_online(device.device_id, force=was_offline):
         await ws_manager.broadcast(
         await ws_manager.broadcast(
             {
             {
                 "type": "spoolbuddy_online",
                 "type": "spoolbuddy_online",
@@ -198,10 +255,13 @@ async def device_heartbeat(
                 "hostname": device.hostname,
                 "hostname": device.hostname,
             }
             }
         )
         )
+    if was_offline:
+        logger.info("SpoolBuddy device back online: %s", device.device_id)
 
 
     return HeartbeatResponse(
     return HeartbeatResponse(
         pending_command=pending,
         pending_command=pending,
         pending_write_payload=pending_write,
         pending_write_payload=pending_write,
+        pending_system_payload=pending_system,
         tare_offset=device.tare_offset,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
         calibration_factor=device.calibration_factor,
         display_brightness=device.display_brightness,
         display_brightness=device.display_brightness,
@@ -251,7 +311,15 @@ async def nfc_tag_scanned(
                 "tag_type": req.tag_type,
                 "tag_type": req.tag_type,
             }
             }
         )
         )
-        logger.info("SpoolBuddy unknown tag: %s", req.tag_uid)
+        logger.info(
+            "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
+            req.tag_uid,
+            len(req.tag_uid or ""),
+            req.tray_uuid,
+            len(req.tray_uuid or ""),
+            req.tag_type,
+            req.sak,
+        )
 
 
     return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
     return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
 
 
@@ -567,21 +635,185 @@ async def update_display_settings(
     return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
     return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
 
 
 
 
+@router.post("/devices/{device_id}/system/config")
+async def queue_system_config_update(
+    device_id: str,
+    req: SystemConfigRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Queue update of SpoolBuddy .env config on the device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    parsed = urlparse(req.backend_url.strip())
+    if parsed.scheme not in ("http", "https") or not parsed.netloc:
+        raise HTTPException(
+            status_code=400,
+            detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local",
+        )
+
+    payload = {
+        "backend_url": req.backend_url.strip(),
+    }
+    if req.api_key is not None and req.api_key.strip():
+        payload["api_key"] = req.api_key.strip()
+
+    device.pending_system_payload = json.dumps(payload)
+    device.pending_command = "apply_system_config"
+    await db.commit()
+
+    logger.info("Queued system config update for device %s", device_id)
+    return {"status": "queued", "message": "System config update queued"}
+
+
+@router.post("/devices/{device_id}/system/command-result")
+async def system_command_result(
+    device_id: str,
+    req: SystemCommandResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Receive completion status for queued system command from daemon."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if not device.pending_command:
+        logger.info("System command result from %s with no pending command: %s", device_id, req.command)
+        return {"status": "ok", "message": "No pending command"}
+
+    if req.command != device.pending_command:
+        raise HTTPException(
+            status_code=409,
+            detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'",
+        )
+
+    if req.command == "apply_system_config":
+        device.pending_system_payload = None
+    device.pending_command = None
+    await db.commit()
+
+    logger.info(
+        "System command result from %s: %s success=%s message=%s",
+        device_id,
+        req.command,
+        req.success,
+        req.message,
+    )
+    return {"status": "ok"}
+
+
+# --- Diagnostics ---
+
+
+@router.post("/diagnostics/{device_id}/run")
+async def queue_diagnostic(
+    device_id: str,
+    diagnostic: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Queue a hardware diagnostic to run on the SpoolBuddy device.
+
+    Args:
+        device_id: The device ID
+        diagnostic: 'scale' or 'nfc' to select which diagnostic to run
+
+    Returns:
+        Status message indicating diagnostic was queued
+    """
+    if diagnostic not in ("scale", "nfc", "read_tag"):
+        raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
+
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.pending_command = f"run_{diagnostic}_diag"
+    _diagnostic_results.pop((device_id, diagnostic), None)
+    await db.commit()
+
+    logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic)
+    return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"}
+
+
+@router.get("/diagnostics/{device_id}/result")
+async def get_diagnostic_result(
+    device_id: str,
+    diagnostic: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get the latest diagnostic result for a device.
+
+    Args:
+        device_id: The device ID
+        diagnostic: 'scale' or 'nfc'
+
+    Returns:
+        Diagnostic result or 404 if not found
+    """
+    if diagnostic not in ("scale", "nfc", "read_tag"):
+        raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
+
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    diag_result = _diagnostic_results.get((device_id, diagnostic))
+    if not diag_result:
+        raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet")
+    return diag_result
+
+
+@router.post("/diagnostics/{device_id}/result")
+async def report_diagnostic_result(
+    device_id: str,
+    req: DiagnosticResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Report diagnostic result from SpoolBuddy device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if req.diagnostic not in ("nfc", "scale", "read_tag"):
+        raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
+
+    _diagnostic_results[(device_id, req.diagnostic)] = {
+        "diagnostic": req.diagnostic,
+        "success": req.success,
+        "output": req.output,
+        "exit_code": req.exit_code,
+    }
+
+    device.pending_command = None
+    await db.commit()
+
+    logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success)
+    return {"status": "ok", "message": "Diagnostic result recorded"}
+
+
 # --- Update check ---
 # --- Update check ---
 
 
 
 
 @router.get("/devices/{device_id}/update-check")
 @router.get("/devices/{device_id}/update-check")
 async def check_daemon_update(
 async def check_daemon_update(
     device_id: str,
     device_id: str,
-    include_beta: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
-    """Check if a newer daemon version is available on GitHub."""
-    import httpx
-
-    from backend.app.api.routes.updates import is_newer_version, parse_version
-    from backend.app.core.config import GITHUB_REPO
+    """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
+    from backend.app.api.routes.updates import is_newer_version
+    from backend.app.core.config import APP_VERSION
 
 
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
     device = result.scalar_one_or_none()
     device = result.scalar_one_or_none()
@@ -590,61 +822,27 @@ async def check_daemon_update(
 
 
     current = device.firmware_version or "0.0.0"
     current = device.firmware_version or "0.0.0"
 
 
-    try:
-        async with httpx.AsyncClient() as client:
-            response = await client.get(
-                f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
-                headers={"Accept": "application/vnd.github.v3+json"},
-                timeout=10.0,
-            )
-            response.raise_for_status()
-            releases = response.json()
-
-            release_data = None
-            for release in releases:
-                tag = release.get("tag_name", "")
-                if include_beta:
-                    release_data = release
-                    break
-                else:
-                    parsed = parse_version(tag)
-                    if parsed[4] == 0:  # is_prerelease == 0
-                        release_data = release
-                        break
-
-            if not release_data:
-                return {
-                    "current_version": current,
-                    "latest_version": None,
-                    "update_available": False,
-                    "release_url": None,
-                }
-
-            latest = release_data.get("tag_name", "").lstrip("v")
-            return {
-                "current_version": current,
-                "latest_version": latest,
-                "update_available": is_newer_version(latest, current),
-                "release_url": release_data.get("html_url"),
-            }
-    except Exception as e:
-        logger.warning("Failed to check for daemon updates: %s", e)
-        return {
-            "current_version": current,
-            "latest_version": None,
-            "update_available": False,
-            "release_url": None,
-            "error": str(e),
-        }
+    return {
+        "current_version": current,
+        "latest_version": APP_VERSION,
+        "update_available": is_newer_version(APP_VERSION, current),
+    }
 
 
 
 
 @router.post("/devices/{device_id}/update")
 @router.post("/devices/{device_id}/update")
 async def trigger_daemon_update(
 async def trigger_daemon_update(
     device_id: str,
     device_id: str,
+    req: dict | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
-    """Trigger a daemon update on the SpoolBuddy device via pending_command."""
+    """Trigger a SpoolBuddy update over SSH.
+
+    Bambuddy SSHes into the device, pulls the matching branch, installs deps,
+    and restarts the daemon. Progress is broadcast via WebSocket.
+    """
+    from backend.app.services.spoolbuddy_ssh import perform_ssh_update
+
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
     device = result.scalar_one_or_none()
     device = result.scalar_one_or_none()
     if not device:
     if not device:
@@ -656,12 +854,11 @@ async def trigger_daemon_update(
     if device.update_status == "updating":
     if device.update_status == "updating":
         return {"status": "already_updating", "message": "Update already in progress"}
         return {"status": "already_updating", "message": "Update already in progress"}
 
 
-    device.pending_command = "update"
     device.update_status = "pending"
     device.update_status = "pending"
-    device.update_message = "Waiting for device to pick up update command..."
+    device.update_message = "Starting SSH update..."
     await db.commit()
     await db.commit()
 
 
-    logger.info("SpoolBuddy %s: update command queued", device_id)
+    logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
     await ws_manager.broadcast(
     await ws_manager.broadcast(
         {
         {
             "type": "spoolbuddy_update",
             "type": "spoolbuddy_update",
@@ -670,7 +867,24 @@ async def trigger_daemon_update(
         }
         }
     )
     )
 
 
-    return {"status": "ok", "message": "Update command sent to device"}
+    # Run the SSH update in the background
+    asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
+
+    return {"status": "ok", "message": "SSH update started"}
+
+
+@router.get("/ssh/public-key")
+async def get_ssh_public_key(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Return the SSH public key for SpoolBuddy pairing."""
+    from backend.app.services.spoolbuddy_ssh import get_public_key
+
+    try:
+        key = await get_public_key()
+        return {"public_key": key}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
 
 
 
 
 @router.post("/devices/{device_id}/update-status")
 @router.post("/devices/{device_id}/update-status")

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

@@ -4,8 +4,8 @@ from pathlib import Path
 
 
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
-# Application version - single source of truth.
-APP_VERSION = "0.2.2.1"
+# Application version - single source of truth
+APP_VERSION = "0.2.2.2"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 

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

@@ -12,7 +12,7 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
     # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
     # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
     cursor.execute("PRAGMA journal_mode = WAL")
     cursor.execute("PRAGMA journal_mode = WAL")
     # Wait up to 5 seconds when the database is locked instead of failing immediately
     # Wait up to 5 seconds when the database is locked instead of failing immediately
-    cursor.execute("PRAGMA busy_timeout = 5000")
+    cursor.execute("PRAGMA busy_timeout = 15000")
     cursor.execute("PRAGMA synchronous = NORMAL")
     cursor.execute("PRAGMA synchronous = NORMAL")
     cursor.close()
     cursor.close()
 
 
@@ -20,8 +20,8 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
 engine = create_async_engine(
 engine = create_async_engine(
     settings.database_url,
     settings.database_url,
     echo=settings.debug,
     echo=settings.debug,
-    pool_size=10,
-    max_overflow=20,
+    pool_size=20,
+    max_overflow=200,
 )
 )
 
 
 # Register the pragma listener on the underlying sync engine
 # Register the pragma listener on the underlying sync engine
@@ -46,8 +46,8 @@ async def reinitialize_database():
     engine = create_async_engine(
     engine = create_async_engine(
         settings.database_url,
         settings.database_url,
         echo=settings.debug,
         echo=settings.debug,
-        pool_size=10,
-        max_overflow=20,
+        pool_size=20,
+        max_overflow=200,
     )
     )
     event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
     event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
     async_session = async_sessionmaker(
     async_session = async_sessionmaker(
@@ -254,6 +254,14 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add missing-spool-assignment print-start notification toggle
+    try:
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0")
+        )
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add project_id column to print_archives
     # Migration: Add project_id column to print_archives
     try:
     try:
         await conn.execute(
         await conn.execute(
@@ -338,6 +346,12 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add auto_off_persistent column to smart_plugs (keep auto-off enabled between prints)
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_persistent BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add AMS alarm notification columns to notification_providers
     # Migration: Add AMS alarm notification columns to notification_providers
     try:
     try:
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
@@ -589,6 +603,7 @@ async def run_migrations(conn):
                     enabled BOOLEAN NOT NULL DEFAULT 1,
                     enabled BOOLEAN NOT NULL DEFAULT 1,
                     auto_on BOOLEAN NOT NULL DEFAULT 1,
                     auto_on BOOLEAN NOT NULL DEFAULT 1,
                     auto_off BOOLEAN NOT NULL DEFAULT 1,
                     auto_off BOOLEAN NOT NULL DEFAULT 1,
+                    auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
                     off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
                     off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
                     off_delay_minutes INTEGER NOT NULL DEFAULT 5,
                     off_delay_minutes INTEGER NOT NULL DEFAULT 5,
                     off_temp_threshold INTEGER NOT NULL DEFAULT 70,
                     off_temp_threshold INTEGER NOT NULL DEFAULT 70,
@@ -617,7 +632,8 @@ async def run_migrations(conn):
                 INSERT INTO smart_plugs_new
                 INSERT INTO smart_plugs_new
                 SELECT id, name, ip_address,
                 SELECT id, name, ip_address,
                        COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
                        COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
-                       enabled, auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
+                       enabled, auto_on, auto_off, COALESCE(auto_off_persistent, 0),
+                       off_delay_mode, off_delay_minutes, off_temp_threshold,
                        username, password, power_alert_enabled, power_alert_high, power_alert_low,
                        username, password, power_alert_enabled, power_alert_high, power_alert_low,
                        power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
                        power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
                        COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
                        COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
@@ -886,6 +902,7 @@ async def run_migrations(conn):
                     enabled BOOLEAN NOT NULL DEFAULT 1,
                     enabled BOOLEAN NOT NULL DEFAULT 1,
                     auto_on BOOLEAN NOT NULL DEFAULT 1,
                     auto_on BOOLEAN NOT NULL DEFAULT 1,
                     auto_off BOOLEAN NOT NULL DEFAULT 1,
                     auto_off BOOLEAN NOT NULL DEFAULT 1,
+                    auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
                     off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
                     off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
                     off_delay_minutes INTEGER NOT NULL DEFAULT 5,
                     off_delay_minutes INTEGER NOT NULL DEFAULT 5,
                     off_temp_threshold INTEGER NOT NULL DEFAULT 70,
                     off_temp_threshold INTEGER NOT NULL DEFAULT 70,
@@ -915,7 +932,8 @@ async def run_migrations(conn):
                 INSERT INTO smart_plugs_temp
                 INSERT INTO smart_plugs_temp
                 SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
                 SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
                        ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
                        ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
-                       auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
+                       auto_on, auto_off, COALESCE(auto_off_persistent, 0),
+                       off_delay_mode, off_delay_minutes, off_temp_threshold,
                        username, password, power_alert_enabled, power_alert_high, power_alert_low,
                        username, password, power_alert_enabled, power_alert_high, power_alert_low,
                        power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
                        power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
                        show_in_switchbar, last_state, last_checked, auto_off_executed,
                        show_in_switchbar, last_state, last_checked, auto_off_executed,
@@ -1406,6 +1424,22 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Persist SpoolBuddy backend URL and queued system payload
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add system_stats JSON blob column to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     try:
     try:

+ 16 - 0
backend/app/core/websocket.py

@@ -91,6 +91,22 @@ class ConnectionManager:
             }
             }
         )
         )
 
 
+    async def send_missing_spool_assignment(
+        self,
+        printer_id: int,
+        printer_name: str,
+        missing_slots: list[dict[str, str]],
+    ):
+        """Notify clients that a print started with missing spool assignments."""
+        await self.broadcast(
+            {
+                "type": "missing_spool_assignment",
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "missing_slots": missing_slots,
+            }
+        )
+
 
 
 # Global connection manager
 # Global connection manager
 ws_manager = ConnectionManager()
 ws_manager = ConnectionManager()

+ 32 - 5
backend/app/main.py

@@ -73,6 +73,9 @@ from backend.app.services.printer_manager import (
     printer_state_to_dict,
     printer_state_to_dict,
 )
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.services.spool_assignment_notifications import (
+    notify_missing_spool_assignments_on_print_start,
+)
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman_tracking import (
 from backend.app.services.spoolman_tracking import (
     cleanup_tracking as _cleanup_spoolman_tracking,
     cleanup_tracking as _cleanup_spoolman_tracking,
@@ -425,15 +428,25 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
 
     # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
     # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
     vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
     vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
-    # Include AMS dry_time values so drying status changes trigger broadcasts
+    # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
     ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
     ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
+    # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
+    ams_tray_key = (
+        tuple(
+            (t.get("id"), t.get("tray_type", ""), t.get("state"))
+            for a in (state.raw_data.get("ams") or [])
+            for t in a.get("tray", [])
+        )
+        if state.raw_data
+        else ()
+    )
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
-        f"{ams_dry_key}"
+        f"{ams_dry_key}:{ams_tray_key}"
     )
     )
 
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -775,9 +788,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
             from backend.app.services.spool_tag_matcher import (
             from backend.app.services.spool_tag_matcher import (
                 auto_assign_spool,
                 auto_assign_spool,
                 create_spool_from_tray,
                 create_spool_from_tray,
+                find_matching_untagged_spool,
                 get_spool_by_tag,
                 get_spool_by_tag,
                 is_bambu_tag,
                 is_bambu_tag,
                 is_valid_tag,
                 is_valid_tag,
+                link_tag_to_inventory_spool,
             )
             )
 
 
             _spoolman_on = await get_setting(db, "spoolman_enabled")
             _spoolman_on = await get_setting(db, "spoolman_enabled")
@@ -833,10 +848,15 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             continue
                             continue
 
 
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
-                            # BL spool with RFID tag: auto-match or auto-create
+                            # BL spool with RFID tag: auto-match → inventory match → auto-create
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
                             if not spool:
                             if not spool:
-                                spool = await create_spool_from_tray(db, tray)
+                                # Try matching an untagged inventory spool (same material/color)
+                                spool = await find_matching_untagged_spool(db, tray)
+                                if spool:
+                                    await link_tag_to_inventory_spool(db, spool, tray)
+                                else:
+                                    spool = await create_spool_from_tray(db, tray)
                             await auto_assign_spool(
                             await auto_assign_spool(
                                 printer_id,
                                 printer_id,
                                 ams_id,
                                 ams_id,
@@ -1207,6 +1227,9 @@ async def on_print_start(printer_id: int, data: dict):
 
 
     await ws_manager.send_print_start(printer_id, data)
     await ws_manager.send_print_start(printer_id, data)
 
 
+    # Notify when the print-start AMS mapping references tray slots without spool assignments.
+    await notify_missing_spool_assignments_on_print_start(printer_id, data, logger)
+
     # MQTT relay - publish print start
     # MQTT relay - publish print start
     try:
     try:
         printer_info = printer_manager.get_printer(printer_id)
         printer_info = printer_manager.get_printer(printer_id)
@@ -4113,7 +4136,11 @@ async def serve_service_worker():
     """Serve service worker."""
     """Serve service worker."""
     sw_file = app_settings.static_dir / "sw.js"
     sw_file = app_settings.static_dir / "sw.js"
     if sw_file.exists():
     if sw_file.exists():
-        return FileResponse(sw_file, media_type="application/javascript")
+        return FileResponse(
+            sw_file,
+            media_type="application/javascript",
+            headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
+        )
     return {"error": "Service worker not found"}
     return {"error": "Service worker not found"}
 
 
 
 

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

@@ -65,6 +65,7 @@ class NotificationProvider(Base):
     on_print_failed = Column(Boolean, default=True)
     on_print_failed = Column(Boolean, default=True)
     on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
+    on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
     on_printer_offline = Column(Boolean, default=False)

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

@@ -55,6 +55,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Print {progress}% Complete",
         "title_template": "Print {progress}% Complete",
         "body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
         "body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
     },
     },
+    {
+        "event_type": "print_missing_spool_assignment",
+        "name": "Missing Spool Assignment",
+        "title_template": "Missing Spool Assignment",
+        "body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
+    },
     {
     {
         "event_type": "printer_offline",
         "event_type": "printer_offline",
         "name": "Printer Offline",
         "name": "Printer Offline",

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

@@ -57,6 +57,7 @@ class SmartPlug(Base):
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start
     auto_off: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn off at print complete/fail
     auto_off: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn off at print complete/fail
+    auto_off_persistent: Mapped[bool] = mapped_column(Boolean, default=False)  # Keep auto-off enabled between prints
 
 
     # Turn-off delay mode: "time" or "temperature"
     # Turn-off delay mode: "time" or "temperature"
     off_delay_mode: Mapped[str] = mapped_column(String(20), default="time")
     off_delay_mode: Mapped[str] = mapped_column(String(20), default="time")

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

@@ -22,6 +22,7 @@ class SpoolBuddyDevice(Base):
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
     nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
     nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
     nfc_connection: Mapped[str | None] = mapped_column(String(20))
     nfc_connection: Mapped[str | None] = mapped_column(String(20))
+    backend_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
     display_brightness: Mapped[int] = mapped_column(Integer, default=100)
     display_brightness: Mapped[int] = mapped_column(Integer, default=100)
     display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
     display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
     has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)
     has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -31,8 +32,10 @@ class SpoolBuddyDevice(Base):
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
     update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
     update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
     update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    pending_system_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
+    system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

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

@@ -16,6 +16,16 @@ class FolderCreate(BaseModel):
     archive_id: int | None = None
     archive_id: int | None = None
 
 
 
 
+class ExternalFolderCreate(BaseModel):
+    """Schema for linking an external folder."""
+
+    name: str = Field(..., min_length=1, max_length=255)
+    external_path: str = Field(..., min_length=1, max_length=500)
+    readonly: bool = True
+    show_hidden: bool = False
+    parent_id: int | None = None
+
+
 class FolderUpdate(BaseModel):
 class FolderUpdate(BaseModel):
     """Schema for updating a folder."""
     """Schema for updating a folder."""
 
 
@@ -35,6 +45,10 @@ class FolderResponse(BaseModel):
     archive_id: int | None = None
     archive_id: int | None = None
     project_name: str | None = None
     project_name: str | None = None
     archive_name: str | None = None
     archive_name: str | None = None
+    is_external: bool = False
+    external_path: str | None = None
+    external_readonly: bool = False
+    external_show_hidden: bool = False
     file_count: int = 0  # Computed field
     file_count: int = 0  # Computed field
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
@@ -53,6 +67,9 @@ class FolderTreeItem(BaseModel):
     archive_id: int | None = None
     archive_id: int | None = None
     project_name: str | None = None
     project_name: str | None = None
     archive_name: str | None = None
     archive_name: str | None = None
+    is_external: bool = False
+    external_path: str | None = None
+    external_readonly: bool = False
     file_count: int = 0
     file_count: int = 0
     children: list["FolderTreeItem"] = []
     children: list["FolderTreeItem"] = []
 
 
@@ -104,6 +121,7 @@ class FileResponse(BaseModel):
     folder_name: str | None = None
     folder_name: str | None = None
     project_id: int | None
     project_id: int | None
     project_name: str | None = None
     project_name: str | None = None
+    is_external: bool = False
 
 
     filename: str
     filename: str
     file_path: str
     file_path: str
@@ -145,6 +163,7 @@ class FileListResponse(BaseModel):
 
 
     id: int
     id: int
     folder_id: int | None
     folder_id: int | None
+    is_external: bool = False
     filename: str
     filename: str
     file_type: str
     file_type: str
     file_size: int
     file_size: int

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

@@ -35,6 +35,10 @@ class NotificationProviderBase(BaseModel):
     on_print_failed: bool = Field(default=True, description="Notify on print failed")
     on_print_failed: bool = Field(default=True, description="Notify on print failed")
     on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
     on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
     on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
     on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
+    on_print_missing_spool_assignment: bool = Field(
+        default=False,
+        description="Notify when a print starts with required trays missing spool assignments",
+    )
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
@@ -119,6 +123,7 @@ class NotificationProviderUpdate(BaseModel):
     on_print_failed: bool | None = None
     on_print_failed: bool | None = None
     on_print_stopped: bool | None = None
     on_print_stopped: bool | None = None
     on_print_progress: bool | None = None
     on_print_progress: bool | None = None
+    on_print_missing_spool_assignment: bool | None = None
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool | None = None
     on_printer_offline: bool | None = None

+ 15 - 0
backend/app/schemas/notification_template.py

@@ -15,6 +15,7 @@ class EventType(StrEnum):
     PRINT_FAILED = "print_failed"
     PRINT_FAILED = "print_failed"
     PRINT_STOPPED = "print_stopped"
     PRINT_STOPPED = "print_stopped"
     PRINT_PROGRESS = "print_progress"
     PRINT_PROGRESS = "print_progress"
+    PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_ERROR = "printer_error"
     PRINTER_ERROR = "printer_error"
     FILAMENT_LOW = "filament_low"
     FILAMENT_LOW = "filament_low"
@@ -62,6 +63,13 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "app_name",
         "app_name",
     ],
     ],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "eta", "timestamp", "app_name"],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "eta", "timestamp", "app_name"],
+    "print_missing_spool_assignment": [
+        "printer",
+        "missing_slots",
+        "missing_slot_details",
+        "timestamp",
+        "app_name",
+    ],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -140,6 +148,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 15:00",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    "print_missing_spool_assignment": {
+        "printer": "Bambu X1C",
+        "missing_slots": "A1, A3",
+        "missing_slot_details": "- A1: PLA Basic\n- A3: PETG HF",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "printer_offline": {
     "printer_offline": {
         "printer": "Bambu X1C",
         "printer": "Bambu X1C",
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",

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

@@ -136,6 +136,7 @@ class AMSTray(BaseModel):
     nozzle_temp_max: int | None = None  # Max nozzle temperature
     nozzle_temp_max: int | None = None  # Max nozzle temperature
     drying_temp: int | None = None  # RFID-recommended drying temp
     drying_temp: int | None = None  # RFID-recommended drying temp
     drying_time: int | None = None  # RFID-recommended drying time (hours)
     drying_time: int | None = None  # RFID-recommended drying time (hours)
+    state: int | None = None  # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
 
 
 
 
 class AMSUnit(BaseModel):
 class AMSUnit(BaseModel):

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

@@ -48,6 +48,7 @@ class SmartPlugBase(BaseModel):
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
     auto_off: bool = True
     auto_off: bool = True
+    auto_off_persistent: bool = False
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
@@ -115,6 +116,7 @@ class SmartPlugUpdate(BaseModel):
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_on: bool | None = None
     auto_off: bool | None = None
     auto_off: bool | None = None
+    auto_off_persistent: bool | None = None
     off_delay_mode: Literal["time", "temperature"] | None = None
     off_delay_mode: Literal["time", "temperature"] | None = None
     off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
     off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
     off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
     off_temp_threshold: int | None = Field(default=None, ge=30, le=150)

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

@@ -16,6 +16,7 @@ class DeviceRegisterRequest(BaseModel):
     calibration_factor: float = 1.0
     calibration_factor: float = 1.0
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
     has_backlight: bool = False
     has_backlight: bool = False
 
 
 
 
@@ -31,6 +32,7 @@ class DeviceResponse(BaseModel):
     calibration_factor: float
     calibration_factor: float
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
     display_brightness: int = 100
     display_brightness: int = 100
     display_blank_timeout: int = 0
     display_blank_timeout: int = 0
     has_backlight: bool = False
     has_backlight: bool = False
@@ -42,7 +44,9 @@ class DeviceResponse(BaseModel):
     uptime_s: int
     uptime_s: int
     update_status: str | None = None
     update_status: str | None = None
     update_message: str | None = None
     update_message: str | None = None
+    system_stats: dict | None = None
     online: bool = False
     online: bool = False
+    ssh_public_key: str | None = None
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
 
 
@@ -58,11 +62,14 @@ class HeartbeatRequest(BaseModel):
     ip_address: str | None = None
     ip_address: str | None = None
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
+    system_stats: dict | None = None
 
 
 
 
 class HeartbeatResponse(BaseModel):
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
     pending_command: str | None = None
     pending_write_payload: dict | None = None
     pending_write_payload: dict | None = None
+    pending_system_payload: dict | None = None
     tare_offset: int
     tare_offset: int
     calibration_factor: float
     calibration_factor: float
     display_brightness: int = 100
     display_brightness: int = 100
@@ -104,10 +111,6 @@ class UpdateSpoolWeightRequest(BaseModel):
 # --- Calibration schemas ---
 # --- Calibration schemas ---
 
 
 
 
-class TareRequest(BaseModel):
-    pass
-
-
 class SetTareRequest(BaseModel):
 class SetTareRequest(BaseModel):
     tare_offset: int
     tare_offset: int
 
 
@@ -142,3 +145,24 @@ class WriteTagResultRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
     brightness: int = Field(ge=0, le=100)
     brightness: int = Field(ge=0, le=100)
     blank_timeout: int = Field(ge=0)
     blank_timeout: int = Field(ge=0)
+
+
+class SystemConfigRequest(BaseModel):
+    backend_url: str = Field(..., min_length=1, max_length=255)
+    api_key: str | None = Field(default=None, max_length=255)
+
+
+class SystemCommandResultRequest(BaseModel):
+    command: str
+    success: bool
+    message: str | None = None
+
+
+# --- Diagnostics schemas ---
+
+
+class DiagnosticResultRequest(BaseModel):
+    diagnostic: str  # 'nfc', 'scale', or 'read_tag'
+    success: bool
+    output: str
+    exit_code: int

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

@@ -565,6 +565,8 @@ class BackgroundDispatchService:
             elif base_name.endswith(".3mf"):
             elif base_name.endswith(".3mf"):
                 base_name = base_name[:-4]
                 base_name = base_name[:-4]
             remote_filename = f"{base_name}.3mf"
             remote_filename = f"{base_name}.3mf"
+            # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
+            remote_filename = remote_filename.replace(" ", "_")
             remote_path = f"/{remote_filename}"
             remote_path = f"/{remote_filename}"
 
 
             ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
             ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
@@ -732,6 +734,8 @@ class BackgroundDispatchService:
             elif base_name.endswith(".3mf"):
             elif base_name.endswith(".3mf"):
                 base_name = base_name[:-4]
                 base_name = base_name[:-4]
             remote_filename = f"{base_name}.3mf"
             remote_filename = f"{base_name}.3mf"
+            # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
+            remote_filename = remote_filename.replace(" ", "_")
             remote_path = f"/{remote_filename}"
             remote_path = f"/{remote_filename}"
 
 
             ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
             ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()

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

@@ -10,6 +10,7 @@ but with qos=1 they respond instantly.
 import asyncio
 import asyncio
 import json
 import json
 import logging
 import logging
+import os
 import ssl
 import ssl
 import threading
 import threading
 import time
 import time
@@ -268,6 +269,8 @@ class BambuMQTTClient:
     # Class-level cache: serial_number -> False when request topic is known unsupported.
     # Class-level cache: serial_number -> False when request topic is known unsupported.
     # Persists across client instances so reconnects don't re-trigger failed subscriptions.
     # Persists across client instances so reconnects don't re-trigger failed subscriptions.
     _request_topic_cache: dict[str, bool] = {}
     _request_topic_cache: dict[str, bool] = {}
+    # Counter for generating unique MQTT client IDs across instances.
+    _client_instance_counter: int = 0
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -344,6 +347,13 @@ class BambuMQTTClient:
         self._request_topic_sub_time: float = 0.0
         self._request_topic_sub_time: float = 0.0
         self._request_topic_confirmed: bool = False
         self._request_topic_confirmed: bool = False
 
 
+        # Set when check_staleness() force-closes the socket to trigger reconnect.
+        # Prevents _on_disconnect from redundantly broadcasting state (already done).
+        self._stale_reconnecting: bool = False
+        # Timestamp of last stale reconnect — prevents rapid-fire socket closes
+        # when the frontend polls status faster than paho can reconnect.
+        self._last_stale_reconnect: float = 0.0
+
     @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"
@@ -362,20 +372,47 @@ class BambuMQTTClient:
         time_since_last = time.time() - self._last_message_time
         time_since_last = time.time() - self._last_message_time
         return time_since_last > self.STALE_TIMEOUT
         return time_since_last > self.STALE_TIMEOUT
 
 
+    # Minimum seconds between stale reconnect attempts.  Frontend polls
+    # status every few seconds — without a cooldown, each poll would
+    # force-close the socket before paho has time to reconnect.
+    STALE_RECONNECT_COOLDOWN = 30.0
+
     def check_staleness(self) -> bool:
     def check_staleness(self) -> bool:
         """Check staleness and update connected state if stale. Returns True if connected."""
         """Check staleness and update connected state if stale. Returns True if connected."""
         if self.state.connected and self.is_stale():
         if self.state.connected and self.is_stale():
+            # Don't force-close again if we already did recently — give paho
+            # time to reconnect and the printer time to send its first message.
+            now = time.time()
+            if now - self._last_stale_reconnect < self.STALE_RECONNECT_COOLDOWN:
+                return self.state.connected
+
             logger.warning(
             logger.warning(
-                f"[{self.serial_number}] Connection stale - no message for {time.time() - self._last_message_time:.1f}s"
+                f"[{self.serial_number}] Connection stale - no message for {now - self._last_message_time:.1f}s, forcing reconnect"
             )
             )
+            self._last_stale_reconnect = now
             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)
+            # Force-close the underlying socket so paho's loop thread detects
+            # the broken connection and triggers auto-reconnect.  We don't call
+            # client.disconnect() because that's a clean disconnect and paho
+            # would NOT auto-reconnect afterwards.
+            # Set flag so _on_disconnect knows this was intentional and skips
+            # redundant state broadcast (we already set connected=False above).
+            self._stale_reconnecting = True
+            if self._client:
+                try:
+                    sock = self._client.socket()
+                    if sock:
+                        sock.close()
+                except Exception:
+                    pass  # Best-effort; paho loop will reconnect on next iteration
         return self.state.connected
         return self.state.connected
 
 
     def _on_connect(self, client, userdata, flags, rc, properties=None):
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
         if rc == 0:
             self.state.connected = True
             self.state.connected = True
+            self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             # Reset per-connection warning state so warnings fire once per (re)connection
             # Reset per-connection warning state so warnings fire once per (re)connection
             self._ams_version_warned = set()
             self._ams_version_warned = set()
             client.subscribe(self.topic_subscribe)
             client.subscribe(self.topic_subscribe)
@@ -433,10 +470,30 @@ class BambuMQTTClient:
             self._request_topic_sub_time = 0.0
             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):
+        # Always unblock disconnect() callers, regardless of whether we suppress
+        # the state broadcast below.  disconnect() sets _disconnection_event and
+        # waits on it — every callback path must fire it.
+        if self._disconnection_event:
+            self._disconnection_event.set()
+
+        # If we intentionally closed the socket for stale reconnect, don't broadcast
+        # another state change — check_staleness() already set connected=False and
+        # notified the UI.  Just log and let paho auto-reconnect.
+        if self._stale_reconnecting:
+            logger.info(
+                "[%s] Disconnect callback after stale reconnect (expected), rc=%s",
+                self.serial_number,
+                rc,
+            )
+            return
+
         # 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.
+        # BUT: never suppress error disconnects (keepalive timeout, connection lost, etc.)
+        # — only suppress when rc indicates a clean/normal disconnect.
+        is_error_disconnect = rc is not None and hasattr(rc, "is_failure") and rc.is_failure
         time_since_last_message = time.time() - self._last_message_time
         time_since_last_message = time.time() - self._last_message_time
-        if time_since_last_message < 30.0 and self._last_message_time > 0:
+        if not is_error_disconnect and time_since_last_message < 10.0 and self._last_message_time > 0:
             logger.debug(
             logger.debug(
                 f"[{self.serial_number}] Ignoring spurious disconnect (last message {time_since_last_message:.1f}s ago)"
                 f"[{self.serial_number}] Ignoring spurious disconnect (last message {time_since_last_message:.1f}s ago)"
             )
             )
@@ -464,8 +521,6 @@ class BambuMQTTClient:
         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)
-        if self._disconnection_event:
-            self._disconnection_event.set()
 
 
     def _on_message(self, client, userdata, msg):
     def _on_message(self, client, userdata, msg):
         try:
         try:
@@ -491,10 +546,6 @@ class BambuMQTTClient:
                 self._handle_request_message(payload)
                 self._handle_request_message(payload)
                 return
                 return
 
 
-            # TEMP: Dump full payload once to find extruder state field
-            if not hasattr(self, "_payload_dumped"):
-                self._payload_dumped = True
-                logger.debug("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2))
             # Log message if logging is enabled
             # Log message if logging is enabled
             if self._logging_enabled:
             if self._logging_enabled:
                 self._message_log.append(
                 self._message_log.append(
@@ -1358,6 +1409,44 @@ class BambuMQTTClient:
                             # When tray_type is explicitly empty, clear everything
                             # When tray_type is explicitly empty, clear everything
                             # including RFID data (tag_uid/tray_uuid).
                             # including RFID data (tag_uid/tray_uuid).
                             slot_clearing = new_tray.get("tray_type") == ""
                             slot_clearing = new_tray.get("tray_type") == ""
+                            # Some printers (e.g. H2D) only send {id, state} in
+                            # incremental updates when a tray is not fully loaded.
+                            # state=11 means loaded; other values (9=empty,
+                            # 10=spool present but filament not in feeder) indicate
+                            # the slot should be cleared.  Without this, old
+                            # tray_type/tray_color persist indefinitely (#784).
+                            tray_state = new_tray.get("state")
+                            if (
+                                tray_state is not None
+                                and tray_state != 11
+                                and "tray_type" not in new_tray
+                                and merged_tray.get("tray_type")
+                            ):
+                                logger.info(
+                                    "[%s] AMS %s tray %s: state=%s (not loaded) — clearing stale tray data",
+                                    self.serial_number,
+                                    ams_id,
+                                    tray_id,
+                                    tray_state,
+                                )
+                                slot_clearing = True
+                                # The incremental update only has {id, state} — inject
+                                # empty values for all content fields so the merge loop
+                                # below clears the stale data from merged_tray.
+                                new_tray.update(
+                                    {
+                                        "tray_type": "",
+                                        "tray_sub_brands": "",
+                                        "tray_color": "",
+                                        "tray_id_name": "",
+                                        "tray_info_idx": "",
+                                        "tag_uid": "0000000000000000",
+                                        "tray_uuid": "00000000000000000000000000000000",
+                                        "remain": 0,
+                                        "k": None,
+                                        "cali_idx": None,
+                                    }
+                                )
                             for key, value in new_tray.items():
                             for key, value in new_tray.items():
                                 # Fields that should always be updated (even with empty/zero values):
                                 # Fields that should always be updated (even with empty/zero values):
                                 # - remain, k, id, cali_idx: status indicators where 0 is valid
                                 # - remain, k, id, cali_idx: status indicators where 0 is valid
@@ -2416,13 +2505,20 @@ class BambuMQTTClient:
         ):
         ):
             should_trigger_completion = True
             should_trigger_completion = True
 
 
-        # Log when we see a terminal state but DON'T trigger completion (diagnostics)
-        if not should_trigger_completion and self.state.state in ("FINISH", "FAILED"):
+        # Log when we FIRST see a terminal state but DON'T trigger completion (diagnostics)
+        # Only log on the transition (prev != current) to avoid flooding logs every MQTT update
+        if (
+            not should_trigger_completion
+            and self.state.state in ("FINISH", "FAILED")
+            and self._previous_gcode_state != self.state.state
+        ):
             logger.info(
             logger.info(
                 f"[{self.serial_number}] State is {self.state.state} but completion NOT triggered: "
                 f"[{self.serial_number}] State is {self.state.state} but completion NOT triggered: "
                 f"prev={self._previous_gcode_state}, was_running={self._was_running}, "
                 f"prev={self._previous_gcode_state}, was_running={self._was_running}, "
                 f"already_triggered={self._completion_triggered}, has_callback={bool(self.on_print_complete)}"
                 f"already_triggered={self._completion_triggered}, has_callback={bool(self.on_print_complete)}"
             )
             )
+            # Mark as triggered so state is clean for the next print cycle
+            self._completion_triggered = True
 
 
         if should_trigger_completion:
         if should_trigger_completion:
             if self.state.state == "FINISH":
             if self.state.state == "FINISH":
@@ -2551,9 +2647,11 @@ class BambuMQTTClient:
                   If not provided, will try to get the running loop.
                   If not provided, will try to get the running loop.
         """
         """
         self._loop = loop
         self._loop = loop
+        BambuMQTTClient._client_instance_counter += 1
+        client_id = f"bambuddy_{self.serial_number}_{os.getpid()}_{BambuMQTTClient._client_instance_counter}"
         self._client = mqtt.Client(
         self._client = mqtt.Client(
             callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
             callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
-            client_id=f"bambuddy_{self.serial_number}",
+            client_id=client_id,
             protocol=mqtt.MQTTv311,
             protocol=mqtt.MQTTv311,
         )
         )
 
 
@@ -2569,9 +2667,16 @@ class BambuMQTTClient:
         ssl_context.verify_mode = ssl.CERT_NONE
         ssl_context.verify_mode = ssl.CERT_NONE
         self._client.tls_set_context(ssl_context)
         self._client.tls_set_context(ssl_context)
 
 
-        # Use shorter keepalive (15s) for faster disconnect detection
-        # Paho considers connection lost after 1.5x keepalive with no response
-        self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
+        # Backoff reconnects to avoid tight reconnect loops on unstable brokers.
+        self._client.reconnect_delay_set(min_delay=1, max_delay=30)
+
+        # Keepalive: paho sends PINGREQs at this interval, broker considers
+        # client dead at 1.5x.  30s is a good balance — fast enough to detect
+        # real network loss (45s), not so aggressive that transient hiccups
+        # trigger false disconnects.  Stale detection (60s no messages) handles
+        # the P1S/P1P firmware bug where the broker stops publishing but the
+        # TCP connection stays alive.
+        self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=30)
         self._client.loop_start()
         self._client.loop_start()
 
 
     def start_print(
     def start_print(
@@ -2606,25 +2711,36 @@ class BambuMQTTClient:
             # Bambu print command format - matches Bambu Studio's format
             # Bambu print command format - matches Bambu Studio's format
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
             ams_mapping2 = []
+            # BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
+            # ams_mapping and relies on ams_mapping2 for external spool details.
+            # Passing raw 254/255 in the flat array causes H2D firmware to fail
+            # with 0700_8012 "Failed to get AMS mapping table".
+            flat_ams_mapping = []
             if ams_mapping is not None:
             if ams_mapping is not None:
                 for tray_id in ams_mapping:
                 for tray_id in ams_mapping:
                     # Ensure tray_id is an integer (may be string from JSON)
                     # Ensure tray_id is an integer (may be string from JSON)
                     tray_id = int(tray_id) if tray_id is not None else -1
                     tray_id = int(tray_id) if tray_id is not None else -1
                     if tray_id == -1:
                     if tray_id == -1:
                         # Unmapped filament slot
                         # Unmapped filament slot
+                        flat_ams_mapping.append(-1)
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                     elif tray_id >= 254:
                     elif tray_id >= 254:
-                        # External spool: 254 = main nozzle, 255 = deputy nozzle
-                        # For ams_mapping2, slot_id is 0 (main) or 1 (deputy), not the tray_id
-                        external_slot = 0 if tray_id == 254 else 1
-                        ams_mapping2.append({"ams_id": 255, "slot_id": external_slot})
+                        # External/virtual spool: each virtual tray is its own AMS unit
+                        # with a single slot (slot 0). BambuStudio convention:
+                        #   255 = VIRTUAL_TRAY_MAIN_ID (main/left nozzle)
+                        #   254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/right nozzle)
+                        # Flat mapping must use -1 (firmware doesn't accept raw 254/255).
+                        flat_ams_mapping.append(-1)
+                        ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                     elif tray_id >= 128:
                     elif tray_id >= 128:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
+                        flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                     else:
                     else:
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         ams_id = tray_id // 4
                         ams_id = tray_id // 4
                         slot_id = tray_id % 4
                         slot_id = tray_id % 4
+                        flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
 
             # H2D series requires integer values (0/1) for calibration/leveling fields
             # H2D series requires integer values (0/1) for calibration/leveling fields
@@ -2675,7 +2791,7 @@ class BambuMQTTClient:
 
 
             # Add AMS mapping if provided
             # Add AMS mapping if provided
             if ams_mapping is not None:
             if ams_mapping is not None:
-                command["print"]["ams_mapping"] = ams_mapping
+                command["print"]["ams_mapping"] = flat_ams_mapping
                 command["print"]["ams_mapping2"] = ams_mapping2
                 command["print"]["ams_mapping2"] = ams_mapping2
 
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))

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

@@ -934,6 +934,47 @@ class NotificationService:
             providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
             providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
         )
         )
 
 
+    async def on_print_missing_spool_assignment(
+        self,
+        printer_id: int,
+        printer_name: str,
+        missing_slots: list[dict[str, str]],
+        db: AsyncSession,
+    ):
+        """Handle print-start event when required trays are missing spool assignments."""
+        if not missing_slots:
+            return
+
+        providers = await self._get_providers_for_event(db, "on_print_missing_spool_assignment", printer_id)
+        if not providers:
+            return
+
+        missing_slot_names = ", ".join(slot.get("slot", "Unknown") for slot in missing_slots)
+        detail_lines = []
+        for slot in missing_slots:
+            slot_name = slot.get("slot", "Unknown")
+            profile = slot.get("profile", "Unknown")
+            detail_lines.append(f"- {slot_name}: {profile}")
+        missing_profile_details = "\n".join(detail_lines)
+
+        variables = {
+            "printer": printer_name,
+            "missing_slots": missing_slot_names,
+            "missing_slot_details": missing_profile_details,
+        }
+
+        title, message = await self._build_message_from_template(db, "print_missing_spool_assignment", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "print_missing_spool_assignment",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+        )
+
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)

+ 2 - 0
backend/app/services/print_scheduler.py

@@ -1571,6 +1571,8 @@ class PrintScheduler:
         elif base_name.endswith(".3mf"):
         elif base_name.endswith(".3mf"):
             base_name = base_name[:-4]  # Remove .3mf
             base_name = base_name[:-4]  # Remove .3mf
         remote_filename = f"{base_name}.3mf"
         remote_filename = f"{base_name}.3mf"
+        # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
+        remote_filename = remote_filename.replace(" ", "_")
         # Upload to root directory (not /cache/) - the start_print command references
         # Upload to root directory (not /cache/) - the start_print command references
         # files by name only (ftp://{filename}), so they must be in the root
         # files by name only (ftp://{filename}), so they must be in the root
         remote_path = f"/{remote_filename}"
         remote_path = f"/{remote_filename}"

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

@@ -55,6 +55,19 @@ A1_MODELS = frozenset(
     ]
     ]
 )
 )
 
 
+# Models affected by the stg_cur=0 idle bug (firmware reports stg_cur=0 when idle,
+# which maps to "Printing" in STAGE_NAMES and overrides the correct IDLE state)
+STG_CUR_IDLE_BUG_MODELS = A1_MODELS | frozenset(
+    [
+        # Display names
+        "P1P",
+        "P1S",
+        # Internal codes (from MQTT/SSDP)
+        "C11",  # P1P
+        "C12",  # P1S
+    ]
+)
+
 
 
 def supports_chamber_temp(model: str | None) -> bool:
 def supports_chamber_temp(model: str | None) -> bool:
     """Check if a printer model has a real chamber temperature sensor.
     """Check if a printer model has a real chamber temperature sensor.
@@ -72,14 +85,14 @@ def supports_chamber_temp(model: str | None) -> bool:
 def has_stg_cur_idle_bug(model: str | None) -> bool:
 def has_stg_cur_idle_bug(model: str | None) -> bool:
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
 
 
-    Some A1/A1 Mini firmware versions report stg_cur=0 (which maps to "Printing")
-    even when the printer is idle. This is a known firmware bug that was observed
-    in the Home Assistant Bambu Lab integration.
+    Some firmware versions report stg_cur=0 (which maps to "Printing")
+    even when the printer is idle. Originally observed on A1/A1 Mini via the
+    Home Assistant Bambu Lab integration, also confirmed on P1S.
     """
     """
     if not model:
     if not model:
         return False
         return False
     model_upper = model.strip().upper()
     model_upper = model.strip().upper()
-    return model_upper in A1_MODELS
+    return model_upper in STG_CUR_IDLE_BUG_MODELS
 
 
 
 
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
@@ -612,6 +625,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                         "nozzle_temp_max": tray.get("nozzle_temp_max"),
                         "nozzle_temp_max": tray.get("nozzle_temp_max"),
                         "drying_temp": tray.get("drying_temp"),
                         "drying_temp": tray.get("drying_temp"),
                         "drying_time": tray.get("drying_time"),
                         "drying_time": tray.get("drying_time"),
+                        "state": tray.get("state"),
                     }
                     }
                 )
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)

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

@@ -416,7 +416,7 @@ class SmartPlugManager:
             logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
             logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
 
 
     async def _mark_auto_off_executed(self, plug_id: int):
     async def _mark_auto_off_executed(self, plug_id: int):
-        """Disable auto-off after it was executed (one-shot behavior)."""
+        """Disable auto-off after it was executed (one-shot behavior unless persistent)."""
         try:
         try:
             from backend.app.core.database import async_session
             from backend.app.core.database import async_session
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
@@ -425,14 +425,18 @@ class SmartPlugManager:
                 result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
                 result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug:
                 if plug:
-                    plug.auto_off = False  # Disable auto-off (one-shot behavior)
+                    if not plug.auto_off_persistent:
+                        plug.auto_off = False  # Disable auto-off (one-shot behavior)
                     plug.auto_off_executed = False  # Reset the flag
                     plug.auto_off_executed = False  # Reset the flag
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending_since = None
                     plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
                     plug.last_state = "OFF"
                     plug.last_checked = datetime.now(timezone.utc)
                     plug.last_checked = datetime.now(timezone.utc)
                     await db.commit()
                     await db.commit()
-                    logger.info("Auto-off executed and disabled for plug %s", plug_id)
+                    if plug.auto_off_persistent:
+                        logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
+                    else:
+                        logger.info("Auto-off executed and disabled for plug %s", plug_id)
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
             logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
 
 

+ 169 - 0
backend/app/services/spool_assignment_notifications.py

@@ -0,0 +1,169 @@
+import logging
+
+from backend.app.core.database import async_session
+from backend.app.core.websocket import ws_manager
+from backend.app.models.printer import Printer
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.notification_service import notification_service
+from backend.app.services.printer_manager import printer_manager
+
+
+def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
+    """Convert an assignment tuple to Bambuddy global tray ID."""
+    if ams_id in (254, 255):
+        return 254 + tray_id
+    if ams_id >= 128:
+        return ams_id
+    return ams_id * 4 + tray_id
+
+
+def _slot_label_from_global_tray(global_tray_id: int) -> str:
+    """Return a human-readable slot label from a global tray ID."""
+    if global_tray_id == 254:
+        return "Ext-L"
+    if global_tray_id == 255:
+        return "Ext-R"
+    if global_tray_id >= 128:
+        return f"HT-{chr(65 + (global_tray_id - 128))}"
+    ams_id = global_tray_id // 4
+    tray_id = global_tray_id % 4
+    return f"{chr(65 + ams_id)}{tray_id + 1}"
+
+
+def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:
+    """Resolve expected tray material/profile and color for a global tray ID from current printer state."""
+    if not state or not state.raw_data:
+        return ("Unknown", "Unknown")
+
+    ams_raw = state.raw_data.get("ams", {})
+    ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+
+    vt_trays = state.raw_data.get("vt_tray", [])
+    if not isinstance(vt_trays, list):
+        vt_trays = []
+
+    for tray in vt_trays:
+        if not isinstance(tray, dict):
+            continue
+        if int(tray.get("id", -1)) == global_tray_id:
+            profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
+            color = tray.get("tray_color") or "Unknown"
+            return (profile, color)
+
+    for ams in ams_units:
+        if not isinstance(ams, dict):
+            continue
+        ams_id = int(ams.get("id", -1))
+        trays = ams.get("tray", [])
+        if not isinstance(trays, list):
+            continue
+        for tray in trays:
+            if not isinstance(tray, dict):
+                continue
+            tray_id = int(tray.get("id", -1))
+            candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
+            if candidate == global_tray_id:
+                profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
+                color = tray.get("tray_color") or "Unknown"
+                return (profile, color)
+
+    return ("Unknown", "Unknown")
+
+
+def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
+    """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
+    if not isinstance(mapping_raw, list) or not mapping_raw:
+        return []
+
+    decoded: list[int] = []
+    for value in mapping_raw:
+        try:
+            if isinstance(value, int):
+                encoded = value
+            elif isinstance(value, str):
+                encoded = int(value, 10)
+            else:
+                continue
+        except ValueError:
+            continue
+
+        if encoded >= 65535:
+            continue
+
+        ams_hw_id = (encoded >> 8) & 0xFF
+        slot = encoded & 0xFF
+
+        if 0 <= ams_hw_id <= 3:
+            decoded.append(ams_hw_id * 4 + (slot & 0x03))
+        elif 128 <= ams_hw_id <= 135:
+            decoded.append(ams_hw_id)
+        elif ams_hw_id in (254, 255):
+            decoded.append(255 if slot == 255 else 254)
+
+    return decoded
+
+
+async def notify_missing_spool_assignments_on_print_start(
+    printer_id: int,
+    data: dict,
+    logger: logging.Logger,
+) -> None:
+    """Send notification when print-start mapping references unassigned trays."""
+    explicit_mapping = data.get("ams_mapping")
+    explicit_values = (
+        [value for value in explicit_mapping if isinstance(value, int)]
+        if isinstance(explicit_mapping, list)
+        else []
+    )
+    raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
+    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
+    mapping_values = explicit_values if explicit_values else decoded_values
+
+    used_global_trays = {value for value in mapping_values if value >= 0}
+    if not used_global_trays:
+        return
+
+    try:
+        async with async_session() as db:
+            printer = await db.get(Printer, printer_id)
+            printer_name = printer.name if printer else f"Printer {printer_id}"
+
+            assignments_result = await db.execute(
+                SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id)
+            )
+            assignments = assignments_result.fetchall()
+            assigned_global_trays = {
+                _global_tray_from_assignment(assignment.ams_id, assignment.tray_id) for assignment in assignments
+            }
+
+            missing_global = sorted(used_global_trays - assigned_global_trays)
+            if not missing_global:
+                return
+
+            state = printer_manager.get_status(printer_id)
+            missing_slots = []
+            for global_id in missing_global:
+                profile, color = _tray_profile_and_color_for_global_id(state, global_id)
+                missing_slots.append(
+                    {
+                        "slot": _slot_label_from_global_tray(global_id),
+                        "profile": profile,
+                        "color": color,
+                    }
+                )
+
+            await ws_manager.send_missing_spool_assignment(
+                printer_id=printer_id,
+                printer_name=printer_name,
+                missing_slots=missing_slots,
+            )
+
+            await notification_service.on_print_missing_spool_assignment(
+                printer_id=printer_id,
+                printer_name=printer_name,
+                missing_slots=missing_slots,
+                db=db,
+            )
+    except Exception as e:
+        logger.warning("Missing spool-assignment notification failed: %s", e)

+ 178 - 10
backend/app/services/spool_tag_matcher.py

@@ -2,12 +2,16 @@
 
 
 import logging
 import logging
 
 
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.utils.tag_normalization import (
+    normalize_tag_uid as _normalize_tag_uid,
+    normalize_tray_uuid as _normalize_tray_uuid,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,14 +22,17 @@ ZERO_TRAY_UUID = "00000000000000000000000000000000"
 
 
 def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
 def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
     """Check if a tag/UUID pair contains a non-zero, non-empty value."""
     """Check if a tag/UUID pair contains a non-zero, non-empty value."""
-    uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid)
-    uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
+    uid = _normalize_tag_uid(tag_uid)
+    uuid = _normalize_tray_uuid(tray_uuid)
+    uid_valid = bool(uid) and uid != ZERO_TAG_UID and uid != "0" * len(uid)
+    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
     return uid_valid or uuid_valid
     return uid_valid or uuid_valid
 
 
 
 
 def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
 def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
     """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
     """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
-    uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
+    uuid = _normalize_tray_uuid(tray_uuid)
+    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
     has_preset = bool(tray_info_idx)
     has_preset = bool(tray_info_idx)
     return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
     return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
 
 
@@ -43,8 +50,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
     tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
     tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
     tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
     tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
     tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
-    tag_uid = tray_data.get("tag_uid", "")
-    tray_uuid = tray_data.get("tray_uuid", "")
+    tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", ""))
+    tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", ""))
     tray_info_idx = tray_data.get("tray_info_idx", "")
     tray_info_idx = tray_data.get("tray_info_idx", "")
     nozzle_min = tray_data.get("nozzle_temp_min", 0)
     nozzle_min = tray_data.get("nozzle_temp_min", 0)
     nozzle_max = tray_data.get("nozzle_temp_max", 0)
     nozzle_max = tray_data.get("nozzle_temp_max", 0)
@@ -165,17 +172,118 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     return spool
     return spool
 
 
 
 
+async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:
+    """Find an existing untagged inventory spool matching brand/material/color.
+
+    When a Bambu Lab spool is detected in the AMS but no tag match exists,
+    check if the user has a manually-added spool with the same properties
+    that hasn't been linked to a tag yet. Returns the oldest match (FIFO).
+    """
+    tray_type = tray_data.get("tray_type", "")
+    tray_sub_brands = tray_data.get("tray_sub_brands", "")
+    tray_color = tray_data.get("tray_color", "")  # RRGGBBAA
+
+    if not tray_type or not tray_color:
+        return None
+
+    # Parse material the same way create_spool_from_tray does
+    material = tray_type
+    subtype = None
+    if tray_sub_brands and " " in tray_sub_brands:
+        parts = tray_sub_brands.split(" ", 1)
+        if parts[0].upper() == material.upper():
+            subtype = parts[1]
+        else:
+            material = tray_sub_brands
+    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
+        material = tray_sub_brands
+
+    # Build query: active spools with no tag, matching brand + material + color
+    query = (
+        select(Spool)
+        .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
+        .where(
+            Spool.archived_at.is_(None),
+            Spool.tag_uid.is_(None),
+            Spool.tray_uuid.is_(None),
+            func.upper(Spool.material) == material.upper(),
+            func.upper(Spool.rgba) == tray_color.upper(),
+        )
+    )
+
+    # Match subtype if parsed (e.g. "Basic", "Matte")
+    if subtype:
+        query = query.where(func.upper(Spool.subtype) == subtype.upper())
+    else:
+        query = query.where(Spool.subtype.is_(None))
+
+    # FIFO: oldest spool first (user likely added in purchase order)
+    query = query.order_by(Spool.created_at.asc()).limit(1)
+
+    result = await db.execute(query)
+    spool = result.scalar_one_or_none()
+
+    if spool:
+        logger.info(
+            "Found matching untagged spool %d: %s %s %s (rgba=%s)",
+            spool.id,
+            spool.brand or "",
+            spool.material,
+            spool.color_name or "",
+            spool.rgba or "",
+        )
+
+    return spool
+
+
+async def link_tag_to_inventory_spool(db: AsyncSession, spool: Spool, tray_data: dict) -> None:
+    """Link RFID tag data from AMS tray to an existing inventory spool."""
+    tag_uid = tray_data.get("tag_uid", "")
+    tray_uuid = tray_data.get("tray_uuid", "")
+    tray_info_idx = tray_data.get("tray_info_idx", "")
+
+    if tag_uid and tag_uid != ZERO_TAG_UID:
+        spool.tag_uid = tag_uid
+    if tray_uuid and tray_uuid != ZERO_TRAY_UUID:
+        spool.tray_uuid = tray_uuid
+    spool.data_origin = "rfid_linked"
+    spool.tag_type = "bambulab"
+
+    # Update slicer preset if not already set
+    if tray_info_idx and not spool.slicer_filament:
+        spool.slicer_filament = tray_info_idx
+        try:
+            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+            name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
+            if name and not spool.slicer_filament_name:
+                spool.slicer_filament_name = name
+        except Exception:
+            pass
+
+    await db.flush()
+    logger.info(
+        "Linked RFID tag to existing spool %d (tag=%s uuid=%s origin=rfid_linked)",
+        spool.id,
+        spool.tag_uid or "",
+        spool.tray_uuid or "",
+    )
+
+
 async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
 async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
     """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
     """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
 
 
     Prefers tray_uuid match over tag_uid (more reliable).
     Prefers tray_uuid match over tag_uid (more reliable).
     """
     """
+    tray_uuid_norm = _normalize_tray_uuid(tray_uuid)
+    tag_uid_norm = _normalize_tag_uid(tag_uid)
+
     # Try tray_uuid first (Bambu Lab spools — more reliable)
     # Try tray_uuid first (Bambu Lab spools — more reliable)
-    if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid):
+    if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != "0" * len(tray_uuid_norm):
         result = await db.execute(
         result = await db.execute(
             select(Spool)
             select(Spool)
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
-            .where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None))
+            .where(func.upper(Spool.tray_uuid) == tray_uuid_norm, Spool.archived_at.is_(None))
             .limit(1)
             .limit(1)
         )
         )
         spool = result.scalar_one_or_none()
         spool = result.scalar_one_or_none()
@@ -183,17 +291,77 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp
             return spool
             return spool
 
 
     # Fall back to tag_uid
     # Fall back to tag_uid
-    if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid):
+    if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != "0" * len(tag_uid_norm):
         result = await db.execute(
         result = await db.execute(
             select(Spool)
             select(Spool)
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
-            .where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None))
+            .where(func.upper(Spool.tag_uid) == tag_uid_norm, Spool.archived_at.is_(None))
             .limit(1)
             .limit(1)
         )
         )
         spool = result.scalar_one_or_none()
         spool = result.scalar_one_or_none()
         if spool:
         if spool:
             return spool
             return spool
 
 
+        # Compatibility fallback: some readers report 4-byte UID (8 hex) while
+        # stored values may contain longer forms. Prefer suffix match only.
+        if len(tag_uid_norm) >= 8:
+            suffix8 = tag_uid_norm[-8:]
+            short_uid_body = tag_uid_norm[1:] if len(tag_uid_norm) == 8 else ""
+
+            # Build LIKE patterns for candidates search
+            like_patterns = [
+                func.upper(Spool.tag_uid).like(f"%{tag_uid_norm}"),
+                func.upper(Spool.tag_uid).like(f"%{suffix8}"),
+            ]
+            if short_uid_body:
+                like_patterns.append(func.upper(Spool.tag_uid).like(f"%{short_uid_body}%"))
+
+            candidates = await db.execute(
+                select(Spool)
+                .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
+                .where(
+                    Spool.tag_uid.is_not(None),
+                    Spool.archived_at.is_(None),
+                    or_(*like_patterns),
+                )
+                .limit(100)
+            )
+            for candidate in candidates.scalars().all():
+                candidate_uid = _normalize_tag_uid(candidate.tag_uid)
+                if not candidate_uid:
+                    continue
+                if candidate_uid == tag_uid_norm:
+                    return candidate
+                if len(candidate_uid) > len(tag_uid_norm) and candidate_uid.endswith(tag_uid_norm):
+                    return candidate
+                if len(tag_uid_norm) > len(candidate_uid) and tag_uid_norm.endswith(candidate_uid):
+                    return candidate
+                # Backward-compatible matching: allow first-character mismatch
+                # when remaining characters match. This handles cases where the same
+                # physical tag reports different first bytes across different readers
+                # (e.g., one reader reports "A45012F", another reports "B45012F").
+                if len(tag_uid_norm) == len(candidate_uid) and len(tag_uid_norm) > 1:
+                    # Same length: check if all chars except the first match
+                    if candidate_uid[1:] == tag_uid_norm[1:]:
+                        logger.warning(
+                            "Matched spool %d via first-char variance: stored=%s → scanned=%s",
+                            candidate.id,
+                            candidate_uid,
+                            tag_uid_norm,
+                        )
+                        return candidate
+                # Short UID (8 chars) matching: allow first-character mismatch
+                # within the first 8 bytes when remaining 7 chars match.
+                if len(tag_uid_norm) == 8 and len(candidate_uid) >= 8:
+                    if candidate_uid[:8][1:] == tag_uid_norm[1:]:
+                        logger.warning(
+                            "Matched spool %d via short UID variance: stored=%s → scanned=%s",
+                            candidate.id,
+                            candidate_uid,
+                            tag_uid_norm,
+                        )
+                        return candidate
+
     return None
     return None
 
 
 
 

+ 247 - 0
backend/app/services/spoolbuddy_ssh.py

@@ -0,0 +1,247 @@
+"""SSH-based update service for SpoolBuddy devices.
+
+Instead of the daemon updating itself (fragile: permission issues, self-modifying
+code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
+update remotely: git fetch/checkout, pip install, systemctl restart.
+"""
+
+import asyncio
+import logging
+import os
+import shutil
+from pathlib import Path
+
+from backend.app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+SSH_USER = "spoolbuddy"
+DEFAULT_INSTALL_PATH = "/opt/bambuddy"
+
+
+def _get_ssh_key_dir() -> Path:
+    """Return (and create if needed) the directory for SpoolBuddy SSH keys."""
+    key_dir = settings.base_dir / "spoolbuddy" / "ssh"
+    if not key_dir.exists():
+        key_dir.mkdir(mode=0o700, parents=True)
+    return key_dir
+
+
+async def get_or_create_keypair() -> tuple[Path, Path]:
+    """Return (private_key_path, public_key_path), generating if missing."""
+    key_dir = _get_ssh_key_dir()
+    private_key = key_dir / "id_ed25519"
+    public_key = key_dir / "id_ed25519.pub"
+
+    if private_key.exists() and public_key.exists():
+        return private_key, public_key
+
+    logger.info("Generating SSH keypair for SpoolBuddy updates")
+    proc = await asyncio.create_subprocess_exec(
+        "ssh-keygen",
+        "-t",
+        "ed25519",
+        "-f",
+        str(private_key),
+        "-N",
+        "",  # no passphrase
+        "-C",
+        "bambuddy-spoolbuddy",
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    _, stderr = await proc.communicate()
+    if proc.returncode != 0:
+        raise RuntimeError(f"ssh-keygen failed: {stderr.decode()[:200]}")
+
+    private_key.chmod(0o600)
+    logger.info("SSH keypair generated at %s", key_dir)
+    return private_key, public_key
+
+
+async def get_public_key() -> str:
+    """Return the SSH public key content for pairing."""
+    _, public_key = await get_or_create_keypair()
+    return public_key.read_text().strip()
+
+
+def detect_current_branch() -> str:
+    """Detect the git branch Bambuddy is running on.
+
+    For native installs, reads from the .git directory.
+    For Docker (no .git), falls back to GIT_BRANCH env var, then "main".
+    """
+    git_dir = settings.base_dir / ".git"
+    if git_dir.exists():
+        git_path = shutil.which("git") or "/usr/bin/git"
+        try:
+            import subprocess
+
+            result = subprocess.run(
+                [git_path, "rev-parse", "--abbrev-ref", "HEAD"],
+                cwd=str(settings.base_dir),
+                capture_output=True,
+                text=True,
+                timeout=5,
+            )
+            if result.returncode == 0 and result.stdout.strip():
+                return result.stdout.strip()
+        except Exception:
+            pass
+
+    return os.environ.get("GIT_BRANCH", "main")
+
+
+async def _run_ssh_command(
+    ip: str,
+    command: str,
+    private_key: Path,
+    timeout: int = 60,
+) -> tuple[int, str, str]:
+    """Execute a command on a SpoolBuddy device via SSH.
+
+    Returns (returncode, stdout, stderr).
+    """
+    ssh_path = shutil.which("ssh") or "/usr/bin/ssh"
+    proc = await asyncio.create_subprocess_exec(
+        ssh_path,
+        "-i",
+        str(private_key),
+        "-o",
+        "StrictHostKeyChecking=no",
+        "-o",
+        "UserKnownHostsFile=/dev/null",
+        "-o",
+        "ConnectTimeout=10",
+        "-o",
+        "BatchMode=yes",
+        "-o",
+        "LogLevel=ERROR",
+        f"{SSH_USER}@{ip}",
+        command,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    try:
+        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+    except TimeoutError:
+        proc.kill()
+        await proc.communicate()
+        return -1, "", "SSH command timed out"
+
+    return proc.returncode, stdout.decode(), stderr.decode()
+
+
+async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
+    """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
+
+    Updates device.update_status/update_message in the DB and broadcasts
+    progress via WebSocket at each step.
+    """
+    from sqlalchemy import select
+
+    from backend.app.api.routes.spoolbuddy import ws_manager
+    from backend.app.core.database import async_session
+    from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+    install_path = install_path or DEFAULT_INSTALL_PATH
+    branch = detect_current_branch()
+
+    async def _update_progress(status: str, message: str) -> None:
+        """Update device status in DB and broadcast via WebSocket."""
+        async with async_session() as db:
+            result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+            device = result.scalar_one_or_none()
+            if device:
+                device.update_status = status
+                device.update_message = message[:255] if message else None
+                if status in ("complete", "error"):
+                    device.pending_command = None
+                await db.commit()
+
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_update",
+                "device_id": device_id,
+                "update_status": status,
+                "update_message": message[:255] if message else None,
+            }
+        )
+
+    try:
+        private_key, _ = await get_or_create_keypair()
+
+        # Step 1: Test SSH connectivity
+        await _update_progress("updating", "Connecting via SSH...")
+        rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
+        if rc != 0:
+            await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
+            return
+
+        # Step 2: Git fetch
+        await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
+            private_key,
+            timeout=120,
+        )
+        if rc != 0:
+            await _update_progress("error", f"git fetch failed: {stderr[:200]}")
+            return
+
+        # Step 3: Git checkout + reset
+        await _update_progress("updating", "Applying update...")
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
+            f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
+            private_key,
+        )
+        if rc != 0:
+            await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
+            return
+
+        # Step 4: Install dependencies
+        await _update_progress("updating", "Installing dependencies...")
+        venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
+            private_key,
+            timeout=120,
+        )
+        if rc != 0:
+            logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
+
+        # Step 5: Restart daemon
+        await _update_progress("updating", "Restarting daemon...")
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            "sudo /usr/bin/systemctl restart spoolbuddy.service",
+            private_key,
+        )
+        if rc != 0:
+            await _update_progress("error", f"Service restart failed: {stderr[:200]}")
+            return
+
+        # Step 6: Clear browser cache and restart kiosk
+        # Remove Chromium's Service Worker + cache storage to prevent stale frontend
+        await _run_ssh_command(
+            ip_address,
+            "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
+            private_key,
+        )
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            "sudo /usr/bin/systemctl restart getty@tty1.service",
+            private_key,
+        )
+        if rc != 0:
+            logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
+
+        logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
+
+    except Exception as e:
+        logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
+        await _update_progress("error", f"Update failed: {str(e)[:200]}")

+ 99 - 47
backend/app/services/usage_tracker.py

@@ -164,6 +164,70 @@ class PrintSession:
 _active_sessions: dict[int, PrintSession] = {}
 _active_sessions: dict[int, PrintSession] = {}
 
 
 
 
+def _to_epoch_seconds(value: datetime | None) -> float | None:
+    """Convert datetime to epoch seconds, assuming UTC for naive values."""
+    if value is None:
+        return None
+    dt = value
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt.timestamp()
+
+
+async def _resolve_spool_id_for_tray(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession,
+    spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
+    print_started_at: datetime | None = None,
+) -> int | None:
+    """Resolve spool ID for a tray with safe support for mid-print reassignment.
+
+    Resolution order:
+    1. If snapshot exists and live assignment changed *during this print*, use live spool.
+    2. Otherwise use snapshot spool when available.
+    3. Fall back to live assignment.
+    """
+    key = (ams_id, tray_id)
+    snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
+
+    # Backward-compatible fast path: if we have a snapshot but no print-start
+    # timestamp, preserve legacy behavior and avoid extra DB lookups.
+    if snapshot_spool_id is not None and print_started_at is None:
+        return snapshot_spool_id
+
+    result = await db.execute(
+        select(SpoolAssignment).where(
+            SpoolAssignment.printer_id == printer_id,
+            SpoolAssignment.ams_id == ams_id,
+            SpoolAssignment.tray_id == tray_id,
+        )
+    )
+    live_assignment = result.scalar_one_or_none()
+
+    if snapshot_spool_id is not None:
+        if live_assignment and live_assignment.spool_id != snapshot_spool_id:
+            live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
+            started_ts = _to_epoch_seconds(print_started_at)
+            if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
+                logger.info(
+                    "[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    snapshot_spool_id,
+                    live_assignment.spool_id,
+                )
+                return live_assignment.spool_id
+        return snapshot_spool_id
+
+    if live_assignment:
+        return live_assignment.spool_id
+
+    return None
+
+
 async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
 async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
     """Capture AMS tray remain% and spool assignments at print start."""
     """Capture AMS tray remain% and spool assignments at print start."""
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
@@ -323,6 +387,7 @@ async def on_print_complete(
             last_layer_num=data.get("last_layer_num", 0),
             last_layer_num=data.get("last_layer_num", 0),
             default_filament_cost=default_filament_cost,
             default_filament_cost=default_filament_cost,
             spool_assignments=session.spool_assignments if session else None,
             spool_assignments=session.spool_assignments if session else None,
+            print_started_at=session.started_at if session else None,
         )
         )
         results.extend(threemf_results)
         results.extend(threemf_results)
 
 
@@ -357,20 +422,16 @@ async def on_print_complete(
                     if delta_pct <= 0:
                     if delta_pct <= 0:
                         continue  # No consumption or tray was refilled
                         continue  # No consumption or tray was refilled
 
 
-                    # Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
-                    spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
+                    spool_id = await _resolve_spool_id_for_tray(
+                        printer_id=printer_id,
+                        ams_id=ams_id,
+                        tray_id=tray_id,
+                        db=db,
+                        spool_assignments_snapshot=session.spool_assignments,
+                        print_started_at=session.started_at,
+                    )
                     if spool_id is None:
                     if spool_id is None:
-                        result = await db.execute(
-                            select(SpoolAssignment).where(
-                                SpoolAssignment.printer_id == printer_id,
-                                SpoolAssignment.ams_id == ams_id,
-                                SpoolAssignment.tray_id == tray_id,
-                            )
-                        )
-                        assignment = result.scalar_one_or_none()
-                        if not assignment:
-                            continue
-                        spool_id = assignment.spool_id
+                        continue
 
 
                     # Load spool
                     # Load spool
                     spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
                     spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
@@ -463,6 +524,7 @@ async def _track_from_3mf(
     last_layer_num: int = 0,
     last_layer_num: int = 0,
     default_filament_cost: float = 0.0,
     default_filament_cost: float = 0.0,
     spool_assignments: dict[tuple[int, int], int] | None = None,
     spool_assignments: dict[tuple[int, int], int] | None = None,
+    print_started_at: datetime | None = None,
 ) -> list[dict]:
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
     """Track usage from 3MF per-filament slicer data (primary path).
 
 
@@ -726,26 +788,22 @@ async def _track_from_3mf(
                     segment_grams,
                     segment_grams,
                 )
                 )
 
 
-                # Find spool for this tray
-                seg_spool_id = spool_assignments.get(seg_key) if spool_assignments else None
+                seg_spool_id = await _resolve_spool_id_for_tray(
+                    printer_id=printer_id,
+                    ams_id=seg_ams_id,
+                    tray_id=seg_tray_id,
+                    db=db,
+                    spool_assignments_snapshot=spool_assignments,
+                    print_started_at=print_started_at,
+                )
                 if seg_spool_id is None:
                 if seg_spool_id is None:
-                    assign_result = await db.execute(
-                        select(SpoolAssignment).where(
-                            SpoolAssignment.printer_id == printer_id,
-                            SpoolAssignment.ams_id == seg_ams_id,
-                            SpoolAssignment.tray_id == seg_tray_id,
-                        )
+                    logger.info(
+                        "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
+                        printer_id,
+                        seg_ams_id,
+                        seg_tray_id,
                     )
                     )
-                    assignment = assign_result.scalar_one_or_none()
-                    if not assignment:
-                        logger.info(
-                            "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
-                            printer_id,
-                            seg_ams_id,
-                            seg_tray_id,
-                        )
-                        continue
-                    seg_spool_id = assignment.spool_id
+                    continue
 
 
                 spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
                 spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
                 spool = spool_result.scalar_one_or_none()
                 spool = spool_result.scalar_one_or_none()
@@ -851,23 +909,17 @@ async def _track_from_3mf(
         if key in handled_trays:
         if key in handled_trays:
             continue
             continue
 
 
-        # Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
-        spool_id = spool_assignments.get(key) if spool_assignments else None
+        spool_id = await _resolve_spool_id_for_tray(
+            printer_id=printer_id,
+            ams_id=ams_id,
+            tray_id=tray_id,
+            db=db,
+            spool_assignments_snapshot=spool_assignments,
+            print_started_at=print_started_at,
+        )
         if spool_id is None:
         if spool_id is None:
-            assign_result = await db.execute(
-                select(SpoolAssignment).where(
-                    SpoolAssignment.printer_id == printer_id,
-                    SpoolAssignment.ams_id == ams_id,
-                    SpoolAssignment.tray_id == tray_id,
-                )
-            )
-            assignment = assign_result.scalar_one_or_none()
-            if not assignment:
-                logger.info(
-                    "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
-                )
-                continue
-            spool_id = assignment.spool_id
+            logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
+            continue
 
 
         # Load spool
         # Load spool
         spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
         spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))

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

@@ -1421,6 +1421,9 @@ class SlicerProxyManager:
     PRINTER_MQTT_PORT = 8883
     PRINTER_MQTT_PORT = 8883
     PRINTER_FILE_TRANSFER_PORT = 6000
     PRINTER_FILE_TRANSFER_PORT = 6000
     PRINTER_RTSP_PORT = 322  # X1/H2/P2 series camera (A1/P1 use port 6000)
     PRINTER_RTSP_PORT = 322  # X1/H2/P2 series camera (A1/P1 use port 6000)
+    # Undocumented proprietary ports used by some models (A1, P1S, etc.)
+    # BambuStudio requires port 2024 for printing; OrcaSlicer also needs 2025.
+    PRINTER_AUX_PORTS = [2024, 2025, 2026]
     PRINTER_BIND_PORTS = [3000, 3002]
     PRINTER_BIND_PORTS = [3000, 3002]
 
 
     # Local listen ports - must match what Bambu Studio expects
     # Local listen ports - must match what Bambu Studio expects
@@ -1461,6 +1464,7 @@ class SlicerProxyManager:
         self._mqtt_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
         self._file_transfer_proxy: TCPProxy | None = None
         self._file_transfer_proxy: TCPProxy | None = None
         self._rtsp_proxy: TCPProxy | None = None
         self._rtsp_proxy: TCPProxy | None = None
+        self._aux_proxies: list[TCPProxy] = []
         self._bind_proxies: list[TCPProxy] = []
         self._bind_proxies: list[TCPProxy] = []
         self._bind_server = None
         self._bind_server = None
         self._probe_servers: list[asyncio.Server] = []
         self._probe_servers: list[asyncio.Server] = []
@@ -1560,6 +1564,22 @@ class SlicerProxyManager:
             bind_address=self.bind_address,
             bind_address=self.bind_address,
         )
         )
 
 
+        # Auxiliary ports (2024-2026) — raw TCP pass-through for undocumented
+        # proprietary services. Required by BambuStudio/OrcaSlicer for some
+        # models (A1, P1S). Silently ignored if the printer doesn't listen.
+        for aux_port in self.PRINTER_AUX_PORTS:
+            self._aux_proxies.append(
+                TCPProxy(
+                    name=f"Aux-{aux_port}",
+                    listen_port=aux_port,
+                    target_host=self.target_host,
+                    target_port=aux_port,
+                    on_connect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"connected: {cid}"),
+                    on_disconnect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"disconnected: {cid}"),
+                    bind_address=self.bind_address,
+                )
+            )
+
         # Bind/auth — respond with VP identity instead of proxying to printer.
         # Bind/auth — respond with VP identity instead of proxying to printer.
         # The detect response contains the printer name, serial, model, and
         # The detect response contains the printer name, serial, model, and
         # bind status. Proxying it would leak the real printer's identity and
         # bind status. Proxying it would leak the real printer's identity and
@@ -1628,6 +1648,13 @@ class SlicerProxyManager:
                 name="slicer_proxy_rtsp",
                 name="slicer_proxy_rtsp",
             ),
             ),
         ]
         ]
+        for ap in self._aux_proxies:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(ap),
+                    name=f"slicer_proxy_aux_{ap.listen_port}",
+                )
+            )
         if self._bind_server:
         if self._bind_server:
             self._tasks.append(
             self._tasks.append(
                 asyncio.create_task(
                 asyncio.create_task(
@@ -1702,6 +1729,10 @@ class SlicerProxyManager:
             await self._rtsp_proxy.stop()
             await self._rtsp_proxy.stop()
             self._rtsp_proxy = None
             self._rtsp_proxy = None
 
 
+        for ap in self._aux_proxies:
+            await ap.stop()
+        self._aux_proxies = []
+
         if self._bind_server:
         if self._bind_server:
             await self._bind_server.stop()
             await self._bind_server.stop()
             self._bind_server = None
             self._bind_server = None

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

@@ -0,0 +1,24 @@
+"""Shared helpers for normalizing RFID tag and tray identifiers."""
+
+
+def normalize_hex(value: str | None) -> str:
+    if not value:
+        return ""
+    hex_chars = "".join(ch for ch in str(value).strip() if ch in "0123456789abcdefABCDEF")
+    return hex_chars.upper()
+
+
+def normalize_tag_uid(value: str | None) -> str:
+    uid = normalize_hex(value)
+    # DB column is VARCHAR(16), so keep the least-significant bytes if longer.
+    if len(uid) > 16:
+        uid = uid[-16:]
+    return uid
+
+
+def normalize_tray_uuid(value: str | None) -> str:
+    uuid = normalize_hex(value)
+    # DB column is VARCHAR(32). Keep canonical 32-char UUID when possible.
+    if len(uuid) >= 32:
+        uuid = uuid[:32]
+    return uuid

+ 1 - 0
backend/tests/conftest.py

@@ -427,6 +427,7 @@ def notification_provider_factory(db_session):
             "on_print_failed": True,
             "on_print_failed": True,
             "on_print_stopped": True,
             "on_print_stopped": True,
             "on_print_progress": False,
             "on_print_progress": False,
+            "on_print_missing_spool_assignment": False,
             "on_printer_offline": False,
             "on_printer_offline": False,
             "on_printer_error": False,
             "on_printer_error": False,
             "on_filament_low": False,
             "on_filament_low": False,

+ 378 - 0
backend/tests/integration/test_external_folders_api.py

@@ -0,0 +1,378 @@
+"""Integration tests for External Folder API endpoints."""
+
+import os
+import tempfile
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestExternalFolderCreation:
+    """Tests for POST /library/folders/external."""
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        """Create a temporary directory to act as an external folder."""
+        ext_dir = tmp_path / "nas_share"
+        ext_dir.mkdir()
+        # Add some test files
+        (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
+        (ext_dir / "bracket.stl").write_bytes(b"fakestl")
+        (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
+        (ext_dir / "readme.txt").write_text("not a print file")
+        (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
+        return ext_dir
+
+    @pytest.fixture
+    def nested_external_dir(self, external_dir):
+        """Create a nested subdirectory in the external folder."""
+        sub = external_dir / "subfolder"
+        sub.mkdir()
+        (sub / "nested_part.stl").write_bytes(b"nestedstl")
+        return external_dir
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify external folder can be created with valid path."""
+        data = {
+            "name": "NAS Prints",
+            "external_path": str(external_dir),
+            "readonly": True,
+            "show_hidden": False,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "NAS Prints"
+        assert result["is_external"] is True
+        assert result["external_readonly"] is True
+        assert result["external_show_hidden"] is False
+        assert result["external_path"] == str(external_dir.resolve())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
+        """Verify 400 for non-existent path."""
+        data = {
+            "name": "Bad Path",
+            "external_path": "/nonexistent/path/that/does/not/exist",
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 400
+        assert "does not exist" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
+        """Verify system directories are blocked."""
+        data = {
+            "name": "System",
+            "external_path": "/proc",
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 400
+        assert "system directory" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):
+        """Verify 400 when path is a file, not directory."""
+        file_path = tmp_path / "not_a_dir.txt"
+        file_path.write_text("hello")
+        data = {
+            "name": "Not A Dir",
+            "external_path": str(file_path),
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 400
+        assert "not a directory" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify 409 when same path already linked."""
+        data = {
+            "name": "First",
+            "external_path": str(external_dir),
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+
+        data["name"] = "Duplicate"
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 409
+        assert "already exists" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify external folder shows up in folder tree with external fields."""
+        data = {
+            "name": "My NAS",
+            "external_path": str(external_dir),
+            "readonly": True,
+        }
+        await async_client.post("/api/v1/library/folders/external", json=data)
+
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        folders = response.json()
+        ext_folder = next((f for f in folders if f["name"] == "My NAS"), None)
+        assert ext_folder is not None
+        assert ext_folder["is_external"] is True
+        assert ext_folder["external_readonly"] is True
+
+
+class TestExternalFolderScan:
+    """Tests for POST /library/folders/{id}/scan."""
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        """Create a temporary directory with test files."""
+        ext_dir = tmp_path / "prints"
+        ext_dir.mkdir()
+        (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
+        (ext_dir / "bracket.stl").write_bytes(b"fakestl")
+        (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
+        (ext_dir / "readme.txt").write_text("not a print file")
+        (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
+        sub = ext_dir / "subfolder"
+        sub.mkdir()
+        (sub / "nested.stl").write_bytes(b"nested")
+        return ext_dir
+
+    @pytest.fixture
+    async def external_folder(self, async_client, db_session, external_dir):
+        """Create an external folder via API."""
+        data = {
+            "name": "Scan Test",
+            "external_path": str(external_dir),
+            "readonly": True,
+            "show_hidden": False,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        return response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify scan discovers supported files."""
+        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response.status_code == 200
+        result = response.json()
+        # Should find: benchy.3mf, bracket.stl, print.gcode, subfolder/nested.stl
+        # Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
+        assert result["added"] == 4
+        assert result["removed"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify hidden files are skipped by default."""
+        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+
+        # List files in folder
+        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
+        assert response.status_code == 200
+        files = response.json()
+        filenames = [f["filename"] for f in files]
+        assert ".hidden.3mf" not in filenames
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
+        """Verify hidden files found when show_hidden=True."""
+        data = {
+            "name": "Show Hidden Test",
+            "external_path": str(external_dir),
+            "show_hidden": True,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        folder = response.json()
+
+        response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        result = response.json()
+        # Now should also find .hidden.3mf → 5 total
+        assert result["added"] == 5
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify scanning twice doesn't duplicate files."""
+        response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response1.json()["added"] == 4
+
+        response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response2.json()["added"] == 0
+        assert response2.json()["removed"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_removes_deleted_files(
+        self, async_client: AsyncClient, db_session, external_folder, external_dir
+    ):
+        """Verify scan removes entries for files no longer on disk."""
+        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+
+        # Delete a file from disk
+        (external_dir / "bracket.stl").unlink()
+
+        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        result = response.json()
+        assert result["removed"] == 1
+        assert result["added"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
+        """Verify scan fails on regular (non-external) folder."""
+        # Create a regular folder
+        data = {"name": "Regular Folder"}
+        response = await async_client.post("/api/v1/library/folders", json=data)
+        folder = response.json()
+
+        response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        assert response.status_code == 400
+        assert "not an external" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
+        """Verify scanned files have is_external=True."""
+        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+
+        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
+        files = response.json()
+        assert len(files) > 0
+        for f in files:
+            assert f["is_external"] is True
+
+
+class TestExternalFolderProtections:
+    """Tests for read-only protections on external folders."""
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        ext_dir = tmp_path / "readonly_share"
+        ext_dir.mkdir()
+        (ext_dir / "test.stl").write_bytes(b"fakestl")
+        return ext_dir
+
+    @pytest.fixture
+    async def readonly_folder(self, async_client, db_session, external_dir):
+        """Create a read-only external folder with files scanned."""
+        data = {
+            "name": "Read Only",
+            "external_path": str(external_dir),
+            "readonly": True,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        folder = response.json()
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        return folder
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify uploads to read-only external folders are blocked."""
+        import io
+
+        file_content = io.BytesIO(b"test content")
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={readonly_folder['id']}",
+            files={"file": ("test.gcode", file_content, "application/octet-stream")},
+        )
+        assert response.status_code == 403
+        assert "read-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify moving files to read-only external folder is blocked."""
+        from backend.app.models.library import LibraryFile
+
+        # Create a regular file
+        lib_file = LibraryFile(
+            filename="regular.3mf",
+            file_path="/test/regular.3mf",
+            file_size=1024,
+            file_type="3mf",
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
+        response = await async_client.post("/api/v1/library/files/move", json=data)
+        assert response.status_code == 403
+        assert "read-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify external files can't be moved to other folders."""
+        # Get the external file ID
+        response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
+        files = response.json()
+        assert len(files) > 0
+        ext_file_id = files[0]["id"]
+
+        # Try to move to root
+        data = {"file_ids": [ext_file_id], "folder_id": None}
+        response = await async_client.post("/api/v1/library/files/move", json=data)
+        assert response.status_code == 200
+        # File should be skipped, not moved
+        result = response.json()
+        assert result["moved"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_external_file_removes_db_only(
+        self, async_client: AsyncClient, db_session, readonly_folder, external_dir
+    ):
+        """Verify deleting an external file only removes DB entry, not the file on disk."""
+        response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
+        files = response.json()
+        ext_file_id = files[0]["id"]
+        ext_filename = files[0]["filename"]
+
+        # Delete via API
+        response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
+        assert response.status_code == 200
+
+        # File should still exist on disk
+        assert (external_dir / ext_filename).exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_external_folder_preserves_files(
+        self, async_client: AsyncClient, db_session, readonly_folder, external_dir
+    ):
+        """Verify deleting an external folder doesn't delete files from disk."""
+        response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
+        assert response.status_code == 200
+
+        # Files should still exist on disk
+        assert (external_dir / "test.stl").exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
+        """Verify ZIP extraction to read-only external folder is blocked."""
+        import io
+        import zipfile
+
+        # Create a minimal zip
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w") as zf:
+            zf.writestr("test.stl", b"fakestl")
+        buf.seek(0)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
+            files={"file": ("test.zip", buf, "application/zip")},
+        )
+        assert response.status_code == 403
+        assert "read-only" in response.json()["detail"].lower()

+ 36 - 0
backend/tests/integration/test_notifications_api.py

@@ -391,6 +391,42 @@ class TestNotificationsAPI:
         assert result["on_bed_cooled"] is False
         assert result["on_bed_cooled"] is False
         assert result["on_first_layer_complete"] is True
         assert result["on_first_layer_complete"] is True
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):
+        """Verify missing spool assignment toggle persists on create."""
+        data = {
+            "name": "Missing Spool Assignment Test",
+            "provider_type": "ntfy",
+            "config": {"server": "https://ntfy.sh", "topic": "test"},
+            "on_print_missing_spool_assignment": True,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_print_missing_spool_assignment"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_missing_spool_assignment_toggle(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """CRITICAL: Verify missing spool assignment toggle persists correctly."""
+        provider = await notification_provider_factory(on_print_missing_spool_assignment=False)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_print_missing_spool_assignment": True},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_print_missing_spool_assignment"] is True
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_print_missing_spool_assignment"] is True
+
 
 
 class TestNotificationTemplatesAPI:
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""
     """Integration tests for /api/v1/notification-templates/ endpoints."""

+ 71 - 45
backend/tests/integration/test_spoolbuddy.py

@@ -7,6 +7,7 @@ import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.api.routes import spoolbuddy as spoolbuddy_routes
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 
 
@@ -154,6 +155,7 @@ class TestDeviceEndpoints:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
     async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
         device = await device_factory(device_id="sb-hb")
         device = await device_factory(device_id="sb-hb")
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
 
 
         with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
         with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
             mock_ws.broadcast = AsyncMock()
             mock_ws.broadcast = AsyncMock()
@@ -166,6 +168,10 @@ class TestDeviceEndpoints:
         data = resp.json()
         data = resp.json()
         assert data["tare_offset"] == device.tare_offset
         assert data["tare_offset"] == device.tare_offset
         assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
         assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-hb"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -208,6 +214,7 @@ class TestDeviceEndpoints:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
     async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
         # Create device with last_seen far in the past (offline)
         # Create device with last_seen far in the past (offline)
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
         await device_factory(
         await device_factory(
             device_id="sb-offline",
             device_id="sb-offline",
             last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
             last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
@@ -227,6 +234,55 @@ class TestDeviceEndpoints:
         assert msg["type"] == "spoolbuddy_online"
         assert msg["type"] == "spoolbuddy_online"
         assert msg["device_id"] == "sb-offline"
         assert msg["device_id"] == "sb-offline"
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
+        await device_factory(
+            device_id="sb-already-online",
+            last_seen=datetime.now(timezone.utc),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-already-online/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
+            )
+
+        assert resp.status_code == 200
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-already-online"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
+        await device_factory(
+            device_id="sb-throttle",
+            last_seen=datetime.now(timezone.utc),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp1 = await async_client.post(
+                f"{API}/devices/sb-throttle/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+            resp2 = await async_client.post(
+                f"{API}/devices/sb-throttle/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
+            )
+
+        assert resp1.status_code == 200
+        assert resp2.status_code == 200
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-throttle"
+
 
 
 # ============================================================================
 # ============================================================================
 # NFC endpoints
 # NFC endpoints
@@ -813,27 +869,19 @@ class TestDisplayEndpoints:
 class TestUpdateEndpoints:
 class TestUpdateEndpoints:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_trigger_update_queues_command(self, async_client: AsyncClient, device_factory):
+    async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
         await device_factory(device_id="sb-upd")
         await device_factory(device_id="sb-upd")
 
 
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
+        ):
             mock_ws.broadcast = AsyncMock()
             mock_ws.broadcast = AsyncMock()
             resp = await async_client.post(f"{API}/devices/sb-upd/update")
             resp = await async_client.post(f"{API}/devices/sb-upd/update")
 
 
         assert resp.status_code == 200
         assert resp.status_code == 200
         assert resp.json()["status"] == "ok"
         assert resp.json()["status"] == "ok"
 
 
-        # Verify heartbeat returns update command
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            hb = await async_client.post(
-                f"{API}/devices/sb-upd/heartbeat",
-                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
-            )
-
-        # update command is NOT cleared by heartbeat (cleared by update-status)
-        assert hb.json()["pending_command"] == "update"
-
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
     async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
@@ -943,50 +991,25 @@ class TestUpdateEndpoints:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
     async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
-        """GET /devices/{id}/update-check queries GitHub and returns version comparison."""
+        """GET /devices/{id}/update-check compares device version against APP_VERSION."""
         await device_factory(device_id="sb-uc", firmware_version="0.1.0")
         await device_factory(device_id="sb-uc", firmware_version="0.1.0")
 
 
-        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
-
-        mock_resp = MagicMock()
-        mock_resp.status_code = 200
-        mock_resp.json.return_value = mock_releases
-        mock_resp.raise_for_status = MagicMock()
-
-        mock_client = AsyncMock()
-        mock_client.get.return_value = mock_resp
-        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
-        mock_client.__aexit__ = AsyncMock(return_value=False)
-
-        with patch("httpx.AsyncClient", return_value=mock_client):
-            resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
+        resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
 
 
         assert resp.status_code == 200
         assert resp.status_code == 200
         data = resp.json()
         data = resp.json()
         assert data["current_version"] == "0.1.0"
         assert data["current_version"] == "0.1.0"
-        assert data["latest_version"] == "0.2.0"
+        assert data["latest_version"] is not None
         assert data["update_available"] is True
         assert data["update_available"] is True
-        assert data["release_url"] == "https://github.com/test/releases/0.2.0"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
     async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
-        await device_factory(device_id="sb-uc2", firmware_version="0.2.0")
+        from backend.app.core.config import APP_VERSION
 
 
-        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
+        await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
 
 
-        mock_resp = MagicMock()
-        mock_resp.status_code = 200
-        mock_resp.json.return_value = mock_releases
-        mock_resp.raise_for_status = MagicMock()
-
-        mock_client = AsyncMock()
-        mock_client.get.return_value = mock_resp
-        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
-        mock_client.__aexit__ = AsyncMock(return_value=False)
-
-        with patch("httpx.AsyncClient", return_value=mock_client):
-            resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
+        resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
 
 
         assert resp.status_code == 200
         assert resp.status_code == 200
         assert resp.json()["update_available"] is False
         assert resp.json()["update_available"] is False
@@ -1002,7 +1025,10 @@ class TestUpdateEndpoints:
     async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
     async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
         await device_factory(device_id="sb-upd-ws")
         await device_factory(device_id="sb-upd-ws")
 
 
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
+        ):
             mock_ws.broadcast = AsyncMock()
             mock_ws.broadcast = AsyncMock()
             await async_client.post(f"{API}/devices/sb-upd-ws/update")
             await async_client.post(f"{API}/devices/sb-upd-ws/update")
 
 

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

@@ -734,6 +734,207 @@ class TestAMSDataMerging:
         )
         )
 
 
 
 
+class TestAMSTrayStateClearning:
+    """Tests for AMS tray state-based clearing (#784).
+
+    Some printers (e.g. H2D) only send {id, state} in incremental MQTT
+    updates when a tray is not fully loaded.  state=11 means loaded;
+    other values (9=empty, 10=spool present but filament not in feeder)
+    should clear stale tray data that was set from an earlier pushall.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+        return client
+
+    def _seed_loaded_tray(self, mqtt_client):
+        """Seed AMS 0 with a fully loaded tray (state=11) and an empty slot."""
+        initial = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PETG",
+                            "tray_sub_brands": "PETG HF",
+                            "tray_color": "00FF00FF",
+                            "tray_id_name": "A00-G1",
+                            "tray_info_idx": "GFG99",
+                            "tag_uid": "AABBCCDD11223344",
+                            "tray_uuid": "AABBCCDD11223344AABBCCDD11223344",
+                            "remain": 75,
+                            "k": 0.02,
+                            "cali_idx": 5,
+                            "state": 11,
+                        },
+                        {
+                            "id": 1,
+                            "tray_type": "PLA",
+                            "tray_color": "FF0000FF",
+                            "remain": 50,
+                            "state": 11,
+                        },
+                    ],
+                }
+            ],
+            "power_on_flag": False,  # H2D always sends False
+        }
+        mqtt_client._handle_ams_data(initial)
+        ams = mqtt_client.state.raw_data["ams"]
+        assert ams[0]["tray"][0]["tray_type"] == "PETG"
+        assert ams[0]["tray"][1]["tray_type"] == "PLA"
+
+    def test_state_10_clears_stale_tray_data(self, mqtt_client):
+        """Incremental update with state=10 (spool present, not loaded) clears tray."""
+        self._seed_loaded_tray(mqtt_client)
+
+        # H2D sends only {id, state} when filament is retracted
+        update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "state": 10},
+                        {"id": 1, "state": 11},  # slot 1 still loaded
+                    ],
+                }
+            ],
+            "power_on_flag": False,
+        }
+        mqtt_client._handle_ams_data(update)
+
+        ams = mqtt_client.state.raw_data["ams"]
+        tray0 = ams[0]["tray"][0]
+        tray1 = ams[0]["tray"][1]
+
+        # Tray 0 should be cleared
+        assert tray0["tray_type"] == "", "tray_type must be cleared on state=10"
+        assert tray0["tray_color"] == "", "tray_color must be cleared"
+        assert tray0["tray_sub_brands"] == "", "tray_sub_brands must be cleared"
+        assert tray0["tray_id_name"] == "", "tray_id_name must be cleared"
+        assert tray0["tray_info_idx"] == "", "tray_info_idx must be cleared"
+        assert tray0["tag_uid"] == "0000000000000000", "tag_uid must be cleared"
+        assert tray0["tray_uuid"] == "00000000000000000000000000000000", "tray_uuid must be cleared"
+        assert tray0["remain"] == 0, "remain must be 0"
+        assert tray0["k"] is None, "k must be cleared"
+        assert tray0["cali_idx"] is None, "cali_idx must be cleared"
+        assert tray0["state"] == 10, "state should be preserved"
+
+        # Tray 1 should be untouched
+        assert tray1["tray_type"] == "PLA", "Loaded slot must be preserved"
+        assert tray1["remain"] == 50
+
+    def test_state_9_clears_stale_tray_data(self, mqtt_client):
+        """Incremental update with state=9 (empty, no spool) clears tray."""
+        self._seed_loaded_tray(mqtt_client)
+
+        update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "state": 9},
+                        {"id": 1, "state": 11},
+                    ],
+                }
+            ],
+            "power_on_flag": False,
+        }
+        mqtt_client._handle_ams_data(update)
+
+        tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert tray0["tray_type"] == "", "state=9 must clear tray_type"
+        assert tray0["remain"] == 0
+
+    def test_state_11_preserves_tray_data(self, mqtt_client):
+        """Incremental update with state=11 (loaded) must NOT clear tray."""
+        self._seed_loaded_tray(mqtt_client)
+
+        update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "state": 11},
+                        {"id": 1, "state": 11},
+                    ],
+                }
+            ],
+            "power_on_flag": False,
+        }
+        mqtt_client._handle_ams_data(update)
+
+        tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert tray0["tray_type"] == "PETG", "state=11 must preserve tray data"
+        assert tray0["tray_color"] == "00FF00FF"
+        assert tray0["remain"] == 75
+
+    def test_no_clearing_when_tray_type_already_empty(self, mqtt_client):
+        """Don't re-clear a tray that's already empty (avoids log spam)."""
+        self._seed_loaded_tray(mqtt_client)
+
+        # First unload clears
+        update = {
+            "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}],
+            "power_on_flag": False,
+        }
+        mqtt_client._handle_ams_data(update)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
+
+        # Second identical update should not trigger clearing again
+        # (merged_tray.get("tray_type") is already empty/falsy)
+        mqtt_client._handle_ams_data(update)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
+
+    def test_reload_after_unload_restores_data(self, mqtt_client):
+        """After clearing via state=10, a full update with state=11 restores data."""
+        self._seed_loaded_tray(mqtt_client)
+
+        # Unload
+        mqtt_client._handle_ams_data(
+            {
+                "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}],
+                "power_on_flag": False,
+            }
+        )
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
+
+        # Reload — full tray data arrives again
+        mqtt_client._handle_ams_data(
+            {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {
+                                "id": 0,
+                                "tray_type": "PETG",
+                                "tray_sub_brands": "PETG HF",
+                                "tray_color": "00FF00FF",
+                                "remain": 75,
+                                "state": 11,
+                            },
+                            {"id": 1, "state": 11},
+                        ],
+                    }
+                ],
+                "power_on_flag": False,
+            }
+        )
+        tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert tray0["tray_type"] == "PETG", "Reload must restore tray data"
+        assert tray0["tray_color"] == "00FF00FF"
+        assert tray0["remain"] == 75
+
+
 class TestNozzleRackData:
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
 
 
@@ -2656,3 +2857,287 @@ class TestSendDryingCommand:
         # qos may be positional arg [2] or keyword
         # qos may be positional arg [2] or keyword
         qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
         qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
         assert qos == 1
         assert qos == 1
+
+
+class TestStartPrintAmsMapping:
+    """Tests for ams_mapping/ams_mapping2 construction in start_print().
+
+    BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
+    ams_mapping and puts the real external spool info only in ams_mapping2.
+    Passing raw 254/255 in the flat array causes H2D firmware to fail
+    with 0700_8012 "Failed to get AMS mapping table".
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _get_published_command(self, mqtt_client):
+        """Extract the parsed print command from the last publish call."""
+        call_args = mqtt_client._client.publish.call_args
+        return json.loads(call_args[0][1])["print"]
+
+    def test_regular_ams_trays_preserved_in_flat_mapping(self, mqtt_client):
+        """Regular AMS tray IDs pass through unchanged in flat ams_mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[0, 5, 11])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [0, 5, 11]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 0, "slot_id": 0},
+            {"ams_id": 1, "slot_id": 1},
+            {"ams_id": 2, "slot_id": 3},
+        ]
+
+    def test_unmapped_slots(self, mqtt_client):
+        """Unmapped slots (-1) produce -1 in flat and 0xFF/0xFF in mapping2."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1, -1]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+        ]
+
+    def test_external_main_nozzle_becomes_minus_one_in_flat(self, mqtt_client):
+        """Virtual tray 255 (main nozzle) must be -1 in flat mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[255])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1]
+        assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
+
+    def test_external_deputy_nozzle_becomes_minus_one_in_flat(self, mqtt_client):
+        """Virtual tray 254 (deputy nozzle) must be -1 in flat mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[254])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1]
+        assert cmd["ams_mapping2"] == [{"ams_id": 254, "slot_id": 0}]
+
+    def test_h2d_external_spool_mixed_with_ams(self, mqtt_client):
+        """H2D scenario: AMS trays + unmapped + external deputy nozzle."""
+        # Reproduces the exact scenario from issue #797:
+        # 5-slot 3MF, only slot 5 assigned to external deputy nozzle (254)
+        mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1, -1, -1, 255])
+
+        cmd = self._get_published_command(mqtt_client)
+        # Flat mapping: all -1 (external converted, unmapped stay -1)
+        assert cmd["ams_mapping"] == [-1, -1, -1, -1, -1]
+        # Detailed mapping: unmapped slots use 0xFF, external uses real ams_id
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 0},
+        ]
+
+    def test_ams_ht_trays_preserved_in_flat_mapping(self, mqtt_client):
+        """AMS-HT tray IDs (>=128) pass through in flat mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[128, 131])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [128, 131]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 128, "slot_id": 0},
+            {"ams_id": 131, "slot_id": 0},
+        ]
+
+    def test_dual_nozzle_both_external(self, mqtt_client):
+        """Both nozzles using external spools: 254 (deputy) + 255 (main)."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1, -1]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 254, "slot_id": 0},
+            {"ams_id": 255, "slot_id": 0},
+        ]
+
+    def test_no_ams_mapping_omits_fields(self, mqtt_client):
+        """When ams_mapping is None, neither field is in the command."""
+        mqtt_client.start_print("test.3mf", ams_mapping=None)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert "ams_mapping" not in cmd
+        assert "ams_mapping2" not in cmd
+
+
+class TestStaleReconnect:
+    """Tests for stale connection detection and reconnect without UI bouncing."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_STALE",
+            access_code="12345678",
+        )
+        return client
+
+    def test_check_staleness_sets_flag_and_broadcasts_once(self, mqtt_client):
+        """check_staleness() should set connected=False, broadcast, and set _stale_reconnecting."""
+        import time
+
+        state_changes = []
+        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120  # well past 60s threshold
+
+        result = mqtt_client.check_staleness()
+
+        assert result is False
+        assert mqtt_client.state.connected is False
+        assert mqtt_client._stale_reconnecting is True
+        assert state_changes == [False]  # Exactly one broadcast
+
+    def test_check_staleness_noop_when_not_connected(self, mqtt_client):
+        """check_staleness() should not set flag when already disconnected."""
+        import time
+
+        mqtt_client.state.connected = False
+        mqtt_client._last_message_time = time.time() - 120
+
+        mqtt_client.check_staleness()
+
+        assert mqtt_client._stale_reconnecting is False
+
+    def test_check_staleness_noop_when_not_stale(self, mqtt_client):
+        """check_staleness() should not set flag when messages are recent."""
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 5  # 5s ago, well within 60s
+
+        result = mqtt_client.check_staleness()
+
+        assert result is True
+        assert mqtt_client.state.connected is True
+        assert mqtt_client._stale_reconnecting is False
+
+    def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):
+        """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
+        state_changes = []
+        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
+        mqtt_client._stale_reconnecting = True
+        mqtt_client.state.connected = False
+
+        mqtt_client._on_disconnect(None, None)
+
+        # No state change broadcast — check_staleness() already did it
+        assert state_changes == []
+        assert mqtt_client.state.connected is False
+
+    def test_on_disconnect_fires_event_during_stale_reconnect(self, mqtt_client):
+        """_on_disconnect must still fire _disconnection_event even during stale reconnect.
+
+        If disconnect() is called while _stale_reconnecting is True (e.g. user removes
+        the printer before paho reconnects), the event must fire so disconnect() doesn't hang.
+        """
+        import threading
+
+        mqtt_client._stale_reconnecting = True
+        mqtt_client._disconnection_event = threading.Event()
+
+        mqtt_client._on_disconnect(None, None)
+
+        assert mqtt_client._disconnection_event.is_set()
+
+    def test_on_connect_clears_stale_reconnecting_flag(self, mqtt_client):
+        """_on_connect should clear _stale_reconnecting and restore connected=True."""
+        mqtt_client._stale_reconnecting = True
+        mqtt_client.state.connected = 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)
+
+        assert mqtt_client._stale_reconnecting is False
+        assert mqtt_client.state.connected is True
+
+    def test_full_stale_reconnect_cycle_no_bounce(self, mqtt_client):
+        """Full cycle: stale → disconnect callback → reconnect. UI should see exactly one disconnect."""
+        import time
+
+        state_changes = []
+        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+
+        # Step 1: Stale detection triggers
+        mqtt_client.check_staleness()
+        assert state_changes == [False]
+
+        # Step 2: Paho fires disconnect callback (from socket close)
+        mqtt_client._on_disconnect(None, None)
+        # Should NOT add another state change
+        assert state_changes == [False]
+
+        # Step 3: Paho reconnects
+        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)
+        assert state_changes == [False, True]  # Now connected again
+        assert mqtt_client._stale_reconnecting is False
+
+    def test_spurious_disconnect_suppressed_when_recent_messages(self, mqtt_client):
+        """Non-error disconnect with recent messages should be suppressed."""
+        import time
+
+        state_changes = []
+        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 3  # 3s ago
+
+        # Non-error disconnect (rc=None)
+        mqtt_client._on_disconnect(None, None)
+
+        assert state_changes == []
+        assert mqtt_client.state.connected is True
+
+    def test_error_disconnect_not_suppressed_despite_recent_messages(self, mqtt_client):
+        """Error disconnect should always be processed, even with recent messages."""
+        import time
+
+        import paho.mqtt.client as mqtt
+        from paho.mqtt.reasoncodes import ReasonCode
+
+        state_changes = []
+        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 3  # 3s ago
+
+        # Error disconnect (rc.is_failure = True)
+        rc = ReasonCode(mqtt.CONNACK >> 4, identifier=0x80)  # Failure code
+        mqtt_client._on_disconnect(None, None, rc=rc)
+
+        assert state_changes == [False]
+        assert mqtt_client.state.connected is False

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

@@ -1163,17 +1163,23 @@ class TestHasStgCurIdleBug:
         assert has_stg_cur_idle_bug("a1") is True  # case insensitive
         assert has_stg_cur_idle_bug("a1") is True  # case insensitive
         assert has_stg_cur_idle_bug("a1 mini") is True
         assert has_stg_cur_idle_bug("a1 mini") is True
 
 
-    def test_a1_internal_codes_return_true(self):
-        """Verify A1 internal model codes return True."""
+    def test_p1_models_return_true(self):
+        """Verify P1P/P1S model variants return True."""
+        assert has_stg_cur_idle_bug("P1P") is True
+        assert has_stg_cur_idle_bug("P1S") is True
+        assert has_stg_cur_idle_bug("p1p") is True  # case insensitive
+
+    def test_internal_codes_return_true(self):
+        """Verify internal model codes return True."""
         assert has_stg_cur_idle_bug("N1") is True  # A1 Mini
         assert has_stg_cur_idle_bug("N1") is True  # A1 Mini
         assert has_stg_cur_idle_bug("N2S") is True  # A1
         assert has_stg_cur_idle_bug("N2S") is True  # A1
+        assert has_stg_cur_idle_bug("C11") is True  # P1P
+        assert has_stg_cur_idle_bug("C12") is True  # P1S
 
 
-    def test_non_a1_models_return_false(self):
-        """Verify non-A1 models return False."""
+    def test_non_affected_models_return_false(self):
+        """Verify non-affected models return False."""
         assert has_stg_cur_idle_bug("X1C") is False
         assert has_stg_cur_idle_bug("X1C") is False
         assert has_stg_cur_idle_bug("X1") is False
         assert has_stg_cur_idle_bug("X1") is False
-        assert has_stg_cur_idle_bug("P1P") is False
-        assert has_stg_cur_idle_bug("P1S") is False
         assert has_stg_cur_idle_bug("H2D") is False
         assert has_stg_cur_idle_bug("H2D") is False
 
 
     def test_none_model_returns_false(self):
     def test_none_model_returns_false(self):

+ 143 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -417,6 +417,149 @@ class TestGetPlugForPrinter:
         assert result is script1
         assert result is script1
 
 
 
 
+class TestAutoOffPersistent:
+    """Tests for persistent auto-off behavior (Issue #826).
+
+    When auto_off_persistent is True, auto_off should remain enabled after
+    execution instead of being disabled (one-shot default).
+    """
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.mark.asyncio
+    async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
+        """Default one-shot: auto_off should be set to False after execution."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.auto_off = True
+        mock_plug.auto_off_persistent = False
+        mock_plug.auto_off_executed = False
+        mock_plug.auto_off_pending = True
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
+
+        with patch("backend.app.core.database.async_session") as mock_session_ctx:
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar_one_or_none.return_value = mock_plug
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            await manager._mark_auto_off_executed(1)
+
+            assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
+            assert mock_plug.auto_off_pending is False
+            assert mock_plug.auto_off_pending_since is None
+            mock_db.commit.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
+        """Persistent mode: auto_off should remain True after execution."""
+        mock_plug = MagicMock()
+        mock_plug.id = 2
+        mock_plug.auto_off = True
+        mock_plug.auto_off_persistent = True
+        mock_plug.auto_off_executed = False
+        mock_plug.auto_off_pending = True
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
+
+        with patch("backend.app.core.database.async_session") as mock_session_ctx:
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar_one_or_none.return_value = mock_plug
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            await manager._mark_auto_off_executed(2)
+
+            assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
+            assert mock_plug.auto_off_pending is False
+            assert mock_plug.auto_off_pending_since is None
+            mock_db.commit.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_persistent_auto_off_full_cycle(self, manager):
+        """Verify persistent auto-off survives a full print cycle.
+
+        Simulates: print start → print complete → auto-off executes → next print start.
+        auto_off should remain True throughout for persistent plugs.
+        """
+        mock_plug = MagicMock()
+        mock_plug.id = 3
+        mock_plug.name = "HA BentoBox Filter"
+        mock_plug.plug_type = "homeassistant"
+        mock_plug.ha_entity_id = "switch.bentobox_filter"
+        mock_plug.ip_address = None
+        mock_plug.username = None
+        mock_plug.password = None
+        mock_plug.enabled = True
+        mock_plug.auto_on = True
+        mock_plug.auto_off = True
+        mock_plug.auto_off_persistent = True
+        mock_plug.off_delay_mode = "time"
+        mock_plug.off_delay_minutes = 1
+        mock_plug.off_temp_threshold = 70
+        mock_plug.printer_id = 1
+        mock_plug.auto_off_executed = False
+        mock_plug.auto_off_pending = False
+        mock_plug.last_state = "OFF"
+        mock_plug.last_checked = None
+
+        mock_db = AsyncMock()
+        mock_db.commit = AsyncMock()
+
+        # Step 1: Print starts — plug turns on
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
+        ):
+            mock_get.return_value = mock_plug
+            mock_service = AsyncMock()
+            mock_service.turn_on = AsyncMock(return_value=True)
+            mock_svc.return_value = mock_service
+
+            await manager.on_print_start(printer_id=1, db=mock_db)
+
+            assert mock_plug.auto_off_executed is False
+            assert mock_plug.auto_off is True  # Still enabled
+
+        # Step 2: Print completes — auto-off is scheduled
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get.return_value = mock_plug
+
+            await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
+
+            mock_schedule.assert_called_once()
+            assert mock_plug.auto_off is True  # Still enabled after scheduling
+
+        # Step 3: Auto-off executes via _mark_auto_off_executed
+        with patch("backend.app.core.database.async_session") as mock_session_ctx:
+            mock_db2 = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar_one_or_none.return_value = mock_plug
+            mock_db2.execute = AsyncMock(return_value=mock_result)
+            mock_db2.commit = AsyncMock()
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            await manager._mark_auto_off_executed(3)
+
+            # KEY ASSERTION: auto_off stays True for persistent mode
+            assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
+            assert mock_plug.auto_off_pending is False
+
+
 class TestScheduleLoop:
 class TestScheduleLoop:
     """Tests for the schedule-based plug control."""
     """Tests for the schedule-based plug control."""
 
 

+ 77 - 0
backend/tests/unit/services/test_spool_assignment_notifications.py

@@ -0,0 +1,77 @@
+"""Unit tests for spool assignment notification service."""
+
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
+
+
+class _FakeAssignmentsResult:
+    def __init__(self, rows):
+        self._rows = rows
+
+    def fetchall(self):
+        return self._rows
+
+
+class _FakeSession:
+    def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):
+        self._printer = SimpleNamespace(name=printer_name)
+        self._assignments = assignments
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+        return False
+
+    async def get(self, model, key):
+        return self._printer
+
+    async def execute(self, statement):
+        return _FakeAssignmentsResult(self._assignments)
+
+
+@pytest.mark.asyncio
+async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
+    """When a mapped tray is unassigned, service emits websocket and notification events."""
+    logger = logging.getLogger(__name__)
+    data = {
+        "ams_mapping": [1],
+        "raw_data": {},
+    }
+
+    # Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
+    assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
+
+    with (
+        patch(
+            "backend.app.services.spool_assignment_notifications.async_session",
+            return_value=_FakeSession("Printer A", assignments),
+        ),
+        patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
+            new_callable=AsyncMock,
+        ) as mock_ws,
+        patch(
+            "backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
+            new_callable=AsyncMock,
+        ) as mock_notify,
+    ):
+        await notify_missing_spool_assignments_on_print_start(1, data, logger)
+
+    mock_ws.assert_awaited_once()
+    ws_kwargs = mock_ws.await_args.kwargs
+    assert ws_kwargs["printer_id"] == 1
+    assert ws_kwargs["printer_name"] == "Printer A"
+    assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
+
+    mock_notify.assert_awaited_once()
+    notify_kwargs = mock_notify.await_args.kwargs
+    assert notify_kwargs["printer_id"] == 1
+    assert notify_kwargs["printer_name"] == "Printer A"
+    assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]

+ 385 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -8,9 +8,11 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.services.spool_tag_matcher import (
 from backend.app.services.spool_tag_matcher import (
     auto_assign_spool,
     auto_assign_spool,
     create_spool_from_tray,
     create_spool_from_tray,
+    find_matching_untagged_spool,
     get_spool_by_tag,
     get_spool_by_tag,
     is_bambu_tag,
     is_bambu_tag,
     is_valid_tag,
     is_valid_tag,
+    link_tag_to_inventory_spool,
 )
 )
 
 
 # -- helpers -----------------------------------------------------------------
 # -- helpers -----------------------------------------------------------------
@@ -196,6 +198,109 @@ async def test_get_spool_by_tag_returns_none_for_zeros(db_session):
     assert found is None
     assert found is None
 
 
 
 
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_first_char_variance_same_length(db_session):
+    """Match spool when scanned tag differs only in first character.
+
+    Handles case where same physical tag reports different first bytes
+    across different readers (e.g., "A45012F" stored, "B45012F" scanned).
+    Both tags have same length and differ only in first char.
+    """
+    spool = Spool(
+        material="PLA",
+        tag_uid="A4501234CCDDEE88",  # First tag variant
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.commit()
+
+    # Scan with different first character — should still match
+    found = await get_spool_by_tag(db_session, "B4501234CCDDEE88", "")
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_first_char_variance_short_uid(db_session):
+    """Match spool when 8-char scanned tag differs only in first character.
+
+    Handles short UID (8 char) from 4-byte readers with first-char variance.
+    The stored tag is longer (16 char), but the first 8 chars of the stored tag
+    should match the scanned 8-char UID with first-char tolerance.
+    """
+    spool = Spool(
+        material="PLA",
+        tag_uid="A4501234CCDDEE88",  # 16-char stored tag
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.commit()
+
+    # Scan with 8-char short UID whose first char differs but remaining 7 match
+    # the first 8 chars of the stored tag: stored[:8] = "A4501234",
+    # scanned = "B4501234" → first-char variance on short UID
+    found = await get_spool_by_tag(db_session, "B4501234", "")
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_short_uid_exact_match_preferred(db_session):
+    """Prefer exact match over first-char variance match."""
+    # Spool with exact 8-char UID match
+    spool_exact = Spool(
+        material="PLA",
+        tag_uid="B4501234",
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool_exact.k_profiles = []
+    spool_exact.assignments = []
+    db_session.add(spool_exact)
+
+    # Spool that would match via first-char variance
+    spool_variance = Spool(
+        material="PETG",
+        tag_uid="A4501234",
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool_variance.k_profiles = []
+    spool_variance.assignments = []
+    db_session.add(spool_variance)
+    await db_session.commit()
+
+    # Exact match should win over variance match
+    found = await get_spool_by_tag(db_session, "B4501234", "")
+    assert found is not None
+    assert found.id == spool_exact.id
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_no_false_positive_different_suffix(db_session):
+    """Don't match tags with different suffixes just because first char varies."""
+    spool = Spool(
+        material="PLA",
+        tag_uid="AABBCCDD11223344",
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.commit()
+
+    # Scan with different suffix (only first char is same) — should NOT match
+    found = await get_spool_by_tag(db_session, "AABBCCDD11223355", "")
+    assert found is None, "Should not match when suffix differs"
+
+
 # -- auto_assign_spool (SpoolAssignment creation) ---------------------------
 # -- auto_assign_spool (SpoolAssignment creation) ---------------------------
 
 
 
 
@@ -352,3 +457,283 @@ async def test_auto_assign_no_greenlet_error_existing_spool(db_session, printer_
 
 
     assert assignment is not None
     assert assignment is not None
     assert assignment.spool_id == found.id
     assert assignment.spool_id == found.id
+
+
+# -- find_matching_untagged_spool -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_exact_match(db_session):
+    """Finds an untagged spool with matching material, subtype, and color."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_skips_tagged(db_session):
+    """Spools that already have a tag_uid are not matched."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        tag_uid="1122334455667788",
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_skips_uuid_tagged(db_session):
+    """Spools that already have a tray_uuid are not matched."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        tray_uuid="AABBCCDD11223344AABBCCDD11223344",
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_skips_archived(db_session):
+    """Archived spools are not matched."""
+    from datetime import datetime
+
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        archived_at=datetime.now(),
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_wrong_material(db_session):
+    """Material mismatch returns None."""
+    spool = Spool(
+        material="PETG",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_wrong_color(db_session):
+    """Color (rgba) mismatch returns None."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FF0000FF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_wrong_subtype(db_session):
+    """Subtype mismatch returns None (PLA Matte vs PLA Basic)."""
+    spool = Spool(
+        material="PLA",
+        subtype="Matte",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_fifo(db_session):
+    """When multiple match, returns the oldest (FIFO)."""
+    import asyncio
+
+    spool_old = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool_old)
+    await db_session.flush()
+
+    # Small delay to ensure different created_at
+    await asyncio.sleep(0.05)
+
+    spool_new = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool_new)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool_old.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_case_insensitive(db_session):
+    """Matching is case-insensitive for material and rgba."""
+    spool = Spool(
+        material="pla",
+        subtype="basic",
+        rgba="ffffffff",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_no_subtype(db_session):
+    """Tray without subtype matches spool without subtype."""
+    tray = {**SAMPLE_TRAY, "tray_sub_brands": "PLA", "tray_type": "PLA"}
+    spool = Spool(
+        material="PLA",
+        subtype=None,
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, tray)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_relationships_loaded(db_session):
+    """Matched spool has k_profiles and assignments eagerly loaded."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    db_session.expire(spool)
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert _relationship_is_loaded(found, "k_profiles")
+    assert _relationship_is_loaded(found, "assignments")
+
+
+# -- link_tag_to_inventory_spool -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_link_tag_to_inventory_spool(db_session):
+    """Links RFID tag data to an existing spool."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.flush()
+
+    await link_tag_to_inventory_spool(db_session, spool, SAMPLE_TRAY)
+    await db_session.commit()
+
+    assert spool.tag_uid == "AABBCCDD11223344"
+    assert spool.tray_uuid == "AABBCCDD11223344AABBCCDD11223344"
+    assert spool.data_origin == "rfid_linked"
+    assert spool.tag_type == "bambulab"
+    assert spool.slicer_filament == "GFL99"
+
+
+@pytest.mark.asyncio
+async def test_link_tag_preserves_existing_slicer_filament(db_session):
+    """Does not overwrite an existing slicer_filament preset."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="CUSTOM01",
+        slicer_filament_name="My Custom PLA",
+    )
+    db_session.add(spool)
+    await db_session.flush()
+
+    await link_tag_to_inventory_spool(db_session, spool, SAMPLE_TRAY)
+    await db_session.commit()
+
+    assert spool.slicer_filament == "CUSTOM01"
+    assert spool.slicer_filament_name == "My Custom PLA"

+ 340 - 0
backend/tests/unit/services/test_spoolbuddy_ssh.py

@@ -0,0 +1,340 @@
+"""Unit tests for SpoolBuddy SSH update service."""
+
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.spoolbuddy_ssh import (
+    _get_ssh_key_dir,
+    _run_ssh_command,
+    detect_current_branch,
+    get_or_create_keypair,
+    get_public_key,
+    perform_ssh_update,
+)
+
+# -- _get_ssh_key_dir ---------------------------------------------------------
+
+
+def test_get_ssh_key_dir_creates_directory(tmp_path):
+    with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
+        mock_settings.base_dir = tmp_path
+        key_dir = _get_ssh_key_dir()
+        assert key_dir == tmp_path / "spoolbuddy" / "ssh"
+        assert key_dir.exists()
+
+
+def test_get_ssh_key_dir_returns_existing(tmp_path):
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
+        mock_settings.base_dir = tmp_path
+        assert _get_ssh_key_dir() == ssh_dir
+
+
+# -- get_or_create_keypair -----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_keypair_returns_existing(tmp_path):
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    priv = ssh_dir / "id_ed25519"
+    pub = ssh_dir / "id_ed25519.pub"
+    priv.write_text("PRIVATE")
+    pub.write_text("PUBLIC")
+
+    with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
+        mock_settings.base_dir = tmp_path
+        result = await get_or_create_keypair()
+        assert result == (priv, pub)
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_keypair_generates_new(tmp_path):
+    with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
+        mock_settings.base_dir = tmp_path
+
+        ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+
+        async def fake_keygen(*args, **kwargs):
+            # Simulate ssh-keygen creating the files
+            ssh_dir.mkdir(parents=True, exist_ok=True)
+            (ssh_dir / "id_ed25519").write_text("PRIVATE")
+            (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
+            mock_proc = AsyncMock()
+            mock_proc.communicate = AsyncMock(return_value=(b"", b""))
+            mock_proc.returncode = 0
+            return mock_proc
+
+        with patch("asyncio.create_subprocess_exec", side_effect=fake_keygen) as mock_exec:
+            priv, pub = await get_or_create_keypair()
+
+            mock_exec.assert_called_once()
+            args = mock_exec.call_args[0]
+            assert "ssh-keygen" in args
+            assert "-t" in args
+            assert "ed25519" in args
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_keypair_raises_on_failure(tmp_path):
+    with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
+        mock_settings.base_dir = tmp_path
+
+        mock_proc = AsyncMock()
+        mock_proc.communicate = AsyncMock(return_value=(b"", b"keygen error"))
+        mock_proc.returncode = 1
+
+        with (
+            patch("asyncio.create_subprocess_exec", return_value=mock_proc),
+            pytest.raises(RuntimeError, match="ssh-keygen failed"),
+        ):
+            await get_or_create_keypair()
+
+
+# -- get_public_key ------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_public_key(tmp_path):
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    (ssh_dir / "id_ed25519").write_text("PRIVATE")
+    (ssh_dir / "id_ed25519.pub").write_text("ssh-ed25519 AAAA bambuddy-spoolbuddy\n")
+
+    with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
+        mock_settings.base_dir = tmp_path
+        key = await get_public_key()
+        assert key == "ssh-ed25519 AAAA bambuddy-spoolbuddy"
+
+
+# -- detect_current_branch ----------------------------------------------------
+
+
+def test_detect_branch_from_git(tmp_path):
+    (tmp_path / ".git").mkdir()
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch("subprocess.run") as mock_run,
+    ):
+        mock_settings.base_dir = tmp_path
+        mock_run.return_value = MagicMock(returncode=0, stdout="dev\n")
+        assert detect_current_branch() == "dev"
+
+
+def test_detect_branch_env_fallback(tmp_path):
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch.dict(os.environ, {"GIT_BRANCH": "staging"}),
+    ):
+        mock_settings.base_dir = tmp_path
+        assert detect_current_branch() == "staging"
+
+
+def test_detect_branch_default_main(tmp_path):
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch.dict(os.environ, {}, clear=True),
+    ):
+        mock_settings.base_dir = tmp_path
+        # Remove GIT_BRANCH if present
+        os.environ.pop("GIT_BRANCH", None)
+        assert detect_current_branch() == "main"
+
+
+# -- _run_ssh_command ----------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_run_ssh_command_success(tmp_path):
+    key_file = tmp_path / "key"
+    key_file.write_text("KEY")
+
+    mock_proc = AsyncMock()
+    mock_proc.communicate = AsyncMock(return_value=(b"hello\n", b""))
+    mock_proc.returncode = 0
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
+        rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
+
+    assert rc == 0
+    assert stdout == "hello\n"
+    assert stderr == ""
+    args = mock_exec.call_args[0]
+    assert "spoolbuddy@10.0.0.1" in args
+    assert "echo hello" in args
+    assert "BatchMode=yes" in args
+
+
+@pytest.mark.asyncio
+async def test_run_ssh_command_failure(tmp_path):
+    key_file = tmp_path / "key"
+    key_file.write_text("KEY")
+
+    mock_proc = AsyncMock()
+    mock_proc.communicate = AsyncMock(return_value=(b"", b"Connection refused"))
+    mock_proc.returncode = 255
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+        rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
+
+    assert rc == 255
+    assert "Connection refused" in stderr
+
+
+@pytest.mark.asyncio
+async def test_run_ssh_command_timeout(tmp_path):
+    key_file = tmp_path / "key"
+    key_file.write_text("KEY")
+
+    mock_proc = AsyncMock()
+    mock_proc.communicate = AsyncMock(return_value=(b"", b""))
+    mock_proc.kill = MagicMock()
+
+    async def fake_wait_for(coro, timeout):
+        # Consume the coroutine to avoid warning
+        coro.close()
+        raise TimeoutError
+
+    with (
+        patch("asyncio.create_subprocess_exec", return_value=mock_proc),
+        patch("backend.app.services.spoolbuddy_ssh.asyncio.wait_for", side_effect=fake_wait_for),
+    ):
+        rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=1)
+
+    assert rc == -1
+    assert "timed out" in stderr
+    mock_proc.kill.assert_called_once()
+
+
+# -- perform_ssh_update --------------------------------------------------------
+
+
+def _make_update_mocks(tmp_path):
+    """Create common mocks for perform_ssh_update tests."""
+    mock_db_device = MagicMock()
+    mock_db_device.update_status = None
+    mock_db_device.update_message = None
+    mock_db_device.pending_command = None
+
+    mock_result = MagicMock()
+    mock_result.scalar_one_or_none.return_value = mock_db_device
+
+    mock_session = AsyncMock()
+    mock_session.execute = AsyncMock(return_value=mock_result)
+    mock_session.commit = AsyncMock()
+
+    mock_ctx = AsyncMock()
+    mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_ctx.__aexit__ = AsyncMock(return_value=False)
+
+    mock_ws = MagicMock()
+    mock_ws.broadcast = AsyncMock()
+
+    return mock_db_device, mock_ctx, mock_ws
+
+
+@pytest.mark.asyncio
+async def test_perform_ssh_update_success(tmp_path):
+    """Full update flow: all SSH commands succeed."""
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    (ssh_dir / "id_ed25519").write_text("PRIVATE")
+    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
+
+    ssh_calls = []
+
+    async def mock_ssh(ip, cmd, key, timeout=60):
+        ssh_calls.append(cmd)
+        return 0, "ok", ""
+
+    _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
+
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
+        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="dev"),
+        patch("backend.app.core.database.async_session", return_value=mock_ctx),
+        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
+    ):
+        mock_settings.base_dir = tmp_path
+        await perform_ssh_update("sb-test", "10.0.0.1")
+
+    # Should have run: echo ok, git fetch, git checkout+reset, pip install,
+    # systemctl restart, find (SW cleanup), systemctl restart getty
+    assert len(ssh_calls) == 7
+    assert "echo ok" in ssh_calls[0]
+    assert "fetch" in ssh_calls[1]
+    assert "checkout" in ssh_calls[2]
+    assert "pip" in ssh_calls[3]
+    assert "spoolbuddy.service" in ssh_calls[4]
+    assert "Service Worker" in ssh_calls[5]
+    assert "getty" in ssh_calls[6]
+
+    assert mock_ws.broadcast.call_count >= 4
+
+
+@pytest.mark.asyncio
+async def test_perform_ssh_update_ssh_failure(tmp_path):
+    """SSH connectivity check fails — should set error status."""
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    (ssh_dir / "id_ed25519").write_text("PRIVATE")
+    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
+
+    async def mock_ssh(ip, cmd, key, timeout=60):
+        if "echo ok" in cmd:
+            return 255, "", "Connection refused"
+        return 0, "", ""
+
+    mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
+
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
+        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
+        patch("backend.app.core.database.async_session", return_value=mock_ctx),
+        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
+    ):
+        mock_settings.base_dir = tmp_path
+        await perform_ssh_update("sb-test", "10.0.0.1")
+
+    # Should broadcast error status
+    error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get("update_status") == "error"]
+    assert len(error_broadcasts) >= 1
+    assert "SSH connection failed" in error_broadcasts[0][0][0]["update_message"]
+
+
+@pytest.mark.asyncio
+async def test_perform_ssh_update_git_fetch_failure(tmp_path):
+    """Git fetch fails — should set error and stop."""
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    (ssh_dir / "id_ed25519").write_text("PRIVATE")
+    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
+
+    ssh_calls = []
+
+    async def mock_ssh(ip, cmd, key, timeout=60):
+        ssh_calls.append(cmd)
+        if "fetch" in cmd:
+            return 1, "", "fatal: could not read from remote"
+        return 0, "ok", ""
+
+    _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
+
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
+        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
+        patch("backend.app.core.database.async_session", return_value=mock_ctx),
+        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
+    ):
+        mock_settings.base_dir = tmp_path
+        await perform_ssh_update("sb-test", "10.0.0.1")
+
+    # Should stop after git fetch — no checkout, pip, restart
+    assert len(ssh_calls) == 2  # echo ok + git fetch
+    assert not any("checkout" in c for c in ssh_calls)

+ 7 - 3
backend/tests/unit/services/test_usage_tracker.py

@@ -32,13 +32,14 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
     return spool
     return spool
 
 
 
 
-def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
+def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
     """Create a mock SpoolAssignment object."""
     """Create a mock SpoolAssignment object."""
     assignment = MagicMock()
     assignment = MagicMock()
     assignment.spool_id = spool_id
     assignment.spool_id = spool_id
     assignment.printer_id = printer_id
     assignment.printer_id = printer_id
     assignment.ams_id = ams_id
     assignment.ams_id = ams_id
     assignment.tray_id = tray_id
     assignment.tray_id = tray_id
+    assignment.created_at = created_at or datetime.now(timezone.utc)
     return assignment
     return assignment
 
 
 
 
@@ -570,10 +571,11 @@ class TestSpoolAssignmentSnapshot:
         ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
         ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
         pm = _make_printer_manager(_make_printer_state(ams_data))
         pm = _make_printer_manager(_make_printer_state(ams_data))
 
 
-        # db only returns spool (NO assignment query)
+        # db returns no live assignment, then spool from snapshot spool_id
         db = AsyncMock()
         db = AsyncMock()
         db.execute = AsyncMock(
         db.execute = AsyncMock(
             side_effect=[
             side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
             ]
             ]
         )
         )
@@ -657,13 +659,15 @@ class TestSpoolAssignmentSnapshot:
 
 
         filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
         filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
 
 
-        # db: archive, queue_item(None), spool, then cost aggregation queries
+        # db: archive, queue_item(None), live assignment(None), spool,
+        # then cost aggregation queries
         # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
         # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
         db = AsyncMock()
         db = AsyncMock()
         db.execute = AsyncMock(
         db.execute = AsyncMock(
             side_effect=[
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 # Cost aggregation: sum query (uses .scalar()), archive lookup
                 # Cost aggregation: sum query (uses .scalar()), archive lookup
                 MagicMock(scalar=MagicMock(return_value=0)),
                 MagicMock(scalar=MagicMock(return_value=0)),

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

@@ -1091,6 +1091,8 @@ class TestSlicerProxyManager:
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
         assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
         assert proxy_manager.PRINTER_RTSP_PORT == 322
         assert proxy_manager.PRINTER_RTSP_PORT == 322
+        # Auxiliary ports: undocumented proprietary ports for A1/P1S etc.
+        assert proxy_manager.PRINTER_AUX_PORTS == [2024, 2025, 2026]
         # Bind ports: both 3000 and 3002 for slicer compatibility
         # Bind ports: both 3000 and 3002 for slicer compatibility
         assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
         assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
         # FTP data port range for transparent EPSV proxying
         # FTP data port range for transparent EPSV proxying
@@ -1157,6 +1159,14 @@ class TestSlicerProxyManager:
         # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
         # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
         assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
         assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
 
 
+        # Auxiliary ports (2024-2026) should be TCPProxy (transparent)
+        assert len(mgr._aux_proxies) == 3, "Should have 3 aux port proxies"
+        for ap in mgr._aux_proxies:
+            assert isinstance(ap, TCPProxy), "Aux proxies should be TCPProxy"
+        assert mgr._aux_proxies[0].listen_port == 2024
+        assert mgr._aux_proxies[0].target_port == 2024
+        assert mgr._aux_proxies[2].listen_port == 2026
+
         # FTP data ports should be pre-created as TCPProxy instances
         # FTP data ports should be pre-created as TCPProxy instances
         assert len(mgr._ftp_data_proxies) == 101  # 50000-50100 inclusive
         assert len(mgr._ftp_data_proxies) == 101  # 50000-50100 inclusive
         for dp in mgr._ftp_data_proxies:
         for dp in mgr._ftp_data_proxies:

+ 122 - 0
backend/tests/unit/test_spoolbuddy_system_stats.py

@@ -0,0 +1,122 @@
+"""Tests for SpoolBuddy daemon system_stats collector."""
+
+import pytest
+
+pytest.importorskip("spoolbuddy", reason="spoolbuddy package not available in Docker")
+
+from unittest.mock import patch
+
+from spoolbuddy.daemon.system_stats import (
+    _cpu_count,
+    _cpu_temp,
+    _disk_info,
+    _load_avg,
+    _memory_info,
+    _os_info,
+    _system_uptime,
+    collect,
+)
+
+
+class TestCpuTemp:
+    def test_reads_thermal_zone(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value="52100"):
+            assert _cpu_temp() == 52.1
+
+    def test_returns_none_on_missing_file(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=None):
+            assert _cpu_temp() is None
+
+    def test_returns_none_on_bad_value(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value="not_a_number"):
+            assert _cpu_temp() is None
+
+
+class TestMemoryInfo:
+    SAMPLE_MEMINFO = (
+        "MemTotal:        1024000 kB\n"
+        "MemFree:          200000 kB\n"
+        "MemAvailable:     512000 kB\n"
+        "Buffers:           50000 kB\n"
+    )
+
+    def test_parses_meminfo(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=self.SAMPLE_MEMINFO):
+            result = _memory_info()
+            assert result is not None
+            assert result["total_mb"] == 1000
+            assert result["available_mb"] == 500
+            assert result["used_mb"] == 500
+            assert result["percent"] == 50.0
+
+    def test_returns_none_on_missing(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=None):
+            assert _memory_info() is None
+
+
+class TestDiskInfo:
+    def test_returns_disk_stats(self):
+        result = _disk_info()
+        # Should always work on Linux
+        assert result is not None
+        assert "total_gb" in result
+        assert "used_gb" in result
+        assert "free_gb" in result
+        assert "percent" in result
+        assert 0 <= result["percent"] <= 100
+
+
+class TestLoadAvg:
+    def test_returns_three_values(self):
+        result = _load_avg()
+        assert result is not None
+        assert len(result) == 3
+        for val in result:
+            assert isinstance(val, float)
+
+
+class TestCpuCount:
+    def test_returns_positive_int(self):
+        result = _cpu_count()
+        assert result is not None
+        assert result > 0
+
+
+class TestOsInfo:
+    def test_returns_required_keys(self):
+        result = _os_info()
+        assert "os" in result
+        assert "kernel" in result
+        assert "arch" in result
+        assert "python" in result
+
+    def test_parses_pretty_name(self):
+        fake_release = 'PRETTY_NAME="Raspbian GNU/Linux 12 (bookworm)"\nID=raspbian\n'
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=fake_release):
+            result = _os_info()
+            assert result["os"] == "Raspbian GNU/Linux 12 (bookworm)"
+
+
+class TestSystemUptime:
+    def test_parses_uptime(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value="86400.55 172000.10"):
+            assert _system_uptime() == 86400
+
+    def test_returns_none_on_missing(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=None):
+            assert _system_uptime() is None
+
+
+class TestCollect:
+    def test_returns_dict_with_expected_keys(self):
+        result = collect()
+        assert isinstance(result, dict)
+        assert "os" in result
+        # These may or may not be present depending on platform, but os is always present
+
+    def test_all_values_are_json_serializable(self):
+        import json
+
+        result = collect()
+        # Should not raise
+        json.dumps(result)

+ 102 - 1
backend/tests/unit/test_usage_tracker.py

@@ -5,7 +5,7 @@ AMS remain% delta as fallback, per-layer gcode for partial prints,
 slot-to-tray mapping resolution, and notification variable formatting.
 slot-to-tray mapping resolution, and notification variable formatting.
 """
 """
 
 
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
@@ -327,6 +327,107 @@ class TestOnPrintComplete:
 class TestTrackFrom3mf:
 class TestTrackFrom3mf:
     """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
     """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
 
 
+    @pytest.mark.asyncio
+    async def test_prefers_live_assignment_when_reassigned_mid_print(self):
+        """If tray assignment changed during print, track usage on the new spool."""
+        spool_old = _make_spool(spool_id=1, label_weight=1000)
+        spool_new = _make_spool(spool_id=2, label_weight=1000)
+        archive = _make_archive(archive_id=80)
+
+        live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        started_at = datetime.now(timezone.utc)
+        live_assignment.created_at = started_at + timedelta(seconds=5)
+
+        # db: archive, queue_item(None), live assignment lookup, spool_new lookup
+        db = _mock_db_sequential([archive, None, live_assignment, spool_new])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 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=80,
+                status="completed",
+                print_name="MidPrintReassign",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                spool_assignments={(0, 0): spool_old.id},
+                print_started_at=started_at,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == spool_new.id
+
+    @pytest.mark.asyncio
+    async def test_keeps_snapshot_when_live_assignment_predates_print(self):
+        """If live assignment predates print start, preserve snapshot spool mapping."""
+        spool_old = _make_spool(spool_id=1, label_weight=1000)
+        archive = _make_archive(archive_id=81)
+
+        live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        started_at = datetime.now(timezone.utc)
+        live_assignment.created_at = started_at - timedelta(seconds=5)
+
+        # db: archive, queue_item(None), live assignment lookup, spool_old lookup
+        db = _mock_db_sequential([archive, None, live_assignment, spool_old])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 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=81,
+                status="completed",
+                print_name="SnapshotPreserved",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                spool_assignments={(0, 0): spool_old.id},
+                print_started_at=started_at,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == spool_old.id
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_linear_fallback_for_partial_print(self):
     async def test_linear_fallback_for_partial_print(self):
         """Falls back to linear scaling when gcode layer data unavailable."""
         """Falls back to linear scaling when gcode layer data unavailable."""

+ 1 - 0
docker-compose.yml

@@ -29,6 +29,7 @@ services:
     #  - "990:990"                    # Virtual printer FTP control
     #  - "990:990"                    # Virtual printer FTP control
     #  - "6000:6000"                  # Virtual printer file transfer tunnel
     #  - "6000:6000"                  # Virtual printer file transfer tunnel
     #  - "322:322"                    # Virtual printer RTSP camera (X1/H2/P2)
     #  - "322:322"                    # Virtual printer RTSP camera (X1/H2/P2)
+    #  - "2024-2026:2024-2026"        # Virtual printer proprietary ports (A1/P1S)
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data

+ 4 - 0
docker-publish-daily-beta.sh

@@ -288,6 +288,9 @@ else
     # Extract release notes from CHANGELOG: content between ## [<version>] and the next ## [ heading
     # Extract release notes from CHANGELOG: content between ## [<version>] and the next ## [ heading
     CHANGELOG_NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[/!p}" "$CHANGELOG_FILE" | sed '/^$/d; 1{/^$/d}')
     CHANGELOG_NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[/!p}" "$CHANGELOG_FILE" | sed '/^$/d; 1{/^$/d}')
 
 
+    # Strip @mentions so GitHub doesn't auto-generate a "Contributors" section
+    CHANGELOG_NOTES=$(echo "$CHANGELOG_NOTES" | sed 's/@\([a-zA-Z0-9_-]*\)/\1/g')
+
     if [ -z "$CHANGELOG_NOTES" ]; then
     if [ -z "$CHANGELOG_NOTES" ]; then
         echo -e "${YELLOW}  Warning: No changelog notes found for ${VERSION}${NC}"
         echo -e "${YELLOW}  Warning: No changelog notes found for ${VERSION}${NC}"
         CHANGELOG_NOTES="No changelog notes available for this release."
         CHANGELOG_NOTES="No changelog notes available for this release."
@@ -346,6 +349,7 @@ EOF
     gh release create "v${DAILY_TAG}" \
     gh release create "v${DAILY_TAG}" \
         --title "Daily Beta Build v${DAILY_TAG}" \
         --title "Daily Beta Build v${DAILY_TAG}" \
         --prerelease \
         --prerelease \
+        --generate-notes=false \
         --notes "$RELEASE_BODY"
         --notes "$RELEASE_BODY"
     echo -e "${GREEN}  Created GitHub release: v${DAILY_TAG}${NC}"
     echo -e "${GREEN}  Created GitHub release: v${DAILY_TAG}${NC}"
 fi
 fi

+ 22 - 10
frontend/index.html

@@ -28,18 +28,30 @@
     <div id="root"></div>
     <div id="root"></div>
     <script type="module" src="/src/main.tsx"></script>
     <script type="module" src="/src/main.tsx"></script>
 
 
-    <!-- Service Worker Registration -->
+    <!-- Service Worker Registration (skip on SpoolBuddy kiosk) -->
     <script>
     <script>
       if ('serviceWorker' in navigator) {
       if ('serviceWorker' in navigator) {
-        window.addEventListener('load', () => {
-          navigator.serviceWorker.register('/sw.js')
-            .then((registration) => {
-              console.log('SW registered:', registration.scope);
-            })
-            .catch((error) => {
-              console.log('SW registration failed:', error);
-            });
-        });
+        if (location.pathname.startsWith('/spoolbuddy')) {
+          // Kiosk mode — nuke SW and all caches, then reload once to get clean state
+          navigator.serviceWorker.getRegistrations().then((regs) => {
+            if (regs.length > 0) {
+              Promise.all([
+                ...regs.map((r) => r.unregister()),
+                caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),
+              ]).then(() => location.reload());
+            }
+          });
+        } else {
+          window.addEventListener('load', () => {
+            navigator.serviceWorker.register('/sw.js')
+              .then((registration) => {
+                console.log('SW registered:', registration.scope);
+              })
+              .catch((error) => {
+                console.log('SW registration failed:', error);
+              });
+          });
+        }
       }
       }
     </script>
     </script>
   </body>
   </body>

+ 4 - 4
frontend/package-lock.json

@@ -3693,15 +3693,15 @@
       }
       }
     },
     },
     "node_modules/brace-expansion": {
     "node_modules/brace-expansion": {
-      "version": "5.0.2",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
-      "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+      "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
       "dev": true,
       "dev": true,
       "dependencies": {
       "dependencies": {
         "balanced-match": "^4.0.2"
         "balanced-match": "^4.0.2"
       },
       },
       "engines": {
       "engines": {
-        "node": "20 || >=22"
+        "node": "18 || 20 || >=22"
       }
       }
     },
     },
     "node_modules/browserslist": {
     "node_modules/browserslist": {

+ 10 - 8
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v24';
-const STATIC_CACHE = 'bambuddy-static-v24';
+const CACHE_NAME = 'bambuddy-v25';
+const STATIC_CACHE = 'bambuddy-static-v25';
 
 
 // Static assets to cache on install
 // Static assets to cache on install
 const STATIC_ASSETS = [
 const STATIC_ASSETS = [
@@ -113,15 +113,16 @@ self.addEventListener('fetch', (event) => {
     return;
     return;
   }
   }
 
 
-  // JS/CSS assets - stale-while-revalidate
+  // JS/CSS assets - network first (Vite content-hashes filenames, so
+  // cache-busting is built in; network-first ensures new builds load immediately)
   if (
   if (
     url.pathname.startsWith('/assets/') ||
     url.pathname.startsWith('/assets/') ||
     url.pathname.endsWith('.js') ||
     url.pathname.endsWith('.js') ||
     url.pathname.endsWith('.css')
     url.pathname.endsWith('.css')
   ) {
   ) {
     event.respondWith(
     event.respondWith(
-      caches.match(request).then((cached) => {
-        const fetchPromise = fetch(request).then((response) => {
+      fetch(request)
+        .then((response) => {
           if (response.ok) {
           if (response.ok) {
             const clone = response.clone();
             const clone = response.clone();
             caches.open(CACHE_NAME).then((cache) => {
             caches.open(CACHE_NAME).then((cache) => {
@@ -129,9 +130,10 @@ self.addEventListener('fetch', (event) => {
             });
             });
           }
           }
           return response;
           return response;
-        });
-        return cached || fetchPromise;
-      })
+        })
+        .catch(() => {
+          return caches.match(request);
+        })
     );
     );
     return;
     return;
   }
   }

+ 2 - 0
frontend/src/App.tsx

@@ -30,6 +30,7 @@ import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
 import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
 import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
 import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
+import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
 const queryClient = new QueryClient({
 const queryClient = new QueryClient({
   defaultOptions: {
   defaultOptions: {
     queries: {
     queries: {
@@ -125,6 +126,7 @@ function App() {
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
                   <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
                   <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
+                  <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
                   <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                   <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                 </Route>
                 </Route>

+ 46 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -43,6 +43,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
   auto_off: true,
   auto_off: true,
+  auto_off_persistent: false,
   off_delay_mode: 'time',
   off_delay_mode: 'time',
   off_delay_minutes: 5,
   off_delay_minutes: 5,
   off_temp_threshold: 70,
   off_temp_threshold: 70,
@@ -241,6 +242,51 @@ describe('SmartPlugCard', () => {
     });
     });
   });
   });
 
 
+  describe('persistent auto-off', () => {
+    it('shows Keep Enabled toggle when auto_off is enabled', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ auto_off: true, auto_off_persistent: false });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Keep Enabled')).toBeInTheDocument();
+        expect(screen.getByText('Stay enabled between prints instead of one-shot')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show Keep Enabled toggle when auto_off is disabled', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ auto_off: false });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Keep Enabled')).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows Keep Enabled toggle for HA plugs with auto_off enabled', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({
+        plug_type: 'homeassistant',
+        ip_address: null,
+        ha_entity_id: 'switch.bentobox_filter',
+        auto_off: true,
+        auto_off_persistent: true,
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Keep Enabled')).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('disabled state', () => {
   describe('disabled state', () => {
     it('renders plug even when disabled', () => {
     it('renders plug even when disabled', () => {
       const plug = createMockPlug({ enabled: false });
       const plug = createMockPlug({ enabled: false });

+ 56 - 17
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -9,11 +9,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { renderHook, waitFor, act } from '@testing-library/react';
 import { renderHook, waitFor, act } from '@testing-library/react';
 import React from 'react';
 import React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ToastProvider } from '../../contexts/ToastContext';
 
 
 // Track WebSocket instances created during tests
 // Track WebSocket instances created during tests
 let wsInstances: MockWebSocket[] = [];
 let wsInstances: MockWebSocket[] = [];
 let originalWebSocket: typeof WebSocket;
 let originalWebSocket: typeof WebSocket;
 
 
+// Mock react-i18next BEFORE any modules that use it are imported
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string, options?: Record<string, unknown>) => {
+      if (key === 'printers.toast.missingSpoolAssignment' && options) {
+        const { printer, slots } = options as { printer: string; slots: string };
+        return `Missing assignments for ${printer}: ${slots}`;
+      }
+      return key;
+    },
+    i18n: {},
+  }),
+}));
+
 // Enhanced MockWebSocket that tracks instances
 // Enhanced MockWebSocket that tracks instances
 class MockWebSocket {
 class MockWebSocket {
   static readonly CONNECTING = 0;
   static readonly CONNECTING = 0;
@@ -77,13 +92,17 @@ function createTestQueryClient() {
   });
   });
 }
 }
 
 
-// Wrapper with QueryClient for hook testing
+// Wrapper with QueryClient and ToastProvider for hook testing
 function createWrapper(queryClient: QueryClient) {
 function createWrapper(queryClient: QueryClient) {
   return function Wrapper({ children }: { children: React.ReactNode }) {
   return function Wrapper({ children }: { children: React.ReactNode }) {
     return React.createElement(
     return React.createElement(
-      QueryClientProvider,
-      { client: queryClient },
-      children
+      ToastProvider,
+      {},
+      React.createElement(
+        QueryClientProvider,
+        { client: queryClient },
+        children
+      )
     );
     );
   };
   };
 }
 }
@@ -99,6 +118,7 @@ describe('useWebSocket hook', () => {
     vi.clearAllMocks();
     vi.clearAllMocks();
     wsInstances = [];
     wsInstances = [];
     queryClient = createTestQueryClient();
     queryClient = createTestQueryClient();
+
     // Save original and install mock
     // Save original and install mock
     originalWebSocket = globalThis.WebSocket;
     originalWebSocket = globalThis.WebSocket;
     globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
     globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
@@ -164,8 +184,6 @@ describe('useWebSocket hook', () => {
 
 
   describe('hook connection', () => {
   describe('hook connection', () => {
     it('connects to WebSocket on mount', async () => {
     it('connects to WebSocket on mount', async () => {
-      // Reset module cache to get fresh import with our mock
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       renderHook(() => useWebSocket(), {
       renderHook(() => useWebSocket(), {
@@ -178,7 +196,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('reports connected state when WebSocket opens', async () => {
     it('reports connected state when WebSocket opens', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const { result } = renderHook(() => useWebSocket(), {
       const { result } = renderHook(() => useWebSocket(), {
@@ -260,7 +277,6 @@ describe('useWebSocket hook', () => {
         cb(0);
         cb(0);
         return 0;
         return 0;
       });
       });
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -303,7 +319,6 @@ describe('useWebSocket hook', () => {
         cb(0);
         cb(0);
         return 0;
         return 0;
       });
       });
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -345,7 +360,6 @@ describe('useWebSocket hook', () => {
         cb(0);
         cb(0);
         return 0;
         return 0;
       });
       });
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -380,8 +394,39 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
       vi.unstubAllGlobals();
     });
     });
 
 
+    it('handles missing_spool_assignment message without error', async () => {
+      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+        cb(0);
+        return 0;
+      });
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = getLatestWs()!;
+      act(() => {
+        ws.open();
+      });
+
+      // This test verifies that the hook properly handles missing_spool_assignment messages
+      // without throwing an error. The actual toast display is tested via the UI.
+      expect(() => {
+        act(() => {
+          ws.simulateMessage({
+            type: 'missing_spool_assignment',
+            printer_id: 7,
+            printer_name: 'Printer B',
+            missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
+          });
+        });
+      }).not.toThrow();
+
+      vi.unstubAllGlobals();
+    });
+
     it('ignores pong messages without error', async () => {
     it('ignores pong messages without error', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -409,7 +454,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('handles malformed JSON gracefully', async () => {
     it('handles malformed JSON gracefully', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       renderHook(() => useWebSocket(), {
       renderHook(() => useWebSocket(), {
@@ -438,7 +482,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('handles unknown message types gracefully', async () => {
     it('handles unknown message types gracefully', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -470,7 +513,6 @@ describe('useWebSocket hook', () => {
 
 
   describe('sendMessage', () => {
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
     it('sends JSON message when connected', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const { result } = renderHook(() => useWebSocket(), {
       const { result } = renderHook(() => useWebSocket(), {
@@ -494,7 +536,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('does not send when disconnected', async () => {
     it('does not send when disconnected', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const { result } = renderHook(() => useWebSocket(), {
       const { result } = renderHook(() => useWebSocket(), {
@@ -516,7 +557,6 @@ describe('useWebSocket hook', () => {
   describe('reconnection', () => {
   describe('reconnection', () => {
     it('reconnects after connection closes', async () => {
     it('reconnects after connection closes', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
-      vi.resetModules();
 
 
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
@@ -551,7 +591,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('cleans up on unmount', async () => {
     it('cleans up on unmount', async () => {
-      vi.resetModules();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const { unmount } = renderHook(() => useWebSocket(), {
       const { unmount } = renderHook(() => useWebSocket(), {

+ 302 - 0
frontend/src/__tests__/pages/FileManagerExternalFolder.test.tsx

@@ -0,0 +1,302 @@
+/**
+ * Tests for External Folder functionality in FileManagerPage.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock data with external folder
+const mockFoldersWithExternal = [
+  {
+    id: 1,
+    name: 'Regular Folder',
+    parent_id: null,
+    file_count: 3,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    external_path: null,
+    external_readonly: false,
+    children: [],
+  },
+  {
+    id: 2,
+    name: 'NAS Prints',
+    parent_id: null,
+    file_count: 5,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: true,
+    external_path: '/mnt/nas/prints',
+    external_readonly: true,
+    children: [],
+  },
+  {
+    id: 3,
+    name: 'USB Drive',
+    parent_id: null,
+    file_count: 2,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: true,
+    external_path: '/mnt/usb',
+    external_readonly: false,
+    children: [],
+  },
+];
+
+const mockFiles = [
+  {
+    id: 1,
+    filename: 'benchy.3mf',
+    file_path: '/mnt/nas/prints/benchy.3mf',
+    file_size: 1048576,
+    file_type: '3mf',
+    folder_id: 2,
+    is_external: true,
+    thumbnail_path: null,
+    print_name: 'Benchy',
+    print_time_seconds: 3600,
+    print_count: 0,
+    duplicate_count: 0,
+    created_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockStats = {
+  total_files: 10,
+  total_folders: 3,
+  total_size_bytes: 104857600,
+  disk_free_bytes: 10737418240,
+  disk_total_bytes: 107374182400,
+};
+
+describe('FileManagerPage - External Folders', () => {
+  beforeEach(() => {
+    localStorage.clear();
+
+    server.use(
+      http.get('/api/v1/library/folders', () => {
+        return HttpResponse.json(mockFoldersWithExternal);
+      }),
+      http.get('/api/v1/library/files', () => {
+        return HttpResponse.json(mockFiles);
+      }),
+      http.get('/api/v1/library/stats', () => {
+        return HttpResponse.json(mockStats);
+      }),
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json({
+          check_updates: false,
+          check_printer_firmware: false,
+          library_disk_warning_gb: 5,
+        });
+      }),
+      http.post('/api/v1/library/folders/external', async ({ request }) => {
+        const body = await request.json() as { name: string; external_path: string };
+        return HttpResponse.json({
+          id: 10,
+          name: body.name,
+          parent_id: null,
+          is_external: true,
+          external_path: body.external_path,
+          external_readonly: true,
+          external_show_hidden: false,
+          file_count: 0,
+          created_at: '2024-01-01T00:00:00Z',
+          updated_at: '2024-01-01T00:00:00Z',
+        });
+      }),
+      http.post('/api/v1/library/folders/:id/scan', () => {
+        return HttpResponse.json({ status: 'success', added: 3, removed: 0 });
+      }),
+      http.get('/api/v1/projects/', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/archives/', () => {
+        return HttpResponse.json([]);
+      }),
+      http.delete('/api/v1/library/folders/:id', () => {
+        return HttpResponse.json({ success: true });
+      }),
+      http.delete('/api/v1/library/files/:id', () => {
+        return HttpResponse.json({ success: true });
+      }),
+      http.post('/api/v1/library/files/move', () => {
+        return HttpResponse.json({ success: true });
+      }),
+    );
+  });
+
+  describe('rendering', () => {
+    it('shows Link External button', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+    });
+
+    it('shows external folder in sidebar', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+        expect(screen.getByText('USB Drive')).toBeInTheDocument();
+      });
+    });
+
+    it('shows regular folder alongside external', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Regular Folder')).toBeInTheDocument();
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+      });
+    });
+
+    it('shows read-only indicator for readonly external folders', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        // NAS Prints is readonly, should have a lock icon title
+        const lockIcons = document.querySelectorAll('[title="Read Only"]');
+        expect(lockIcons.length).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  describe('external folder modal', () => {
+    it('opens modal when Link External clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External Folder')).toBeInTheDocument();
+      });
+    });
+
+    it('modal has name and path fields', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText('e.g., NAS Prints')).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('/mnt/nas/3d-prints')).toBeInTheDocument();
+      });
+    });
+
+    it('modal has readonly checkbox checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        const readonlyCheckbox = screen.getByText('Read Only').previousElementSibling as HTMLInputElement;
+        expect(readonlyCheckbox).toBeChecked();
+      });
+    });
+
+    it('modal can be closed', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Link External'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Link External Folder')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Cancel'));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Link External Folder')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('external folder info bar', () => {
+    it('shows info bar when external folder selected', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+      });
+
+      // Click on NAS Prints folder - there are multiple elements, get the one in the sidebar
+      const folderElements = screen.getAllByText('NAS Prints');
+      await user.click(folderElements[0]);
+
+      await waitFor(() => {
+        expect(screen.getByText('External Folder')).toBeInTheDocument();
+        expect(screen.getByText('/mnt/nas/prints')).toBeInTheDocument();
+      });
+    });
+
+    it('shows scan button for external folders', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('NAS Prints')).toBeInTheDocument();
+      });
+
+      const folderElements = screen.getAllByText('NAS Prints');
+      await user.click(folderElements[0]);
+
+      await waitFor(() => {
+        expect(screen.getByText('Scan')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show info bar for regular folders', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Regular Folder')).toBeInTheDocument();
+      });
+
+      const folderElements = screen.getAllByText('Regular Folder');
+      await user.click(folderElements[0]);
+
+      // External Folder label should NOT appear
+      await waitFor(() => {
+        expect(screen.queryByText('External Folder')).not.toBeInTheDocument();
+      });
+    });
+  });
+});

+ 143 - 3
frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx

@@ -1,8 +1,9 @@
 /**
 /**
  * Tests for SpoolBuddySettingsPage:
  * Tests for SpoolBuddySettingsPage:
- * - Renders 4 tabs (Device, Display, Scale, Updates)
+ * - Renders 5 tabs (Device, Display, Scale, Updates, System)
  * - Device tab shows hostname, IP, NFC status
  * - Device tab shows hostname, IP, NFC status
  * - Updates tab shows "Check for Updates" button
  * - Updates tab shows "Check for Updates" button
+ * - System tab shows OS stats
  * - Tab switching works
  * - Tab switching works
  */
  */
 
 
@@ -39,6 +40,15 @@ vi.mock('../../api/client', () => ({
       uptime_s: 3600,
       uptime_s: 3600,
       update_status: null,
       update_status: null,
       update_message: null,
       update_message: null,
+      system_stats: {
+        os: { os: 'Raspbian GNU/Linux 12', kernel: '6.1.0-rpi7', arch: 'aarch64', python: '3.11.2' },
+        cpu_temp_c: 52.1,
+        cpu_count: 4,
+        load_avg: [0.15, 0.22, 0.18],
+        memory: { total_mb: 1024, available_mb: 512, used_mb: 512, percent: 50.0 },
+        disk: { total_gb: 29.7, used_gb: 8.2, free_gb: 21.5, percent: 27.6 },
+        system_uptime_s: 86400,
+      },
       online: true,
       online: true,
     }]),
     }]),
     updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
     updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
@@ -50,6 +60,7 @@ vi.mock('../../api/client', () => ({
       update_available: false,
       update_available: false,
     }),
     }),
     triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),
     triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),
+    getSSHPublicKey: vi.fn().mockResolvedValue({ public_key: 'ssh-ed25519 AAAA test-key' }),
   },
   },
 }));
 }));
 
 
@@ -105,13 +116,14 @@ describe('SpoolBuddySettingsPage', () => {
     vi.clearAllMocks();
     vi.clearAllMocks();
   });
   });
 
 
-  it('renders 4 tabs', async () => {
+  it('renders 5 tabs', async () => {
     renderPage();
     renderPage();
     await waitFor(() => {
     await waitFor(() => {
       expect(screen.getByText('Device')).toBeDefined();
       expect(screen.getByText('Device')).toBeDefined();
       expect(screen.getByText('Display')).toBeDefined();
       expect(screen.getByText('Display')).toBeDefined();
       expect(screen.getByText('Scale')).toBeDefined();
       expect(screen.getByText('Scale')).toBeDefined();
       expect(screen.getByText('Updates')).toBeDefined();
       expect(screen.getByText('Updates')).toBeDefined();
+      expect(screen.getByText('System')).toBeDefined();
     });
     });
   });
   });
 
 
@@ -137,7 +149,7 @@ describe('SpoolBuddySettingsPage', () => {
     });
     });
   });
   });
 
 
-  it('switching to Updates tab shows Check for Updates button', async () => {
+  it('switching to Updates tab shows Check for Updates and Force Update buttons', async () => {
     renderPage();
     renderPage();
     await waitFor(() => {
     await waitFor(() => {
       expect(screen.getByText('Updates')).toBeDefined();
       expect(screen.getByText('Updates')).toBeDefined();
@@ -145,6 +157,46 @@ describe('SpoolBuddySettingsPage', () => {
     fireEvent.click(screen.getByText('Updates'));
     fireEvent.click(screen.getByText('Updates'));
     await waitFor(() => {
     await waitFor(() => {
       expect(screen.getByText('Check for Updates')).toBeDefined();
       expect(screen.getByText('Check for Updates')).toBeDefined();
+      expect(screen.getByText('Force Update')).toBeDefined();
+    });
+  });
+
+  it('Updates tab shows current version', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Updates'));
+    await waitFor(() => {
+      expect(screen.getByText('1.2.3')).toBeDefined();
+    });
+  });
+
+  it('Updates tab shows SSH Setup section', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Updates'));
+    await waitFor(() => {
+      expect(screen.getByText('SSH Setup')).toBeDefined();
+    });
+  });
+
+  it('Updates tab shows Apply Update when update is available', async () => {
+    const { spoolbuddyApi } = await import('../../api/client');
+    vi.mocked(spoolbuddyApi.checkDaemonUpdate).mockResolvedValue({
+      current_version: '1.2.3',
+      latest_version: '1.3.0',
+      update_available: true,
+    });
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Updates'));
+    await waitFor(() => {
+      expect(screen.getByText('Apply Update')).toBeDefined();
     });
     });
   });
   });
 
 
@@ -170,4 +222,92 @@ describe('SpoolBuddySettingsPage', () => {
       expect(screen.getByText('Calibrate')).toBeDefined();
       expect(screen.getByText('Calibrate')).toBeDefined();
     });
     });
   });
   });
+
+  it('System tab shows CPU info', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('CPU')).toBeDefined();
+      expect(screen.getByText('4')).toBeDefined(); // cpu_count
+      expect(screen.getByText('0.15 / 0.22 / 0.18')).toBeDefined(); // load_avg
+    });
+  });
+
+  it('System tab shows memory stats', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Memory')).toBeDefined();
+      expect(screen.getByText('512 / 1024 MB')).toBeDefined();
+    });
+  });
+
+  it('System tab shows disk stats', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Disk')).toBeDefined();
+      expect(screen.getByText('8.2 / 29.7 GB')).toBeDefined();
+    });
+  });
+
+  it('System tab shows OS info', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Raspbian GNU/Linux 12')).toBeDefined();
+      expect(screen.getByText('aarch64')).toBeDefined();
+      expect(screen.getByText('3.11.2')).toBeDefined();
+    });
+  });
+
+  it('System tab shows waiting message when no stats', async () => {
+    const { spoolbuddyApi } = await import('../../api/client');
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([{
+      id: 1,
+      device_id: 'sb-test-001',
+      hostname: 'spoolbuddy-pi',
+      ip_address: '192.168.1.100',
+      firmware_version: '1.2.3',
+      has_nfc: true,
+      has_scale: true,
+      tare_offset: 0,
+      calibration_factor: 1.0,
+      nfc_reader_type: 'PN532',
+      nfc_connection: 'I2C',
+      display_brightness: 80,
+      display_blank_timeout: 300,
+      has_backlight: true,
+      last_calibrated_at: null,
+      last_seen: '2026-03-22T12:00:00Z',
+      pending_command: null,
+      nfc_ok: true,
+      scale_ok: true,
+      uptime_s: 3600,
+      update_status: null,
+      update_message: null,
+      system_stats: null,
+      online: true,
+    }]);
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Waiting for system stats...')).toBeDefined();
+    });
+  });
 });
 });

+ 5 - 1
frontend/src/__tests__/utils/currency.test.ts

@@ -26,6 +26,10 @@ describe('getCurrencySymbol', () => {
     expect(getCurrencySymbol('MYR')).toBe('RM');
     expect(getCurrencySymbol('MYR')).toBe('RM');
   });
   });
 
 
+  it('returns ₴ for UAH', () => {
+    expect(getCurrencySymbol('UAH')).toBe('₴');
+  });
+
   it('returns the code itself for unknown currencies', () => {
   it('returns the code itself for unknown currencies', () => {
     expect(getCurrencySymbol('XYZ')).toBe('XYZ');
     expect(getCurrencySymbol('XYZ')).toBe('XYZ');
   });
   });
@@ -46,6 +50,6 @@ describe('SUPPORTED_CURRENCIES', () => {
   });
   });
 
 
   it('has 28 entries', () => {
   it('has 28 entries', () => {
-    expect(SUPPORTED_CURRENCIES).toHaveLength(28);
+    expect(SUPPORTED_CURRENCIES).toHaveLength(29);
   });
   });
 });
 });

+ 66 - 3
frontend/src/api/client.ts

@@ -129,6 +129,7 @@ export interface AMSTray {
   nozzle_temp_max: number | null;  // Max nozzle temperature
   nozzle_temp_max: number | null;  // Max nozzle temperature
   drying_temp: number | null;      // RFID-recommended drying temp
   drying_temp: number | null;      // RFID-recommended drying temp
   drying_time: number | null;      // RFID-recommended drying time (hours)
   drying_time: number | null;      // RFID-recommended drying time (hours)
+  state: number | null;            // AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
 }
 }
 
 
 export interface AMSUnit {
 export interface AMSUnit {
@@ -1073,6 +1074,7 @@ export interface SmartPlug {
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
   auto_off: boolean;
   auto_off: boolean;
+  auto_off_persistent: boolean;
   off_delay_mode: 'time' | 'temperature';
   off_delay_mode: 'time' | 'temperature';
   off_delay_minutes: number;
   off_delay_minutes: number;
   off_temp_threshold: number;
   off_temp_threshold: number;
@@ -1127,6 +1129,7 @@ export interface SmartPlugCreate {
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
   auto_off?: boolean;
+  auto_off_persistent?: boolean;
   off_delay_mode?: 'time' | 'temperature';
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_delay_minutes?: number;
   off_temp_threshold?: number;
   off_temp_threshold?: number;
@@ -1173,6 +1176,7 @@ export interface SmartPlugUpdate {
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
   auto_off?: boolean;
+  auto_off_persistent?: boolean;
   off_delay_mode?: 'time' | 'temperature';
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_delay_minutes?: number;
   off_temp_threshold?: number;
   off_temp_threshold?: number;
@@ -1469,6 +1473,7 @@ export interface NotificationProvider {
   on_print_failed: boolean;
   on_print_failed: boolean;
   on_print_stopped: boolean;
   on_print_stopped: boolean;
   on_print_progress: boolean;
   on_print_progress: boolean;
+  on_print_missing_spool_assignment: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline: boolean;
   on_printer_offline: boolean;
   on_printer_error: boolean;
   on_printer_error: boolean;
@@ -1523,6 +1528,7 @@ export interface NotificationProviderCreate {
   on_print_failed?: boolean;
   on_print_failed?: boolean;
   on_print_stopped?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_progress?: boolean;
+  on_print_missing_spool_assignment?: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
@@ -1570,6 +1576,7 @@ export interface NotificationProviderUpdate {
   on_print_failed?: boolean;
   on_print_failed?: boolean;
   on_print_stopped?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_progress?: boolean;
+  on_print_missing_spool_assignment?: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
@@ -4052,6 +4059,15 @@ export const api = {
     }),
     }),
   deleteLibraryFolder: (id: number) =>
   deleteLibraryFolder: (id: number) =>
     request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }),
     request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }),
+  createExternalFolder: (data: ExternalFolderCreate) =>
+    request<LibraryFolder>('/library/folders/external', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  scanExternalFolder: (folderId: number) =>
+    request<{ status: string; added: number; removed: number }>(`/library/folders/${folderId}/scan`, {
+      method: 'POST',
+    }),
   getLibraryFoldersByProject: (projectId: number) =>
   getLibraryFoldersByProject: (projectId: number) =>
     request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
     request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
   getLibraryFoldersByArchive: (archiveId: number) =>
   getLibraryFoldersByArchive: (archiveId: number) =>
@@ -4436,6 +4452,9 @@ export interface LibraryFolderTree {
   archive_id: number | null;
   archive_id: number | null;
   project_name: string | null;
   project_name: string | null;
   archive_name: string | null;
   archive_name: string | null;
+  is_external: boolean;
+  external_path: string | null;
+  external_readonly: boolean;
   file_count: number;
   file_count: number;
   children: LibraryFolderTree[];
   children: LibraryFolderTree[];
 }
 }
@@ -4448,6 +4467,10 @@ export interface LibraryFolder {
   archive_id: number | null;
   archive_id: number | null;
   project_name: string | null;
   project_name: string | null;
   archive_name: string | null;
   archive_name: string | null;
+  is_external: boolean;
+  external_path: string | null;
+  external_readonly: boolean;
+  external_show_hidden: boolean;
   file_count: number;
   file_count: number;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
@@ -4460,6 +4483,14 @@ export interface LibraryFolderCreate {
   archive_id?: number | null;
   archive_id?: number | null;
 }
 }
 
 
+export interface ExternalFolderCreate {
+  name: string;
+  external_path: string;
+  readonly?: boolean;
+  show_hidden?: boolean;
+  parent_id?: number | null;
+}
+
 export interface LibraryFolderUpdate {
 export interface LibraryFolderUpdate {
   name?: string;
   name?: string;
   parent_id?: number | null;
   parent_id?: number | null;
@@ -4481,6 +4512,7 @@ export interface LibraryFile {
   folder_name: string | null;
   folder_name: string | null;
   project_id: number | null;
   project_id: number | null;
   project_name: string | null;
   project_name: string | null;
+  is_external: boolean;
   filename: string;
   filename: string;
   file_path: string;
   file_path: string;
   file_type: string;
   file_type: string;
@@ -4508,6 +4540,7 @@ export interface LibraryFile {
 export interface LibraryFileListItem {
 export interface LibraryFileListItem {
   id: number;
   id: number;
   folder_id: number | null;
   folder_id: number | null;
+  is_external: boolean;
   filename: string;
   filename: string;
   file_type: string;
   file_type: string;
   file_size: number;
   file_size: number;
@@ -4967,6 +5000,7 @@ export interface SpoolBuddyDevice {
   device_id: string;
   device_id: string;
   hostname: string;
   hostname: string;
   ip_address: string;
   ip_address: string;
+  backend_url?: string | null;
   firmware_version: string | null;
   firmware_version: string | null;
   has_nfc: boolean;
   has_nfc: boolean;
   has_scale: boolean;
   has_scale: boolean;
@@ -4985,6 +5019,15 @@ export interface SpoolBuddyDevice {
   uptime_s: number;
   uptime_s: number;
   update_status: string | null;
   update_status: string | null;
   update_message: string | null;
   update_message: string | null;
+  system_stats: {
+    os?: { os?: string; kernel?: string; arch?: string; python?: string };
+    cpu_temp_c?: number;
+    cpu_count?: number;
+    load_avg?: number[];
+    memory?: { total_mb?: number; available_mb?: number; used_mb?: number; percent?: number };
+    disk?: { total_gb?: number; used_gb?: number; free_gb?: number; percent?: number };
+    system_uptime_s?: number;
+  } | null;
   online: boolean;
   online: boolean;
 }
 }
 
 
@@ -4992,7 +5035,6 @@ export interface DaemonUpdateCheck {
   current_version: string;
   current_version: string;
   latest_version: string | null;
   latest_version: string | null;
   update_available: boolean;
   update_available: boolean;
-  release_url: string | null;
 }
 }
 
 
 // SpoolBuddy API
 // SpoolBuddy API
@@ -5027,8 +5069,14 @@ export const spoolbuddyApi = {
       body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
       body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
     }),
     }),
 
 
-  checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
-    request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
+  updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) =>
+    request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, {
+      method: 'POST',
+      body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }),
+    }),
+
+  checkDaemonUpdate: (deviceId: string) =>
+    request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
 
 
   triggerUpdate: (deviceId: string) =>
   triggerUpdate: (deviceId: string) =>
     request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/update`, {
     request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/update`, {
@@ -5036,6 +5084,9 @@ export const spoolbuddyApi = {
       body: '{}',
       body: '{}',
     }),
     }),
 
 
+  getSSHPublicKey: () =>
+    request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
+
   writeTag: (deviceId: string, spoolId: number) =>
   writeTag: (deviceId: string, spoolId: number) =>
     request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
     request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
       method: 'POST',
       method: 'POST',
@@ -5047,6 +5098,18 @@ export const spoolbuddyApi = {
       method: 'POST',
       method: 'POST',
       body: '{}',
       body: '{}',
     }),
     }),
+
+  queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
+    request<{ status: string; diagnostic: string; message: string }>(
+      `/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,
+      { method: 'POST', body: '{}' }
+    ),
+
+  getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
+    request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>(
+      `/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`,
+      { method: 'GET' }
+    ),
 };
 };
 
 
 export interface BugReportRequest {
 export interface BugReportRequest {

+ 1 - 0
frontend/src/components/AssignSpoolModal.tsx

@@ -256,6 +256,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                   <button
                   <button
                     key={spool.id}
                     key={spool.id}
                     onClick={() => setSelectedSpoolId(spool.id)}
                     onClick={() => setSelectedSpoolId(spool.id)}
+                    title={spool.note || undefined}
                     className={`p-2.5 rounded-lg border text-left transition-colors ${
                     className={`p-2.5 rounded-lg border text-left transition-colors ${
                       selectedSpoolId === spool.id
                       selectedSpoolId === spool.id
                         ? 'bg-bambu-green/20 border-bambu-green'
                         ? 'bg-bambu-green/20 border-bambu-green'

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

@@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_first_layer_complete && (
             {provider.on_first_layer_complete && (
               <span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
               <span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
             )}
             )}
+            {provider.on_print_missing_spool_assignment && (
+              <span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
+            )}
             {provider.quiet_hours_enabled && (
             {provider.quiet_hours_enabled && (
               <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
               <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
                 <Moon className="w-3 h-3" />
                 <Moon className="w-3 h-3" />
@@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                   />
                 </div>
                 </div>
 
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.missingSpoolAssignmentDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_print_missing_spool_assignment ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.printFailed')}</p>
                   <p className="text-sm text-white">{t('notifications.printFailed')}</p>
                   <Toggle
                   <Toggle

+ 19 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -361,6 +361,25 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </div>
 
 
+              {/* Auto Off Persistent */}
+              {plug.auto_off && (
+                <div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <div>
+                    <p className="text-sm text-white">{t('smartPlugs.autoOffPersistent')}</p>
+                    <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffPersistentDescription')}</p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={plug.auto_off_persistent}
+                      onChange={(e) => updateMutation.mutate({ auto_off_persistent: e.target.checked })}
+                      className="sr-only peer"
+                    />
+                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
+
               {/* Delay Mode */}
               {/* Delay Mode */}
               {plug.auto_off && (
               {plug.auto_off && (
                 <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
                 <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">

+ 56 - 14
frontend/src/components/SpoolFormModal.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useState, useEffect, useMemo } from 'react';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Save, Beaker, Palette, Zap, Tag } from 'lucide-react';
+import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
   spool?: InventorySpool | null;
   spool?: InventorySpool | null;
   printersWithCalibrations?: PrinterWithCalibrations[];
   printersWithCalibrations?: PrinterWithCalibrations[];
   currencySymbol: string;
   currencySymbol: string;
+  onSpoolsCreated?: (spools: InventorySpool[]) => void;
 }
 }
 
 
-export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) {
+export function SpoolFormModal({
+  isOpen,
+  onClose,
+  spool,
+  printersWithCalibrations = [],
+  currencySymbol,
+  onSpoolsCreated,
+}: SpoolFormModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         await saveKProfiles(newSpool.id);
         await saveKProfiles(newSpool.id);
       }
       }
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       showToast(t('inventory.spoolCreated'), 'success');
       showToast(t('inventory.spoolCreated'), 'success');
       onClose();
       onClose();
     },
     },
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         }
         }
       }
       }
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated(newSpools);
       showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
       showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
       onClose();
       onClose();
     },
     },
@@ -362,7 +372,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
 
 
   const deleteTagMutation = useMutation({
   const deleteTagMutation = useMutation({
     mutationFn: () =>
     mutationFn: () =>
-      api.updateSpool(spool!.id, { tag_uid: null } as Parameters<typeof api.updateSpool>[1]),
+      api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
     onSuccess: async () => {
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
       showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
@@ -373,6 +383,29 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     },
     },
   });
   });
 
 
+  // Fetch assignment for this spool (to show Unassign button)
+  const { data: assignments } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+    enabled: isOpen && isEditing,
+  });
+  const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
+
+  const unassignMutation = useMutation({
+    mutationFn: () => {
+      if (!spoolAssignment) throw new Error('No assignment');
+      return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
+    },
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+      showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
   // Save K-profiles for selected calibrations
   // Save K-profiles for selected calibrations
   const saveKProfiles = async (spoolId: number) => {
   const saveKProfiles = async (spoolId: number) => {
     if (selectedProfiles.size === 0) {
     if (selectedProfiles.size === 0) {
@@ -477,7 +510,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     }
     }
   };
   };
 
 
-  const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending;
+  const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
 
 
   return (
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center">
     <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -636,15 +669,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         {/* Footer */}
         {/* Footer */}
         <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
         <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
           {isEditing && (
           {isEditing && (
-            <Button
-              variant="secondary"
-              onClick={() => deleteTagMutation.mutate()}
-              disabled={isPending || !spool?.tag_uid}
-              className="mr-auto"
-            >
-              <Tag className="w-4 h-4" />
-              {t('inventory.deleteTag', 'Delete Tag')}
-            </Button>
+            <div className="flex gap-2 mr-auto">
+              <Button
+                variant="secondary"
+                onClick={() => deleteTagMutation.mutate()}
+                disabled={isPending || !spool?.tag_uid}
+              >
+                <Tag className="w-4 h-4" />
+                {t('inventory.deleteTag', 'Delete Tag')}
+              </Button>
+              <Button
+                variant="secondary"
+                onClick={() => unassignMutation.mutate()}
+                disabled={isPending || !spoolAssignment}
+              >
+                <Unlink className="w-4 h-4" />
+                {t('inventory.unassignSpool', 'Unassign')}
+              </Button>
+            </div>
           )}
           )}
           <div className="flex gap-2 ml-auto">
           <div className="flex gap-2 ml-auto">
           <Button variant="secondary" onClick={onClose}>
           <Button variant="secondary" onClick={onClose}>

+ 11 - 5
frontend/src/components/VirtualKeyboard.tsx

@@ -3,7 +3,7 @@ import Keyboard from 'react-simple-keyboard';
 import 'react-simple-keyboard/build/css/index.css';
 import 'react-simple-keyboard/build/css/index.css';
 import './VirtualKeyboard.css';
 import './VirtualKeyboard.css';
 
 
-const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
+const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
 
 
 /**
 /**
  * Set value on a controlled React input using the native setter,
  * Set value on a controlled React input using the native setter,
@@ -17,7 +17,7 @@ function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: st
   input.dispatchEvent(new Event('input', { bubbles: true }));
   input.dispatchEvent(new Event('input', { bubbles: true }));
 }
 }
 
 
-export function VirtualKeyboard() {
+export function VirtualKeyboard({ onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void }) {
   const [visible, setVisible] = useState(false);
   const [visible, setVisible] = useState(false);
   const [closing, setClosing] = useState(false);
   const [closing, setClosing] = useState(false);
   const closingRef = useRef(false);
   const closingRef = useRef(false);
@@ -26,6 +26,11 @@ export function VirtualKeyboard() {
   const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
   const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
 
 
+  // Notify parent when keyboard visibility changes
+  useEffect(() => {
+    onVisibilityChange?.(visible);
+  }, [visible, onVisibilityChange]);
+
   const handleFocusIn = useCallback((e: FocusEvent) => {
   const handleFocusIn = useCallback((e: FocusEvent) => {
     if (closingRef.current) return;
     if (closingRef.current) return;
     const target = e.target as HTMLElement;
     const target = e.target as HTMLElement;
@@ -47,9 +52,10 @@ export function VirtualKeyboard() {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     (keyboardRef.current as any)?.setInput?.(activeInput.current.value);
     (keyboardRef.current as any)?.setInput?.(activeInput.current.value);
 
 
-    // Scroll input into view above the keyboard
+    // Scroll focused input into view after the keyboard renders and layout reflows
     setTimeout(() => {
     setTimeout(() => {
-      target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      const card = target.closest('.bg-zinc-800, .rounded-lg, [data-vkb-group]') as HTMLElement | null;
+      (card ?? target).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
     }, 100);
     }, 100);
   }, []);
   }, []);
 
 
@@ -142,7 +148,7 @@ export function VirtualKeyboard() {
       {!closing && (
       {!closing && (
       <div
       <div
         ref={containerRef}
         ref={containerRef}
-        className="fixed bottom-0 left-0 right-0 z-[9999]"
+        className="relative z-[9999] shrink-0"
         onMouseDown={(e) => e.preventDefault()}
         onMouseDown={(e) => e.preventDefault()}
         onTouchStart={(e) => {
         onTouchStart={(e) => {
           // Prevent focus loss but allow button interaction
           // Prevent focus loss but allow button interaction

+ 176 - 4
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
 import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
 import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
 import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
+import { ConfirmModal } from '../ConfirmModal';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
 import { getFillBarColor } from '../../utils/amsHelpers';
 import { getFillBarColor } from '../../utils/amsHelpers';
@@ -22,6 +23,34 @@ function trayColorToCSS(color: string | null): string {
   return `#${color.slice(0, 6)}`;
   return `#${color.slice(0, 6)}`;
 }
 }
 
 
+// --- Material/profile mismatch helpers (pure functions, no component state) ---
+const normalizeValue = (value: string | undefined | null) =>
+  (value ?? '').trim().toUpperCase();
+
+function checkMaterialMatch(
+  spoolMaterial: string | undefined | null,
+  trayMaterial: string | undefined | null
+): 'exact' | 'partial' | 'none' {
+  const normalizedSpool = normalizeValue(spoolMaterial);
+  const normalizedTray = normalizeValue(trayMaterial);
+  if (!normalizedSpool || !normalizedTray) return 'none';
+  if (normalizedSpool === normalizedTray) return 'exact';
+  if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
+    return 'partial';
+  }
+  return 'none';
+}
+
+function checkProfileMatch(
+  spoolProfile: string | undefined | null,
+  trayProfile: string | undefined | null
+): boolean {
+  const normalizedSpoolProfile = normalizeValue(spoolProfile);
+  const normalizedTrayProfile = normalizeValue(trayProfile);
+  if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
+  return normalizedSpoolProfile === normalizedTrayProfile;
+}
+
 interface AssignToAmsModalProps {
 interface AssignToAmsModalProps {
   isOpen: boolean;
   isOpen: boolean;
   onClose: () => void;
   onClose: () => void;
@@ -34,11 +63,24 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [statusMessage, setStatusMessage] = useState<string | null>(null);
   const [statusMessage, setStatusMessage] = useState<string | null>(null);
   const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
   const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
+  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
+  const [mismatchDetails, setMismatchDetails] = useState<{
+    type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';
+    spoolMaterial: string;
+    trayMaterial: string;
+    spoolProfile?: string;
+    trayProfile?: string;
+    location: string;
+  } | null>(null);
+  const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpen) {
     if (isOpen) {
       setStatusMessage(null);
       setStatusMessage(null);
       setStatusType(null);
       setStatusType(null);
+      setShowMismatchConfirm(false);
+      setMismatchDetails(null);
+      setPendingSlot(null);
     }
     }
   }, [isOpen]);
   }, [isOpen]);
 
 
@@ -155,12 +197,78 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
 
 
   const isWaiting = configureMutation.isPending;
   const isWaiting = configureMutation.isPending;
 
 
-  const handleSlotClick = useCallback((amsId: number, trayId: number) => {
-    if (isWaiting) return;
+  const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
+    if (amsId === 254 || amsId === 255) {
+      const extTrayId = amsId === 254 ? 254 : 254 + trayId;
+      return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
+    }
+    const unit = amsUnits.find(u => u.id === amsId);
+    return unit?.tray?.find(t => t.id === trayId) || null;
+  }, [amsUnits, vtTrays]);
+
+  const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
+    if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
+    if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
+    if (amsId === 254) return t('printers.extL', 'Ext-L');
+    return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext');
+  }, [t, isDualNozzle]);
+
+  const doAssign = useCallback((amsId: number, trayId: number) => {
     setStatusType('info');
     setStatusType('info');
     setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
     setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
     configureMutation.mutate({ amsId, trayId });
     configureMutation.mutate({ amsId, trayId });
-  }, [isWaiting, configureMutation, t]);
+  }, [configureMutation, t]);
+
+  const handleSlotClick = useCallback((amsId: number, trayId: number) => {
+    if (isWaiting) return;
+
+    if (!settings?.disable_filament_warnings) {
+      const tray = getTrayForSlot(amsId, trayId);
+      if (tray && !isTrayEmpty(tray)) {
+        const trayMaterial = tray.tray_sub_brands || tray.tray_type || '';
+        const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);
+        const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;
+        const trayProfile = tray.tray_type || '';
+        const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
+
+        if (materialMatchResult !== 'exact' || !profileMatches) {
+          let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';
+          if (materialMatchResult === 'none' && !profileMatches) {
+            mismatchType = 'material_profile';
+          } else if (materialMatchResult === 'partial' && !profileMatches) {
+            mismatchType = 'partial_profile';
+          } else if (materialMatchResult === 'none') {
+            mismatchType = 'material';
+          } else if (materialMatchResult === 'partial') {
+            mismatchType = 'partial';
+          }
+
+          const location = getSlotLocationLabel(amsId, trayId);
+          setPendingSlot({ amsId, trayId });
+          setMismatchDetails({
+            type: mismatchType,
+            spoolMaterial: spool.material || '',
+            trayMaterial: trayMaterial || '',
+            spoolProfile: spoolProfile || undefined,
+            trayProfile: trayProfile || undefined,
+            location,
+          });
+          setShowMismatchConfirm(true);
+          return;
+        }
+      }
+    }
+
+    doAssign(amsId, trayId);
+  }, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
+
+  const handleConfirmMismatch = useCallback(() => {
+    if (!pendingSlot) return;
+    setShowMismatchConfirm(false);
+    setMismatchDetails(null);
+    doAssign(pendingSlot.amsId, pendingSlot.trayId);
+    setPendingSlot(null);
+  }, [pendingSlot, doAssign]);
 
 
   // Build single-slot items (HT + External)
   // Build single-slot items (HT + External)
   const singleSlots = useMemo(() => {
   const singleSlots = useMemo(() => {
@@ -213,6 +321,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
   const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
   const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
 
 
   return (
   return (
+    <>
     <div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
     <div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
       {/* Header */}
       {/* Header */}
       <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
       <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
@@ -255,7 +364,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
       )}
       )}
 
 
       {/* AMS slots */}
       {/* AMS slots */}
-      <div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
+      <div className="flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto">
         {!isConnected && printerId ? (
         {!isConnected && printerId ? (
           <div className="flex-1 flex items-center justify-center">
           <div className="flex-1 flex items-center justify-center">
             <div className="text-center text-white/50">
             <div className="text-center text-white/50">
@@ -358,5 +467,68 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
         </button>
         </button>
       </div>
       </div>
     </div>
     </div>
+
+    {showMismatchConfirm && mismatchDetails && (() => {
+      let message = '';
+
+      if (mismatchDetails.type === 'material') {
+        message = t('inventory.assignMismatchMessage', {
+          spoolMaterial: mismatchDetails.spoolMaterial,
+          trayMaterial: mismatchDetails.trayMaterial,
+          location: mismatchDetails.location,
+        });
+      } else if (mismatchDetails.type === 'partial') {
+        message = t('inventory.assignPartialMismatchMessage', {
+          spoolMaterial: mismatchDetails.spoolMaterial,
+          trayMaterial: mismatchDetails.trayMaterial,
+          location: mismatchDetails.location,
+        });
+      } else if (mismatchDetails.type === 'material_profile') {
+        message = `${t('inventory.assignMismatchMessage', {
+          spoolMaterial: mismatchDetails.spoolMaterial,
+          trayMaterial: mismatchDetails.trayMaterial,
+          location: mismatchDetails.location,
+        })}\n\n${t('inventory.assignProfileMismatchMessage', {
+          spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+          trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+          location: mismatchDetails.location,
+        })}`;
+      } else if (mismatchDetails.type === 'partial_profile') {
+        message = `${t('inventory.assignPartialMismatchMessage', {
+          spoolMaterial: mismatchDetails.spoolMaterial,
+          trayMaterial: mismatchDetails.trayMaterial,
+          location: mismatchDetails.location,
+        })}\n\n${t('inventory.assignProfileMismatchMessage', {
+          spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+          trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+          location: mismatchDetails.location,
+        })}`;
+      } else if (mismatchDetails.type === 'profile') {
+        message = t('inventory.assignProfileMismatchMessage', {
+          spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+          trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+          location: mismatchDetails.location,
+        });
+      }
+
+      return (
+        <ConfirmModal
+          title={t('inventory.assignMismatchTitle')}
+          message={message}
+          confirmText={t('inventory.assignMismatchConfirm')}
+          variant="warning"
+          isLoading={configureMutation.isPending}
+          onConfirm={handleConfirmMismatch}
+          onCancel={() => {
+            if (!configureMutation.isPending) {
+              setShowMismatchConfirm(false);
+              setPendingSlot(null);
+              setMismatchDetails(null);
+            }
+          }}
+        />
+      );
+    })()}
+    </>
   );
   );
 }
 }

+ 172 - 0
frontend/src/components/spoolbuddy/DiagnosticModal.tsx

@@ -0,0 +1,172 @@
+import { useState, useEffect, useCallback } from 'react';
+import { X, Play, RotateCw } from 'lucide-react';
+import { spoolbuddyApi } from '../../api/client';
+import { useTranslation } from 'react-i18next';
+
+interface DiagnosticModalProps {
+  type: 'scale' | 'nfc' | 'read_tag';
+  deviceId: string;
+  onClose: () => void;
+}
+
+export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
+  const { t } = useTranslation();
+  const [isRunning, setIsRunning] = useState(false);
+  const [output, setOutput] = useState<string>('');
+  const [error, setError] = useState<string>('');
+  const [hasRun, setHasRun] = useState(false);
+
+  // Close on Escape
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isRunning) {
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [isRunning, onClose]);
+
+  const runDiagnostic = useCallback(async () => {
+    setIsRunning(true);
+    setOutput('');
+    setError('');
+    setHasRun(true);
+
+    try {
+      // Step 1: Queue the diagnostic on the device
+      setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
+      await spoolbuddyApi.queueDiagnostics(deviceId, type);
+
+      // Step 2: Poll for results with timeout
+      let result = null;
+      const maxRetries = 60; // 30s timeout with 500ms polling
+      let retryCount = 0;
+
+      while (retryCount < maxRetries && !result) {
+        // Wait a bit before polling
+        await new Promise(resolve => setTimeout(resolve, 500));
+
+        try {
+          result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
+          break;
+        } catch {
+          // Not ready yet, continue polling
+          retryCount++;
+          if (retryCount % 4 === 0) {
+            // Update every 2 seconds (after 4 retries of 500ms)
+            setOutput(prev => prev + '.');
+          }
+        }
+      }
+
+      if (!result) {
+        throw new Error('Diagnostic timed out - device did not report results');
+      }
+
+      setOutput(result.output);
+      if (!result.success) {
+        setError(`Exit code: ${result.exit_code}`);
+      }
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Unknown error');
+      setOutput('');
+    } finally {
+      setIsRunning(false);
+    }
+  }, [type, deviceId, t]);
+
+  const title = type === 'scale'
+    ? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
+    : type === 'read_tag'
+      ? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
+      : t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
+      onClick={onClose}
+    >
+      <div
+        className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex justify-between items-center p-4 border-b border-zinc-700">
+          <h2 className="text-lg font-semibold text-white">{title}</h2>
+          <button
+            onClick={onClose}
+            className="text-zinc-400 hover:text-white transition-colors"
+            aria-label="Close"
+          >
+            <X size={20} />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
+          {isRunning ? (
+            <div className="flex items-center gap-2 text-green-400">
+              <div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
+              <span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
+            </div>
+          ) : output ? (
+            <>
+              <div className="text-green-400 whitespace-pre-wrap break-words">
+                {output}
+              </div>
+              {error && (
+                <div className="text-red-400 mt-2">
+                  ❌ {error}
+                </div>
+              )}
+            </>
+          ) : hasRun ? (
+            <div>
+              {error ? (
+                <div className="text-red-400">ERROR: {error}</div>
+              ) : (
+                <span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
+              )}
+            </div>
+                ) : (
+            <div className="text-zinc-500">
+              {t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
+          <button
+            onClick={runDiagnostic}
+            disabled={isRunning}
+            className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
+          >
+            {isRunning ? (
+              <>
+                <div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
+                {t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
+              </>
+            ) : hasRun ? (
+              <>
+                <RotateCw size={16} />
+                {t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
+              </>
+            ) : (
+              <>
+                <Play size={16} />
+                {t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
+              </>
+            )}
+          </button>
+          <button
+            onClick={onClose}
+            className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
+          >
+            {t('spoolbuddy.diagnostic.close', 'Close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 10 - 0
frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx

@@ -33,6 +33,16 @@ const navItems = [
       </svg>
       </svg>
     ),
     ),
   },
   },
+  {
+    to: '/spoolbuddy/inventory',
+    labelKey: 'spoolbuddy.nav.inventory',
+    fallback: 'Inventory',
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
+      </svg>
+    ),
+  },
   {
   {
     to: '/spoolbuddy/settings',
     to: '/spoolbuddy/settings',
     labelKey: 'spoolbuddy.nav.settings',
     labelKey: 'spoolbuddy.nav.settings',

+ 97 - 14
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -1,12 +1,12 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
-import { Outlet } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { Outlet, useNavigate, useLocation } from 'react-router-dom';
+import { useQuery, useQueries } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
-import { api, spoolbuddyApi } from '../../api/client';
+import { api, spoolbuddyApi, type Printer } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 
 
 export function SpoolBuddyLayout() {
 export function SpoolBuddyLayout() {
@@ -17,6 +17,8 @@ export function SpoolBuddyLayout() {
   const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
   const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
   const lastActivityRef = useRef(Date.now());
   const lastActivityRef = useRef(Date.now());
   const { i18n } = useTranslation();
   const { i18n } = useTranslation();
+  const navigate = useNavigate();
+  const location = useLocation();
   const sbState = useSpoolBuddyState();
   const sbState = useSpoolBuddyState();
 
 
   // Sync language from backend settings (kiosk has its own browser with empty localStorage)
   // Sync language from backend settings (kiosk has its own browser with empty localStorage)
@@ -37,6 +39,11 @@ export function SpoolBuddyLayout() {
     refetchInterval: 30000,
     refetchInterval: 30000,
   });
   });
   const device = devices[0];
   const device = devices[0];
+  const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
+  const sbStateForUi = useMemo(
+    () => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
+    [sbState, effectiveDeviceOnline]
+  );
 
 
   // Sync display settings from device on initial load
   // Sync display settings from device on initial load
   const initializedRef = useRef(false);
   const initializedRef = useRef(false);
@@ -61,22 +68,22 @@ export function SpoolBuddyLayout() {
   // Auto-check for SpoolBuddy daemon updates
   // Auto-check for SpoolBuddy daemon updates
   const { data: updateCheck } = useQuery({
   const { data: updateCheck } = useQuery({
     queryKey: ['spoolbuddy-update-check', device?.device_id],
     queryKey: ['spoolbuddy-update-check', device?.device_id],
-    queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id, true) : Promise.resolve(null),
+    queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id) : Promise.resolve(null),
     enabled: !!device,
     enabled: !!device,
     refetchInterval: 5 * 60 * 1000, // re-check every 5 minutes
     refetchInterval: 5 * 60 * 1000, // re-check every 5 minutes
-    staleTime: 4 * 60 * 1000,
+    staleTime: 0,
   });
   });
 
 
   // Update alert based on device state and available updates
   // Update alert based on device state and available updates
   useEffect(() => {
   useEffect(() => {
-    if (!sbState.deviceOnline) {
+    if (!effectiveDeviceOnline) {
       setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
       setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
     } else if (updateCheck?.update_available && updateCheck.latest_version) {
     } else if (updateCheck?.update_available && updateCheck.latest_version) {
       setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
       setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
     } else {
     } else {
       setAlert(null);
       setAlert(null);
     }
     }
-  }, [sbState.deviceOnline, updateCheck]);
+  }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
 
 
   // Track user activity for screen blank
   // Track user activity for screen blank
   const resetActivity = useCallback(() => {
   const resetActivity = useCallback(() => {
@@ -93,6 +100,19 @@ export function SpoolBuddyLayout() {
     };
     };
   }, [resetActivity]);
   }, [resetActivity]);
 
 
+  // Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag)
+  const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);
+  const prevTagDetected = useRef(false);
+  useEffect(() => {
+    if (tagDetected && !prevTagDetected.current) {
+      resetActivity();
+      if (location.pathname !== '/spoolbuddy') {
+        navigate('/spoolbuddy');
+      }
+    }
+    prevTagDetected.current = tagDetected;
+  }, [tagDetected, location.pathname, navigate, resetActivity]);
+
   // Screen blank timer
   // Screen blank timer
   useEffect(() => {
   useEffect(() => {
     if (displayBlankTimeout <= 0) return;
     if (displayBlankTimeout <= 0) return;
@@ -104,6 +124,65 @@ export function SpoolBuddyLayout() {
     return () => clearInterval(interval);
     return () => clearInterval(interval);
   }, [displayBlankTimeout]);
   }, [displayBlankTimeout]);
 
 
+  // Online printers list for swipe-to-switch
+  const { data: printers = [] } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+  const statusQueries = useQueries({
+    queries: printers.map((printer: Printer) => ({
+      queryKey: ['printerStatus', printer.id],
+      queryFn: () => api.getPrinterStatus(printer.id),
+      refetchInterval: 10000,
+    })),
+  });
+  const onlinePrinters = useMemo(() => {
+    return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);
+  }, [printers, statusQueries]);
+
+  // Swipe left/right to cycle through online printers
+  const touchStartRef = useRef<{ x: number; y: number } | null>(null);
+  const swipeLockedRef = useRef(false);
+  const SWIPE_THRESHOLD = 50;
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent) => {
+    touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
+    swipeLockedRef.current = false;
+  }, []);
+  const handleTouchEnd = useCallback((e: React.TouchEvent) => {
+    if (!touchStartRef.current || onlinePrinters.length < 2) return;
+    const dx = e.changedTouches[0].clientX - touchStartRef.current.x;
+    const dy = e.changedTouches[0].clientY - touchStartRef.current.y;
+    touchStartRef.current = null;
+    swipeLockedRef.current = false;
+    if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
+    const currentIdx = onlinePrinters.findIndex((p: Printer) => p.id === selectedPrinterId);
+    const nextIdx = dx < 0
+      ? (currentIdx + 1) % onlinePrinters.length          // swipe left → next
+      : (currentIdx - 1 + onlinePrinters.length) % onlinePrinters.length; // swipe right → prev
+    setSelectedPrinterId(onlinePrinters[nextIdx].id);
+  }, [onlinePrinters, selectedPrinterId, setSelectedPrinterId]);
+
+  // Block browser back/forward swipe gesture with non-passive touchmove listener
+  useEffect(() => {
+    const el = rootRef.current;
+    if (!el) return;
+    const onTouchMove = (e: TouchEvent) => {
+      if (!touchStartRef.current) return;
+      const dx = Math.abs(e.touches[0].clientX - touchStartRef.current.x);
+      const dy = Math.abs(e.touches[0].clientY - touchStartRef.current.y);
+      // Once locked as horizontal, prevent default for the rest of this gesture
+      if (swipeLockedRef.current) { e.preventDefault(); return; }
+      if (dx > 10 && dx > dy) { swipeLockedRef.current = true; e.preventDefault(); }
+    };
+    el.addEventListener('touchmove', onTouchMove, { passive: false });
+    return () => el.removeEventListener('touchmove', onTouchMove);
+  }, []);
+
+  // Track virtual keyboard visibility to hide bottom bars
+  const [keyboardVisible, setKeyboardVisible] = useState(false);
+
   // CSS brightness filter (software dimming)
   // CSS brightness filter (software dimming)
   const brightnessStyle = displayBrightness < 100
   const brightnessStyle = displayBrightness < 100
     ? { filter: `brightness(${displayBrightness / 100})` } as const
     ? { filter: `brightness(${displayBrightness / 100})` } as const
@@ -112,26 +191,30 @@ export function SpoolBuddyLayout() {
   return (
   return (
     <>
     <>
       <div
       <div
+        ref={rootRef}
+        data-spoolbuddy-kiosk
         className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden"
         className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden"
-        style={brightnessStyle}
+        style={{ ...brightnessStyle, overscrollBehaviorX: 'none' }}
+        onTouchStart={handleTouchStart}
+        onTouchEnd={handleTouchEnd}
       >
       >
         <SpoolBuddyTopBar
         <SpoolBuddyTopBar
           selectedPrinterId={selectedPrinterId}
           selectedPrinterId={selectedPrinterId}
           onPrinterChange={setSelectedPrinterId}
           onPrinterChange={setSelectedPrinterId}
-          deviceOnline={sbState.deviceOnline}
+          deviceOnline={effectiveDeviceOnline}
         />
         />
 
 
         <main className="flex-1 overflow-y-auto">
         <main className="flex-1 overflow-y-auto">
           <Outlet context={{
           <Outlet context={{
-            selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
+            selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
             displayBrightness, setDisplayBrightness,
             displayBrightness, setDisplayBrightness,
             displayBlankTimeout, setDisplayBlankTimeout,
             displayBlankTimeout, setDisplayBlankTimeout,
           }} />
           }} />
         </main>
         </main>
 
 
-        <SpoolBuddyStatusBar alert={alert} />
-        <SpoolBuddyBottomNav />
-        <VirtualKeyboard />
+        {!keyboardVisible && <SpoolBuddyStatusBar alert={alert} />}
+        {!keyboardVisible && <SpoolBuddyBottomNav />}
+        <VirtualKeyboard onVisibilityChange={setKeyboardVisible} />
       </div>
       </div>
 
 
       {/* Screen blank overlay — touch to wake */}
       {/* Screen blank overlay — touch to wake */}

+ 4 - 1
frontend/src/contexts/ToastContext.tsx

@@ -6,6 +6,8 @@ import { formatFileSize } from '../utils/file';
 
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
 
+type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;
+
 interface Toast {
 interface Toast {
   id: string;
   id: string;
   message: string;
   message: string;
@@ -38,7 +40,7 @@ interface DispatchToastData {
 
 
 interface ToastContextType {
 interface ToastContextType {
   showToast: (message: string, type?: ToastType) => void;
   showToast: (message: string, type?: ToastType) => void;
-  showPersistentToast: (id: string, message: string, type?: ToastType) => void;
+  showPersistentToast: ShowPersistentToast;
   dismissToast: (id: string) => void;
   dismissToast: (id: string) => void;
 }
 }
 
 
@@ -460,6 +462,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     return () => window.removeEventListener('background-dispatch', onDispatchEvent);
     return () => window.removeEventListener('background-dispatch', onDispatchEvent);
   }, [t]);
   }, [t]);
 
 
+
   return (
   return (
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
       {children}
       {children}

+ 51 - 1
frontend/src/hooks/useWebSocket.ts

@@ -1,10 +1,14 @@
 import { useQueryClient } from '@tanstack/react-query';
 import { useQueryClient } from '@tanstack/react-query';
 import { useCallback, useEffect, useRef, useState } from 'react';
 import { useCallback, useEffect, useRef, useState } from 'react';
+import { useToast } from '../contexts/ToastContext';
+import { useTranslation } from 'react-i18next';
 
 
 interface WebSocketMessage {
 interface WebSocketMessage {
   type: string;
   type: string;
   printer_id?: number;
   printer_id?: number;
   data?: Record<string, unknown>;
   data?: Record<string, unknown>;
+  printer_name?: string;
+  missing_slots?: Array<{ slot?: string }>;
 }
 }
 
 
 export function useWebSocket() {
 export function useWebSocket() {
@@ -12,6 +16,9 @@ export function useWebSocket() {
   const reconnectTimeoutRef = useRef<number | null>(null);
   const reconnectTimeoutRef = useRef<number | null>(null);
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [isConnected, setIsConnected] = useState(false);
   const [isConnected, setIsConnected] = useState(false);
+  const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
+  const { showToast } = useToast();
+  const { t } = useTranslation();
 
 
   // Debounce invalidations to prevent rapid re-render cascades
   // Debounce invalidations to prevent rapid re-render cascades
   const pendingInvalidations = useRef<Set<string>>(new Set());
   const pendingInvalidations = useRef<Set<string>>(new Set());
@@ -195,6 +202,35 @@ export function useWebSocket() {
         }
         }
         break;
         break;
 
 
+      case 'missing_spool_assignment': {
+        if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
+          break;
+        }
+
+        const missingSlotLabels = message.missing_slots
+          .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
+          .filter((slot) => slot.length > 0);
+
+        if (missingSlotLabels.length === 0) {
+          lastMissingSpoolWarningRef.current.delete(message.printer_id);
+          break;
+        }
+
+        const signature = missingSlotLabels.join('|');
+        if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
+          break;
+        }
+        lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
+
+        const printerName = message.printer_name || `Printer ${message.printer_id}`;
+        const toastMsg = t('printers.toast.missingSpoolAssignment', {
+          printer: printerName,
+          slots: missingSlotLabels.join(', '),
+        });
+        showToast(toastMsg, 'warning');
+        break;
+      }
+
       case 'print_complete':
       case 'print_complete':
         // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
         // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
         // The printer_status websocket messages will naturally update the status
         // The printer_status websocket messages will naturally update the status
@@ -227,6 +263,12 @@ export function useWebSocket() {
         }));
         }));
         break;
         break;
 
 
+      case 'spool_assignment_changed':
+        // Spool assigned/unassigned - refresh assignment data across all tabs
+        debouncedInvalidate('spool-assignments');
+        debouncedInvalidate('slotPresets');
+        break;
+
       case 'spool_auto_assigned':
       case 'spool_auto_assigned':
         // RFID tag matched - refresh inventory and assignment data
         // RFID tag matched - refresh inventory and assignment data
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('inventory-spools');
@@ -287,13 +329,21 @@ export function useWebSocket() {
 
 
       case 'spoolbuddy_online':
       case 'spoolbuddy_online':
         window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
         window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
+        debouncedInvalidate('spoolbuddy-devices');
+        debouncedInvalidate('spoolbuddy-update-check');
         break;
         break;
 
 
       case 'spoolbuddy_offline':
       case 'spoolbuddy_offline':
         window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
         window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
+        debouncedInvalidate('spoolbuddy-devices');
+        break;
+
+      case 'spoolbuddy_update':
+        debouncedInvalidate('spoolbuddy-devices');
+        debouncedInvalidate('spoolbuddy-update-check');
         break;
         break;
     }
     }
-  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
+  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
 
 
   // Keep the ref updated with latest handleMessage
   // Keep the ref updated with latest handleMessage
   useEffect(() => {
   useEffect(() => {

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: 'Drucker gelöscht',
       printerDeleted: 'Drucker gelöscht',
+      missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
       printerAdded: 'Drucker hinzugefügt',
       printerAdded: 'Drucker hinzugefügt',
       printerUpdated: 'Drucker aktualisiert',
       printerUpdated: 'Drucker aktualisiert',
       failedToDelete: 'Drucker konnte nicht gelöscht werden',
       failedToDelete: 'Drucker konnte nicht gelöscht werden',
@@ -2564,6 +2565,18 @@ export default {
     noPermissionUpload: 'Sie haben keine Berechtigung, Dateien hochzuladen',
     noPermissionUpload: 'Sie haben keine Berechtigung, Dateien hochzuladen',
     noPermissionMoveFiles: 'Sie haben keine Berechtigung, Dateien zu verschieben',
     noPermissionMoveFiles: 'Sie haben keine Berechtigung, Dateien zu verschieben',
     noPermissionDeleteFiles: 'Sie haben keine Berechtigung, Dateien zu löschen',
     noPermissionDeleteFiles: 'Sie haben keine Berechtigung, Dateien zu löschen',
+    // External folder
+    linkExternal: 'Extern verknüpfen',
+    linkExternalFolder: 'Externen Ordner verknüpfen',
+    linkExternalFolderDescription: 'Ein Host-Verzeichnis (NAS, USB, Netzlaufwerk) in den Dateimanager einbinden. Dateien werden nicht kopiert — sie werden direkt vom Originalpfad gelesen.',
+    externalFolderNamePlaceholder: 'z.B. NAS-Drucke',
+    externalPath: 'Host-Pfad',
+    externalPathHelp: 'Absoluter Pfad zum Verzeichnis auf dem Docker-Host. Muss als Bind-Mount in den Container eingebunden sein.',
+    readOnly: 'Nur Lesen',
+    readOnlyHelp: 'verhindert Uploads und Löschungen',
+    showHiddenFiles: 'Versteckte Dateien anzeigen (Punkt-Dateien)',
+    externalFolder: 'Externer Ordner',
+    scanFolder: 'Scannen',
     toast: {
     toast: {
       folderCreated: 'Ordner erstellt',
       folderCreated: 'Ordner erstellt',
       folderDeleted: 'Ordner gelöscht',
       folderDeleted: 'Ordner gelöscht',
@@ -2572,6 +2585,8 @@ export default {
       filesMoved: 'Dateien verschoben',
       filesMoved: 'Dateien verschoben',
       folderLinked: 'Ordner verknüpft',
       folderLinked: 'Ordner verknüpft',
       folderUnlinked: 'Ordnerverknüpfung aufgehoben',
       folderUnlinked: 'Ordnerverknüpfung aufgehoben',
+      externalFolderLinked: 'Externer Ordner verknüpft und gescannt',
+      folderScanned: 'Scan abgeschlossen: {{added}} hinzugefügt, {{removed}} entfernt',
       addedToQueue: '{{count}} Datei(en) zur Warteschlange hinzugefügt',
       addedToQueue: '{{count}} Datei(en) zur Warteschlange hinzugefügt',
       addedToQueuePartial: '{{added}} Datei(en) hinzugefügt, {{failed}} fehlgeschlagen',
       addedToQueuePartial: '{{added}} Datei(en) hinzugefügt, {{failed}} fehlgeschlagen',
       failedToAddToQueue: 'Fehler beim Hinzufügen: {{error}}',
       failedToAddToQueue: 'Fehler beim Hinzufügen: {{error}}',
@@ -3656,6 +3671,8 @@ export default {
     autoOnDescription: 'Einschalten wenn Druck startet',
     autoOnDescription: 'Einschalten wenn Druck startet',
     autoOff: 'Auto Aus',
     autoOff: 'Auto Aus',
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
+    autoOffPersistent: 'Aktiviert lassen',
+    autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
     turnOffDelayMode: 'Ausschaltverzögerungsmodus',
     turnOffDelayMode: 'Ausschaltverzögerungsmodus',
     time: 'Zeit',
     time: 'Zeit',
     temp: 'Temp',
     temp: 'Temp',
@@ -3807,6 +3824,8 @@ export default {
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
+    missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
+    missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
     printFailed: 'Druck fehlgeschlagen',
     printFailed: 'Druck fehlgeschlagen',
     printStopped: 'Druck gestoppt',
     printStopped: 'Druck gestoppt',
     progressMilestones: 'Fortschrittsmeilensteine',
     progressMilestones: 'Fortschrittsmeilensteine',
@@ -4445,6 +4464,19 @@ export default {
       deviceInfo: 'Geräteinfo',
       deviceInfo: 'Geräteinfo',
       hostname: 'Host',
       hostname: 'Host',
       uptime: 'Betriebszeit',
       uptime: 'Betriebszeit',
+      systemConfig: 'Backend & Auth',
+      backendUrl: 'Bambuddy Backend URL',
+      apiToken: 'API-Token',
+      apiTokenPlaceholder: 'API-Token eingeben',
+      saveConfig: 'Konfiguration speichern',
+      systemQueued: 'Konfiguration in Warteschlange.',
+      nfcDiagnostic: 'NFC-Diagnose',
+      scaleDiagnostic: 'Waagen-Diagnose',
+      readTagDiagnostic: 'Tag-Lese-Diagnose',
+      testNfc: 'Leser testen',
+      testScale: 'Genauigkeit testen',
+      testReadTag: 'Tag lesen',
+      systemFieldsRequired: 'Backend-URL ist erforderlich.',
       // Display tab
       // Display tab
       brightness: 'Helligkeit',
       brightness: 'Helligkeit',
       saved: 'Gespeichert',
       saved: 'Gespeichert',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: 'Printer deleted',
       printerDeleted: 'Printer deleted',
+      missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
       printerAdded: 'Printer added',
       printerAdded: 'Printer added',
       printerUpdated: 'Printer updated',
       printerUpdated: 'Printer updated',
       failedToDelete: 'Failed to delete printer',
       failedToDelete: 'Failed to delete printer',
@@ -2564,6 +2565,18 @@ export default {
     noPermissionUpload: 'You do not have permission to upload files',
     noPermissionUpload: 'You do not have permission to upload files',
     noPermissionMoveFiles: 'You do not have permission to move files',
     noPermissionMoveFiles: 'You do not have permission to move files',
     noPermissionDeleteFiles: 'You do not have permission to delete files',
     noPermissionDeleteFiles: 'You do not have permission to delete files',
+    // External folder
+    linkExternal: 'Link External',
+    linkExternalFolder: 'Link External Folder',
+    linkExternalFolderDescription: 'Mount a host directory (NAS, USB, network share) into the File Manager. Files are not copied — they are accessed directly from the original path.',
+    externalFolderNamePlaceholder: 'e.g., NAS Prints',
+    externalPath: 'Host Path',
+    externalPathHelp: 'Absolute path to the directory on the Docker host. Must be bind-mounted into the container.',
+    readOnly: 'Read Only',
+    readOnlyHelp: 'prevents uploads and deletions',
+    showHiddenFiles: 'Show hidden files (dotfiles)',
+    externalFolder: 'External Folder',
+    scanFolder: 'Scan',
     toast: {
     toast: {
       folderCreated: 'Folder created',
       folderCreated: 'Folder created',
       folderDeleted: 'Folder deleted',
       folderDeleted: 'Folder deleted',
@@ -2572,6 +2585,8 @@ export default {
       filesMoved: 'Files moved',
       filesMoved: 'Files moved',
       folderLinked: 'Folder linked',
       folderLinked: 'Folder linked',
       folderUnlinked: 'Folder unlinked',
       folderUnlinked: 'Folder unlinked',
+      externalFolderLinked: 'External folder linked and scanned',
+      folderScanned: 'Scan complete: {{added}} added, {{removed}} removed',
       addedToQueue: 'Added {{count}} file(s) to queue',
       addedToQueue: 'Added {{count}} file(s) to queue',
       addedToQueuePartial: 'Added {{added}} file(s), {{failed}} failed',
       addedToQueuePartial: 'Added {{added}} file(s), {{failed}} failed',
       failedToAddToQueue: 'Failed to add files: {{error}}',
       failedToAddToQueue: 'Failed to add files: {{error}}',
@@ -3661,6 +3676,8 @@ export default {
     autoOnDescription: 'Turn on when print starts',
     autoOnDescription: 'Turn on when print starts',
     autoOff: 'Auto Off',
     autoOff: 'Auto Off',
     autoOffDescription: 'Turn off when print completes (one-shot)',
     autoOffDescription: 'Turn off when print completes (one-shot)',
+    autoOffPersistent: 'Keep Enabled',
+    autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
     turnOffDelayMode: 'Turn Off Delay Mode',
     turnOffDelayMode: 'Turn Off Delay Mode',
     time: 'Time',
     time: 'Time',
     temp: 'Temp',
     temp: 'Temp',
@@ -3812,6 +3829,8 @@ export default {
     bedCooledDescription: 'Bed cooled below threshold after print',
     bedCooledDescription: 'Bed cooled below threshold after print',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
+    missingSpoolAssignmentLabel: 'Missing Spool Assignment',
+    missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
     printFailed: 'Print Failed',
     printFailed: 'Print Failed',
     printStopped: 'Print Stopped',
     printStopped: 'Print Stopped',
     progressMilestones: 'Progress Milestones',
     progressMilestones: 'Progress Milestones',
@@ -4450,6 +4469,19 @@ export default {
       deviceInfo: 'Device Info',
       deviceInfo: 'Device Info',
       hostname: 'Host',
       hostname: 'Host',
       uptime: 'Uptime',
       uptime: 'Uptime',
+      systemConfig: 'Backend & Auth',
+      backendUrl: 'Bambuddy Backend URL',
+      apiToken: 'API Token',
+      apiTokenPlaceholder: 'Enter API token',
+      saveConfig: 'Save Config',
+      systemQueued: 'Config queued.',
+      nfcDiagnostic: 'NFC Diagnostic',
+      scaleDiagnostic: 'Scale Diagnostic',
+      readTagDiagnostic: 'Read Tag Diagnostic',
+      testNfc: 'Test reader',
+      testScale: 'Test accuracy',
+      testReadTag: 'Read tag',
+      systemFieldsRequired: 'Backend URL is required.',
       // Display tab
       // Display tab
       brightness: 'Brightness',
       brightness: 'Brightness',
       saved: 'Saved',
       saved: 'Saved',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: 'Imprimante supprimée',
       printerDeleted: 'Imprimante supprimée',
+      missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
       printerAdded: 'Imprimante ajoutée',
       printerAdded: 'Imprimante ajoutée',
       printerUpdated: 'Imprimante mise à jour',
       printerUpdated: 'Imprimante mise à jour',
       failedToDelete: 'Échec de la suppression',
       failedToDelete: 'Échec de la suppression',
@@ -2551,6 +2552,18 @@ export default {
     noPermissionUpload: 'Pas d\'autorisation téléversement',
     noPermissionUpload: 'Pas d\'autorisation téléversement',
     noPermissionMoveFiles: 'Pas d\'autorisation déplacement',
     noPermissionMoveFiles: 'Pas d\'autorisation déplacement',
     noPermissionDeleteFiles: 'Pas d\'autorisation suppression groupée',
     noPermissionDeleteFiles: 'Pas d\'autorisation suppression groupée',
+    // External folder
+    linkExternal: 'Lier externe',
+    linkExternalFolder: 'Lier un dossier externe',
+    linkExternalFolderDescription: 'Monter un répertoire hôte (NAS, USB, partage réseau) dans le gestionnaire de fichiers. Les fichiers ne sont pas copiés — ils sont lus directement depuis le chemin d\'origine.',
+    externalFolderNamePlaceholder: 'ex. Impressions NAS',
+    externalPath: 'Chemin hôte',
+    externalPathHelp: 'Chemin absolu du répertoire sur l\'hôte Docker. Doit être monté en bind dans le conteneur.',
+    readOnly: 'Lecture seule',
+    readOnlyHelp: 'empêche les téléversements et suppressions',
+    showHiddenFiles: 'Afficher les fichiers cachés (fichiers point)',
+    externalFolder: 'Dossier externe',
+    scanFolder: 'Scanner',
     toast: {
     toast: {
       folderCreated: 'Dossier créé',
       folderCreated: 'Dossier créé',
       folderDeleted: 'Dossier supprimé',
       folderDeleted: 'Dossier supprimé',
@@ -2559,6 +2572,8 @@ export default {
       filesMoved: 'Fichiers déplacés',
       filesMoved: 'Fichiers déplacés',
       folderLinked: 'Dossier lié',
       folderLinked: 'Dossier lié',
       folderUnlinked: 'Dossier délié',
       folderUnlinked: 'Dossier délié',
+      externalFolderLinked: 'Dossier externe lié et scanné',
+      folderScanned: 'Scan terminé : {{added}} ajoutés, {{removed}} supprimés',
       addedToQueue: '{{count}} fichier(s) ajouté(s)',
       addedToQueue: '{{count}} fichier(s) ajouté(s)',
       addedToQueuePartial: '{{added}} ajoutés, {{failed}} échecs',
       addedToQueuePartial: '{{added}} ajoutés, {{failed}} échecs',
       failedToAddToQueue: 'Échec ajout file : {{error}}',
       failedToAddToQueue: 'Échec ajout file : {{error}}',
@@ -3648,6 +3663,8 @@ export default {
     autoOnDescription: 'Allumer au démarrage de l\'impression',
     autoOnDescription: 'Allumer au démarrage de l\'impression',
     autoOff: 'Auto Off',
     autoOff: 'Auto Off',
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
+    autoOffPersistent: 'Garder activé',
+    autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
     turnOffDelayMode: 'Mode de délai d\'extinction',
     turnOffDelayMode: 'Mode de délai d\'extinction',
     time: 'Temps',
     time: 'Temps',
     temp: 'Temp',
     temp: 'Temp',
@@ -3799,6 +3816,8 @@ export default {
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
+    missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
+    missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
     printFailed: 'Impression échouée',
     printFailed: 'Impression échouée',
     printStopped: 'Impression arrêtée',
     printStopped: 'Impression arrêtée',
     progressMilestones: 'Jalons de progression',
     progressMilestones: 'Jalons de progression',

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

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: 'Stampante eliminata',
       printerDeleted: 'Stampante eliminata',
+      missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
       printerAdded: 'Stampante aggiunta',
       printerAdded: 'Stampante aggiunta',
       printerUpdated: 'Stampante aggiornata',
       printerUpdated: 'Stampante aggiornata',
       failedToDelete: 'Impossibile eliminare stampante',
       failedToDelete: 'Impossibile eliminare stampante',
@@ -2550,6 +2551,18 @@ export default {
     noPermissionUpload: 'Non hai il permesso di caricare file',
     noPermissionUpload: 'Non hai il permesso di caricare file',
     noPermissionMoveFiles: 'Non hai il permesso di spostare file',
     noPermissionMoveFiles: 'Non hai il permesso di spostare file',
     noPermissionDeleteFiles: 'Non hai il permesso di eliminare file',
     noPermissionDeleteFiles: 'Non hai il permesso di eliminare file',
+    // External folder
+    linkExternal: 'Collega esterno',
+    linkExternalFolder: 'Collega cartella esterna',
+    linkExternalFolderDescription: 'Monta una directory host (NAS, USB, condivisione di rete) nel File Manager. I file non vengono copiati — vengono letti direttamente dal percorso originale.',
+    externalFolderNamePlaceholder: 'es. Stampe NAS',
+    externalPath: 'Percorso host',
+    externalPathHelp: 'Percorso assoluto della directory sull\'host Docker. Deve essere montato come bind nel container.',
+    readOnly: 'Sola lettura',
+    readOnlyHelp: 'impedisce caricamenti e cancellazioni',
+    showHiddenFiles: 'Mostra file nascosti (file punto)',
+    externalFolder: 'Cartella esterna',
+    scanFolder: 'Scansiona',
     toast: {
     toast: {
       folderCreated: 'Cartella creata',
       folderCreated: 'Cartella creata',
       folderDeleted: 'Cartella eliminata',
       folderDeleted: 'Cartella eliminata',
@@ -2558,6 +2571,8 @@ export default {
       filesMoved: 'File spostati',
       filesMoved: 'File spostati',
       folderLinked: 'Cartella collegata',
       folderLinked: 'Cartella collegata',
       folderUnlinked: 'Cartella scollegata',
       folderUnlinked: 'Cartella scollegata',
+      externalFolderLinked: 'Cartella esterna collegata e scansionata',
+      folderScanned: 'Scansione completata: {{added}} aggiunti, {{removed}} rimossi',
       addedToQueue: 'Aggiunti {{count}} file alla coda',
       addedToQueue: 'Aggiunti {{count}} file alla coda',
       addedToQueuePartial: 'Aggiunti {{added}} file, {{failed}} falliti',
       addedToQueuePartial: 'Aggiunti {{added}} file, {{failed}} falliti',
       failedToAddToQueue: 'Aggiunta file fallita: {{error}}',
       failedToAddToQueue: 'Aggiunta file fallita: {{error}}',
@@ -3647,6 +3662,8 @@ export default {
     autoOnDescription: 'Accendi quando inizia la stampa',
     autoOnDescription: 'Accendi quando inizia la stampa',
     autoOff: 'Auto Off',
     autoOff: 'Auto Off',
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
+    autoOffPersistent: 'Mantieni attivo',
+    autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
     turnOffDelayMode: 'Modalità ritardo spegnimento',
     turnOffDelayMode: 'Modalità ritardo spegnimento',
     time: 'Tempo',
     time: 'Tempo',
     temp: 'Temp',
     temp: 'Temp',
@@ -3798,6 +3815,8 @@ export default {
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
+    missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
+    missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
     printFailed: 'Stampa fallita',
     printFailed: 'Stampa fallita',
     printStopped: 'Stampa interrotta',
     printStopped: 'Stampa interrotta',
     progressMilestones: 'Traguardi di avanzamento',
     progressMilestones: 'Traguardi di avanzamento',

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

@@ -264,6 +264,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: 'プリンターを削除しました',
       printerDeleted: 'プリンターを削除しました',
+      missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
       printerAdded: 'プリンターを追加しました',
       printerAdded: 'プリンターを追加しました',
       printerUpdated: 'プリンターを更新しました',
       printerUpdated: 'プリンターを更新しました',
       failedToDelete: 'プリンターの削除に失敗しました',
       failedToDelete: 'プリンターの削除に失敗しました',
@@ -2563,6 +2564,18 @@ export default {
     noPermissionUpload: 'ファイルをアップロードする権限がありません',
     noPermissionUpload: 'ファイルをアップロードする権限がありません',
     noPermissionMoveFiles: 'ファイルを移動する権限がありません',
     noPermissionMoveFiles: 'ファイルを移動する権限がありません',
     noPermissionDeleteFiles: 'ファイルを削除する権限がありません',
     noPermissionDeleteFiles: 'ファイルを削除する権限がありません',
+    // External folder
+    linkExternal: '外部リンク',
+    linkExternalFolder: '外部フォルダをリンク',
+    linkExternalFolderDescription: 'ホストディレクトリ(NAS、USB、ネットワーク共有)をファイルマネージャにマウントします。ファイルはコピーされず、元のパスから直接アクセスされます。',
+    externalFolderNamePlaceholder: '例:NASプリント',
+    externalPath: 'ホストパス',
+    externalPathHelp: 'Dockerホスト上のディレクトリの絶対パス。コンテナにバインドマウントされている必要があります。',
+    readOnly: '読み取り専用',
+    readOnlyHelp: 'アップロードと削除を防止',
+    showHiddenFiles: '隠しファイルを表示(ドットファイル)',
+    externalFolder: '外部フォルダ',
+    scanFolder: 'スキャン',
     toast: {
     toast: {
       folderCreated: 'フォルダを作成しました',
       folderCreated: 'フォルダを作成しました',
       folderDeleted: 'フォルダを削除しました',
       folderDeleted: 'フォルダを削除しました',
@@ -2571,6 +2584,8 @@ export default {
       filesMoved: 'ファイルを移動しました',
       filesMoved: 'ファイルを移動しました',
       folderLinked: 'フォルダをリンクしました',
       folderLinked: 'フォルダをリンクしました',
       folderUnlinked: 'フォルダのリンクを解除しました',
       folderUnlinked: 'フォルダのリンクを解除しました',
+      externalFolderLinked: '外部フォルダがリンクされスキャンされました',
+      folderScanned: 'スキャン完了:{{added}}件追加、{{removed}}件削除',
       addedToQueue: '{{count}}個のファイルをキューに追加しました',
       addedToQueue: '{{count}}個のファイルをキューに追加しました',
       addedToQueuePartial: '{{added}}件追加、{{failed}}件失敗',
       addedToQueuePartial: '{{added}}件追加、{{failed}}件失敗',
       failedToAddToQueue: 'ファイルの追加に失敗: {{error}}',
       failedToAddToQueue: 'ファイルの追加に失敗: {{error}}',
@@ -3660,6 +3675,8 @@ export default {
     autoOnDescription: '印刷開始時にオンにする',
     autoOnDescription: '印刷開始時にオンにする',
     autoOff: '自動オフ',
     autoOff: '自動オフ',
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
+    autoOffPersistent: '有効のまま維持',
+    autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
     turnOffDelayMode: 'オフ遅延モード',
     turnOffDelayMode: 'オフ遅延モード',
     time: '時間',
     time: '時間',
     temp: '温度',
     temp: '温度',
@@ -3811,6 +3828,8 @@ export default {
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
+    missingSpoolAssignmentLabel: 'スプール割り当て不足',
+    missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     printFailed: '印刷失敗',
     printFailed: '印刷失敗',
     printStopped: '印刷停止',
     printStopped: '印刷停止',
     progressMilestones: '進捗マイルストーン',
     progressMilestones: '進捗マイルストーン',

+ 19 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: 'Impressora excluída',
       printerDeleted: 'Impressora excluída',
+      missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
       printerAdded: 'Impressora adicionada',
       printerAdded: 'Impressora adicionada',
       printerUpdated: 'Impressora atualizada',
       printerUpdated: 'Impressora atualizada',
       failedToDelete: 'Falha ao excluir impressora',
       failedToDelete: 'Falha ao excluir impressora',
@@ -2550,6 +2551,18 @@ export default {
     noPermissionUpload: 'Você não tem permissão para enviar arquivos',
     noPermissionUpload: 'Você não tem permissão para enviar arquivos',
     noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
     noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
     noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
     noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
+    // External folder
+    linkExternal: 'Vincular externo',
+    linkExternalFolder: 'Vincular pasta externa',
+    linkExternalFolderDescription: 'Montar um diretório do host (NAS, USB, compartilhamento de rede) no Gerenciador de Arquivos. Os arquivos não são copiados — são acessados diretamente do caminho original.',
+    externalFolderNamePlaceholder: 'ex. Impressões NAS',
+    externalPath: 'Caminho do host',
+    externalPathHelp: 'Caminho absoluto do diretório no host Docker. Deve estar montado como bind no contêiner.',
+    readOnly: 'Somente leitura',
+    readOnlyHelp: 'impede uploads e exclusões',
+    showHiddenFiles: 'Mostrar arquivos ocultos (arquivos ponto)',
+    externalFolder: 'Pasta externa',
+    scanFolder: 'Escanear',
     toast: {
     toast: {
       folderCreated: 'Pasta criada',
       folderCreated: 'Pasta criada',
       folderDeleted: 'Pasta excluída',
       folderDeleted: 'Pasta excluída',
@@ -2558,6 +2571,8 @@ export default {
       filesMoved: 'Arquivos movidos',
       filesMoved: 'Arquivos movidos',
       folderLinked: 'Pasta vinculada',
       folderLinked: 'Pasta vinculada',
       folderUnlinked: 'Pasta desvinculada',
       folderUnlinked: 'Pasta desvinculada',
+      externalFolderLinked: 'Pasta externa vinculada e escaneada',
+      folderScanned: 'Escaneamento concluído: {{added}} adicionados, {{removed}} removidos',
       addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',
       addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',
       addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',
       addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',
       failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',
       failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',
@@ -3647,6 +3662,8 @@ export default {
     autoOnDescription: 'Ligar quando a impressão iniciar',
     autoOnDescription: 'Ligar quando a impressão iniciar',
     autoOff: 'Auto Desligar',
     autoOff: 'Auto Desligar',
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
+    autoOffPersistent: 'Manter ativado',
+    autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
     turnOffDelayMode: 'Modo de atraso para desligar',
     turnOffDelayMode: 'Modo de atraso para desligar',
     time: 'Tempo',
     time: 'Tempo',
     temp: 'Temp',
     temp: 'Temp',
@@ -3798,6 +3815,8 @@ export default {
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
+    missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
+    missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
     printFailed: 'Impressão Falhou',
     printFailed: 'Impressão Falhou',
     printStopped: 'Impressão Parada',
     printStopped: 'Impressão Parada',
     progressMilestones: 'Marcos de Progresso',
     progressMilestones: 'Marcos de Progresso',

+ 19 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -265,6 +265,7 @@ export default {
     // Toast messages
     // Toast messages
     toast: {
     toast: {
       printerDeleted: '打印机已删除',
       printerDeleted: '打印机已删除',
+      missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
       printerAdded: '打印机已添加',
       printerAdded: '打印机已添加',
       printerUpdated: '打印机已更新',
       printerUpdated: '打印机已更新',
       failedToDelete: '删除打印机失败',
       failedToDelete: '删除打印机失败',
@@ -2550,6 +2551,18 @@ export default {
     noPermissionUpload: '您没有上传文件的权限',
     noPermissionUpload: '您没有上传文件的权限',
     noPermissionMoveFiles: '您没有移动文件的权限',
     noPermissionMoveFiles: '您没有移动文件的权限',
     noPermissionDeleteFiles: '您没有删除文件的权限',
     noPermissionDeleteFiles: '您没有删除文件的权限',
+    // External folder
+    linkExternal: '链接外部',
+    linkExternalFolder: '链接外部文件夹',
+    linkExternalFolderDescription: '将主机目录(NAS、USB、网络共享)挂载到文件管理器中。文件不会被复制——直接从原始路径访问。',
+    externalFolderNamePlaceholder: '例如:NAS打印文件',
+    externalPath: '主机路径',
+    externalPathHelp: 'Docker主机上目录的绝对路径。必须以绑定挂载方式挂载到容器中。',
+    readOnly: '只读',
+    readOnlyHelp: '防止上传和删除',
+    showHiddenFiles: '显示隐藏文件(点文件)',
+    externalFolder: '外部文件夹',
+    scanFolder: '扫描',
     toast: {
     toast: {
       folderCreated: '文件夹已创建',
       folderCreated: '文件夹已创建',
       folderDeleted: '文件夹已删除',
       folderDeleted: '文件夹已删除',
@@ -2558,6 +2571,8 @@ export default {
       filesMoved: '文件已移动',
       filesMoved: '文件已移动',
       folderLinked: '文件夹已链接',
       folderLinked: '文件夹已链接',
       folderUnlinked: '文件夹已取消链接',
       folderUnlinked: '文件夹已取消链接',
+      externalFolderLinked: '外部文件夹已链接并扫描',
+      folderScanned: '扫描完成:添加 {{added}} 个,移除 {{removed}} 个',
       addedToQueue: '已将 {{count}} 个文件添加到队列',
       addedToQueue: '已将 {{count}} 个文件添加到队列',
       addedToQueuePartial: '已添加 {{added}} 个文件,{{failed}} 个失败',
       addedToQueuePartial: '已添加 {{added}} 个文件,{{failed}} 个失败',
       failedToAddToQueue: '添加文件失败:{{error}}',
       failedToAddToQueue: '添加文件失败:{{error}}',
@@ -3647,6 +3662,8 @@ export default {
     autoOnDescription: '打印开始时开启',
     autoOnDescription: '打印开始时开启',
     autoOff: '自动关闭',
     autoOff: '自动关闭',
     autoOffDescription: '打印完成时关闭(一次性)',
     autoOffDescription: '打印完成时关闭(一次性)',
+    autoOffPersistent: '保持启用',
+    autoOffPersistentDescription: '在打印之间保持启用而非一次性',
     turnOffDelayMode: '关闭延迟模式',
     turnOffDelayMode: '关闭延迟模式',
     time: '时间',
     time: '时间',
     temp: '温度',
     temp: '温度',
@@ -3798,6 +3815,8 @@ export default {
     bedCooledDescription: '打印后热床温度降至阈值以下',
     bedCooledDescription: '打印后热床温度降至阈值以下',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
+    missingSpoolAssignmentLabel: '缺少料卷分配',
+    missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     printFailed: '打印失败',
     printFailed: '打印失败',
     printStopped: '打印已停止',
     printStopped: '打印已停止',
     progressMilestones: '进度里程碑',
     progressMilestones: '进度里程碑',

+ 32 - 0
frontend/src/index.css

@@ -392,6 +392,38 @@ body {
   box-shadow: var(--card-shadow);
   box-shadow: var(--card-shadow);
 }
 }
 
 
+/* ============================================
+   SPOOLBUDDY KIOSK MODAL CONSTRAINTS
+   Cap large modals (max-w-2xl) to viewport on the
+   small SpoolBuddy touchscreen. Excludes smaller
+   modals like the slot action picker (max-w-sm).
+   ============================================ */
+[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl {
+  height: 90vh;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+
+[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl > div:first-child {
+  flex-shrink: 0;
+}
+
+[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl > div:last-child {
+  flex-shrink: 0;
+}
+
+[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl > div:nth-child(2) {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow-y: auto;
+}
+
+/* Remove inner spool grid max-height to avoid nested scrollbars */
+[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl .max-h-96 {
+  max-height: none;
+}
+
 /* Calendar selected-day list scrollbar theming */
 /* Calendar selected-day list scrollbar theming */
 .calendar-scroll {
 .calendar-scroll {
   scrollbar-width: thin;
   scrollbar-width: thin;

+ 211 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -37,6 +37,9 @@ import {
   Image,
   Image,
   User,
   User,
   Box,
   Box,
+  RefreshCw,
+  Lock,
+  FolderSymlink,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -44,6 +47,7 @@ import type {
   LibraryFileListItem,
   LibraryFileListItem,
   LibraryFolderCreate,
   LibraryFolderCreate,
   LibraryFolderUpdate,
   LibraryFolderUpdate,
+  ExternalFolderCreate,
   AppSettings,
   AppSettings,
   Archive,
   Archive,
   Permission,
   Permission,
@@ -115,6 +119,104 @@ function NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderMo
   );
   );
 }
 }
 
 
+// External Folder Modal
+interface ExternalFolderModalProps {
+  onClose: () => void;
+  onSave: (data: ExternalFolderCreate) => void;
+  isLoading: boolean;
+  t: TFunction;
+}
+
+function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) {
+  const [name, setName] = useState('');
+  const [path, setPath] = useState('');
+  const [readonly, setReadonly] = useState(true);
+  const [showHidden, setShowHidden] = useState(false);
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    onSave({
+      name: name.trim(),
+      external_path: path.trim(),
+      readonly,
+      show_hidden: showHidden,
+    });
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+            <FolderSymlink className="w-5 h-5 text-bambu-green" />
+            {t('fileManager.linkExternalFolder')}
+          </h2>
+          <p className="text-sm text-bambu-gray mt-1">{t('fileManager.linkExternalFolderDescription')}</p>
+        </div>
+        <form onSubmit={handleSubmit} className="p-4 space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('fileManager.folderName')}
+            </label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder={t('fileManager.externalFolderNamePlaceholder')}
+              autoFocus
+              required
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('fileManager.externalPath')}
+            </label>
+            <input
+              type="text"
+              value={path}
+              onChange={(e) => setPath(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green font-mono text-sm"
+              placeholder="/mnt/nas/3d-prints"
+              required
+            />
+            <p className="text-xs text-bambu-gray mt-1">{t('fileManager.externalPathHelp')}</p>
+          </div>
+          <div className="space-y-2">
+            <label className="flex items-center gap-2 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={readonly}
+                onChange={(e) => setReadonly(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <span className="text-sm text-white">{t('fileManager.readOnly')}</span>
+              <span className="text-xs text-bambu-gray">({t('fileManager.readOnlyHelp')})</span>
+            </label>
+            <label className="flex items-center gap-2 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={showHidden}
+                onChange={(e) => setShowHidden(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <span className="text-sm text-white">{t('fileManager.showHiddenFiles')}</span>
+            </label>
+          </div>
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              {t('common.cancel')}
+            </Button>
+            <Button type="submit" disabled={!name.trim() || !path.trim() || isLoading}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fileManager.linkFolder')}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
+
 // Rename Modal
 // Rename Modal
 interface RenameModalProps {
 interface RenameModalProps {
   type: 'file' | 'folder';
   type: 'file' | 'folder';
@@ -432,6 +534,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
   const isLinked = folder.project_id || folder.archive_id;
+  const isExternal = folder.is_external;
 
 
   return (
   return (
     <div>
     <div>
@@ -457,7 +560,11 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
         ) : (
         ) : (
           <div className="w-4.5" />
           <div className="w-4.5" />
         )}
         )}
-        <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
+        {isExternal ? (
+          <FolderSymlink className="w-4 h-4 text-purple-400 flex-shrink-0" />
+        ) : (
+          <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
+        )}
         <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
         <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
         {/* Link indicator - clickable to change link */}
         {/* Link indicator - clickable to change link */}
         {isLinked && (
         {isLinked && (
@@ -474,11 +581,17 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
             )}
             )}
           </button>
           </button>
         )}
         )}
+        {/* Read-only indicator for external folders */}
+        {isExternal && folder.external_readonly && (
+          <span title={t('fileManager.readOnly')}>
+            <Lock className="w-3 h-3 text-amber-400 flex-shrink-0" />
+          </span>
+        )}
         {folder.file_count > 0 && (
         {folder.file_count > 0 && (
           <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
           <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
         )}
         )}
         {/* Quick link button - always visible for unlinked folders */}
         {/* Quick link button - always visible for unlinked folders */}
-        {!isLinked && (
+        {!isLinked && !isExternal && (
           <button
           <button
             onClick={(e) => { e.stopPropagation(); onLink(folder); }}
             onClick={(e) => { e.stopPropagation(); onLink(folder); }}
             className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
             className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
@@ -785,6 +898,7 @@ export function FileManagerPage() {
   const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
   const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
   const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
   const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
   const [showNewFolderModal, setShowNewFolderModal] = useState(false);
   const [showNewFolderModal, setShowNewFolderModal] = useState(false);
+  const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
@@ -979,6 +1093,35 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
     onError: (error: Error) => showToast(error.message, 'error'),
   });
   });
 
 
+  const createExternalFolderMutation = useMutation({
+    mutationFn: async (data: ExternalFolderCreate) => {
+      const folder = await api.createExternalFolder(data);
+      // Auto-scan after creation
+      await api.scanExternalFolder(folder.id);
+      return folder;
+    },
+    onSuccess: (folder) => {
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      setShowExternalFolderModal(false);
+      setSelectedFolderId(folder.id);
+      showToast(t('fileManager.toast.externalFolderLinked'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const scanExternalFolderMutation = useMutation({
+    mutationFn: (folderId: number) => api.scanExternalFolder(folderId),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   const deleteFolderMutation = useMutation({
   const deleteFolderMutation = useMutation({
     mutationFn: (id: number) => api.deleteLibraryFolder(id),
     mutationFn: (id: number) => api.deleteLibraryFolder(id),
     onSuccess: () => {
     onSuccess: () => {
@@ -1195,6 +1338,20 @@ export function FileManagerPage() {
 
 
   const isLoading = foldersLoading || filesLoading;
   const isLoading = foldersLoading || filesLoading;
 
 
+  // Find the selected folder in the tree to check external status
+  const selectedFolder = useMemo(() => {
+    if (!selectedFolderId || !folders) return null;
+    const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {
+      for (const item of items) {
+        if (item.id === selectedFolderId) return item;
+        const found = findFolder(item.children);
+        if (found) return found;
+      }
+      return null;
+    };
+    return findFolder(folders);
+  }, [selectedFolderId, folders]);
+
   return (
   return (
     <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
     <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
       {/* Header */}
       {/* Header */}
@@ -1245,6 +1402,15 @@ export function FileManagerPage() {
             )}
             )}
             {t('fileManager.generateThumbnails')}
             {t('fileManager.generateThumbnails')}
           </Button>
           </Button>
+          <Button
+            variant="secondary"
+            onClick={() => setShowExternalFolderModal(true)}
+            disabled={!hasPermission('library:upload')}
+            title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : t('fileManager.linkExternalFolder')}
+          >
+            <FolderSymlink className="w-4 h-4 mr-2" />
+            {t('fileManager.linkExternal')}
+          </Button>
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => setShowNewFolderModal(true)}
             onClick={() => setShowNewFolderModal(true)}
@@ -1416,6 +1582,40 @@ export function FileManagerPage() {
 
 
         {/* Files area */}
         {/* Files area */}
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
+          {/* External folder info bar */}
+          {selectedFolder?.is_external && (
+            <div className="flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
+              <FolderSymlink className="w-5 h-5 text-purple-400 flex-shrink-0" />
+              <div className="flex-1 min-w-0">
+                <div className="flex items-center gap-2">
+                  <span className="text-sm font-medium text-purple-300">{t('fileManager.externalFolder')}</span>
+                  {selectedFolder.external_readonly && (
+                    <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 flex items-center gap-1">
+                      <Lock className="w-3 h-3" />
+                      {t('fileManager.readOnly')}
+                    </span>
+                  )}
+                </div>
+                <p className="text-xs text-bambu-gray truncate font-mono" title={selectedFolder.external_path || ''}>
+                  {selectedFolder.external_path}
+                </p>
+              </div>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => selectedFolderId && scanExternalFolderMutation.mutate(selectedFolderId)}
+                disabled={scanExternalFolderMutation.isPending}
+                title={t('fileManager.scanFolder')}
+              >
+                {scanExternalFolderMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <RefreshCw className="w-4 h-4" />
+                )}
+                <span className="ml-1.5">{t('fileManager.scanFolder')}</span>
+              </Button>
+            </div>
+          )}
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
           {files && files.length > 0 && (
             <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
             <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
@@ -1898,6 +2098,15 @@ export function FileManagerPage() {
         />
         />
       )}
       )}
 
 
+      {showExternalFolderModal && (
+        <ExternalFolderModal
+          onClose={() => setShowExternalFolderModal(false)}
+          onSave={(data) => createExternalFolderMutation.mutate(data)}
+          isLoading={createExternalFolderMutation.isPending}
+          t={t}
+        />
+      )}
+
       {showMoveModal && folders && (
       {showMoveModal && folders && (
         <MoveFilesModal
         <MoveFilesModal
           folders={folders}
           folders={folders}

+ 1 - 1
frontend/src/pages/InventoryPage.tsx

@@ -418,7 +418,7 @@ export default function InventoryPageRouter() {
   if (spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url) {
   if (spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url) {
     return (
     return (
       <iframe
       <iframe
-        src={spoolmanSettings.spoolman_url}
+        src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
         className="h-full w-full border-0"
         className="h-full w-full border-0"
         title="Spoolman"
         title="Spoolman"
         sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
         sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"

+ 10 - 10
frontend/src/pages/PrintersPage.tsx

@@ -3310,7 +3310,7 @@ function PrinterCard({
                                     </div>
                                     </div>
                                     {/* Fill bar */}
                                     {/* Fill bar */}
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                                      {effectiveFill !== null && effectiveFill >= 0 && tray && (
+                                      {effectiveFill !== null && effectiveFill >= 0 && !isEmpty && tray && (
                                         <div
                                         <div
                                           className="h-full rounded-full transition-all"
                                           className="h-full rounded-full transition-all"
                                           style={{
                                           style={{
@@ -3440,7 +3440,7 @@ function PrinterCard({
                                       </FilamentHoverCard>
                                       </FilamentHoverCard>
                                     ) : (
                                     ) : (
                                       <EmptySlotHoverCard
                                       <EmptySlotHoverCard
-                                        configureSlot={{
+                                        configureSlot={tray?.state === 10 ? {
                                           enabled: hasPermission('printers:control'),
                                           enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             amsId: ams.id,
@@ -3448,8 +3448,8 @@ function PrinterCard({
                                             trayCount: ams.tray.length,
                                             trayCount: ams.tray.length,
                                             extruderId: mappedExtruderId,
                                             extruderId: mappedExtruderId,
                                           }),
                                           }),
-                                        }}
-                                        inventory={spoolmanEnabled ? undefined : {
+                                        } : undefined}
+                                        inventory={tray?.state === 10 && !spoolmanEnabled ? {
                                           onAssignSpool: () => setAssignSpoolModal({
                                           onAssignSpool: () => setAssignSpoolModal({
                                             printerId: printer.id,
                                             printerId: printer.id,
                                             amsId: ams.id,
                                             amsId: ams.id,
@@ -3460,7 +3460,7 @@ function PrinterCard({
                                               location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                               location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                             },
                                             },
                                           }),
                                           }),
-                                        }}
+                                        } : undefined}
                                       >
                                       >
                                         {slotVisual}
                                         {slotVisual}
                                       </EmptySlotHoverCard>
                                       </EmptySlotHoverCard>
@@ -3552,7 +3552,7 @@ function PrinterCard({
                             </div>
                             </div>
                             {/* Fill bar */}
                             {/* Fill bar */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              {htEffectiveFill !== null && htEffectiveFill >= 0 && (
+                              {htEffectiveFill !== null && htEffectiveFill >= 0 && !isEmpty && (
                                 <div
                                 <div
                                   className="h-full rounded-full transition-all"
                                   className="h-full rounded-full transition-all"
                                   style={{
                                   style={{
@@ -3758,7 +3758,7 @@ function PrinterCard({
                                   </FilamentHoverCard>
                                   </FilamentHoverCard>
                                 ) : (
                                 ) : (
                                   <EmptySlotHoverCard
                                   <EmptySlotHoverCard
-                                    configureSlot={{
+                                    configureSlot={tray?.state === 10 ? {
                                       enabled: hasPermission('printers:control'),
                                       enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         amsId: ams.id,
@@ -3766,8 +3766,8 @@ function PrinterCard({
                                         trayCount: ams.tray.length,
                                         trayCount: ams.tray.length,
                                         extruderId: mappedExtruderId,
                                         extruderId: mappedExtruderId,
                                       }),
                                       }),
-                                    }}
-                                    inventory={spoolmanEnabled ? undefined : {
+                                    } : undefined}
+                                    inventory={tray?.state === 10 && !spoolmanEnabled ? {
                                       onAssignSpool: () => setAssignSpoolModal({
                                       onAssignSpool: () => setAssignSpoolModal({
                                         printerId: printer.id,
                                         printerId: printer.id,
                                         amsId: ams.id,
                                         amsId: ams.id,
@@ -3778,7 +3778,7 @@ function PrinterCard({
                                           location: getAmsLabel(ams.id, ams.tray.length),
                                           location: getAmsLabel(ams.id, ams.tray.length),
                                         },
                                         },
                                       }),
                                       }),
-                                    }}
+                                    } : undefined}
                                   >
                                   >
                                     {slotVisual}
                                     {slotVisual}
                                   </EmptySlotHoverCard>
                                   </EmptySlotHoverCard>

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

@@ -1067,8 +1067,6 @@ export function SettingsPage() {
           <span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
           <span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
         </button>
       </div>
       </div>
-
-      {/* General Tab */}
       {activeTab === 'general' && (
       {activeTab === 'general' && (
       <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
       <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
         {/* Left Column - General Settings */}
         {/* Left Column - General Settings */}

+ 298 - 10
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -1,15 +1,18 @@
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
 import { useOutletContext } from 'react-router-dom';
 import { useOutletContext } from 'react-router-dom';
-import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Layers } from 'lucide-react';
+import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
-import type { PrinterStatus, AMSTray } from '../../api/client';
-import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../../utils/amsHelpers';
+import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
+import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel } from '../../utils/amsHelpers';
 import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
 import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
 import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
 import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
 import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
 import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
+import { AssignSpoolModal } from '../../components/AssignSpoolModal';
+import { LinkSpoolModal } from '../../components/LinkSpoolModal';
+import { useToast } from '../../contexts/ToastContext';
 
 
 function getAmsName(amsId: number): string {
 function getAmsName(amsId: number): string {
   if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
   if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
@@ -43,6 +46,7 @@ export function SpoolBuddyAmsPage() {
   const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
   const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
 
 
   const { data: status } = useQuery<PrinterStatus>({
   const { data: status } = useQuery<PrinterStatus>({
     queryKey: ['printerStatus', selectedPrinterId],
     queryKey: ['printerStatus', selectedPrinterId],
@@ -205,6 +209,72 @@ export function SpoolBuddyAmsPage() {
     savedPresetId?: string;
     savedPresetId?: string;
   } | null>(null);
   } | null>(null);
 
 
+  // Slot action picker: shown before opening configure or assign modal
+  const [slotActionPicker, setSlotActionPicker] = useState<{
+    amsId: number;
+    trayId: number;
+    trayCount: number;
+    tray: AMSTray | null;
+    trayType?: string;
+    trayColor?: string;
+    traySubBrands?: string;
+    trayInfoIdx?: string;
+    extruderId?: number;
+    caliIdx?: number | null;
+    savedPresetId?: string;
+    location: string;
+  } | null>(null);
+
+  // Assign spool modal state (inventory)
+  const [assignSpoolModal, setAssignSpoolModal] = useState<{
+    printerId: number;
+    amsId: number;
+    trayId: number;
+    trayInfo: { type: string; material?: string; profile?: string; color: string; location: string };
+  } | null>(null);
+
+  // Link spool modal state (Spoolman)
+  const [linkSpoolModal, setLinkSpoolModal] = useState<{
+    tagUid: string;
+    trayUuid: string;
+    printerId: number;
+    amsId: number;
+    trayId: number;
+  } | null>(null);
+
+  const getAssignment = useCallback((amsId: number, trayId: number): SpoolAssignment | undefined => {
+    return assignments?.find(a => a.ams_id === Number(amsId) && a.tray_id === Number(trayId));
+  }, [assignments]);
+
+  const getLinkedSpool = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
+    if (!linkedSpools || !printerSerial) return undefined;
+    const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
+    return tag ? linkedSpools[tag] : undefined;
+  }, [linkedSpools, printerSerial]);
+
+  const unassignMutation = useMutation({
+    mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
+      api.unassignSpool(printerId, amsId, trayId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
+      showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
+      setSlotActionPicker(null);
+    },
+  });
+
+  const unlinkSpoolMutation = useMutation({
+    mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
+    onSuccess: (result) => {
+      showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');
+      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+      setSlotActionPicker(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('spoolman.unlinkFailed'), 'error');
+    },
+  });
+
   const getActiveSlotForAms = useCallback((amsId: number): number | null => {
   const getActiveSlotForAms = useCallback((amsId: number): number | null => {
     if (effectiveTrayNow === undefined) return null;
     if (effectiveTrayNow === undefined) return null;
     if (amsId <= 3) {
     if (amsId <= 3) {
@@ -224,10 +294,11 @@ export function SpoolBuddyAmsPage() {
     const mappedExtruderId = amsExtruderMap[String(amsId)];
     const mappedExtruderId = amsExtruderMap[String(amsId)];
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
     const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
     const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
-    setConfigureSlotModal({
+    const slotData = {
       amsId,
       amsId,
       trayId,
       trayId,
       trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
       trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
+      tray,
       trayType: tray?.tray_type || undefined,
       trayType: tray?.tray_type || undefined,
       trayColor: tray?.tray_color || undefined,
       trayColor: tray?.tray_color || undefined,
       traySubBrands: tray?.tray_sub_brands || undefined,
       traySubBrands: tray?.tray_sub_brands || undefined,
@@ -235,17 +306,21 @@ export function SpoolBuddyAmsPage() {
       extruderId: isDualNozzle ? extruderId : undefined,
       extruderId: isDualNozzle ? extruderId : undefined,
       caliIdx: tray?.cali_idx,
       caliIdx: tray?.cali_idx,
       savedPresetId: slotPreset?.preset_id,
       savedPresetId: slotPreset?.preset_id,
-    });
+      location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
+    };
+
+    setSlotActionPicker(slotData);
   }, [slotPresets, amsExtruderMap, isDualNozzle]);
   }, [slotPresets, amsExtruderMap, isDualNozzle]);
 
 
   const handleExtSlotClick = useCallback((extTray: AMSTray) => {
   const handleExtSlotClick = useCallback((extTray: AMSTray) => {
     const extTrayId = extTray.id ?? 254;
     const extTrayId = extTray.id ?? 254;
     const slotTrayId = extTrayId - 254;
     const slotTrayId = extTrayId - 254;
     const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
     const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
-    setConfigureSlotModal({
+    const slotData = {
       amsId: 255,
       amsId: 255,
       trayId: slotTrayId,
       trayId: slotTrayId,
       trayCount: 1,
       trayCount: 1,
+      tray: isTrayEmpty(extTray) ? null : extTray,
       trayType: extTray.tray_type || undefined,
       trayType: extTray.tray_type || undefined,
       trayColor: extTray.tray_color || undefined,
       trayColor: extTray.tray_color || undefined,
       traySubBrands: extTray.tray_sub_brands || undefined,
       traySubBrands: extTray.tray_sub_brands || undefined,
@@ -253,9 +328,59 @@ export function SpoolBuddyAmsPage() {
       extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
       extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
       caliIdx: extTray.cali_idx,
       caliIdx: extTray.cali_idx,
       savedPresetId: extSlotPreset?.preset_id,
       savedPresetId: extSlotPreset?.preset_id,
-    });
+      location: isDualNozzle
+        ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
+        : 'External',
+    };
+
+    setSlotActionPicker(slotData);
   }, [slotPresets, isDualNozzle]);
   }, [slotPresets, isDualNozzle]);
 
 
+  const openConfigureFromPicker = useCallback(() => {
+    if (!slotActionPicker) return;
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const { tray, location, ...configData } = slotActionPicker;
+    setSlotActionPicker(null);
+    setConfigureSlotModal(configData);
+  }, [slotActionPicker]);
+
+  const openAssignFromPicker = useCallback(() => {
+    if (!slotActionPicker || !selectedPrinterId) return;
+    const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;
+    setSlotActionPicker(null);
+    setAssignSpoolModal({
+      printerId: selectedPrinterId,
+      amsId,
+      trayId,
+      trayInfo: {
+        type: trayType || '',
+        material: trayType,
+        color: trayColor?.slice(0, 6) || '',
+        location,
+      },
+    });
+  }, [slotActionPicker, selectedPrinterId]);
+
+  const openLinkFromPicker = useCallback(() => {
+    if (!slotActionPicker || !selectedPrinterId) return;
+    const { amsId, trayId, tray } = slotActionPicker;
+    const linkTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase() || '';
+    setSlotActionPicker(null);
+    setLinkSpoolModal({
+      tagUid: tray?.tag_uid || linkTag,
+      trayUuid: tray?.tray_uuid || '',
+      printerId: selectedPrinterId,
+      amsId,
+      trayId,
+    });
+  }, [slotActionPicker, selectedPrinterId, printerSerial]);
+
+  const handleUnassignFromPicker = useCallback(() => {
+    if (!slotActionPicker || !selectedPrinterId) return;
+    const { amsId, trayId } = slotActionPicker;
+    unassignMutation.mutate({ printerId: selectedPrinterId, amsId, trayId });
+  }, [slotActionPicker, selectedPrinterId, unassignMutation]);
+
   // Set alert for low filament in status bar
   // Set alert for low filament in status bar
   useEffect(() => {
   useEffect(() => {
     if (!isConnected && selectedPrinterId) {
     if (!isConnected && selectedPrinterId) {
@@ -263,11 +388,16 @@ export function SpoolBuddyAmsPage() {
       return;
       return;
     }
     }
     for (const unit of amsUnits) {
     for (const unit of amsUnits) {
-      for (const tray of unit.tray || []) {
+      const trays = unit.tray || [];
+      for (let i = 0; i < trays.length; i++) {
+        const tray = trays[i];
         if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {
         if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {
+          const isExternal = unit.id === 254 || unit.id === 255;
+          const isHt = !isExternal && unit.id >= 128;
+          const slot = formatSlotLabel(unit.id, i, isHt, isExternal);
           setAlert({
           setAlert({
             type: 'warning',
             type: 'warning',
-            message: `Low Filament: ${tray.tray_type} (${getAmsName(unit.id)}) - ${tray.remain}% remaining`,
+            message: `Low Filament: ${tray.tray_type} (${slot}) - ${tray.remain}% remaining`,
           });
           });
           return;
           return;
         }
         }
@@ -480,6 +610,164 @@ export function SpoolBuddyAmsPage() {
           }}
           }}
         />
         />
       )}
       )}
+
+      {/* Slot action picker */}
+      {slotActionPicker && selectedPrinterId && (() => {
+        const assignment = getAssignment(slotActionPicker.amsId, slotActionPicker.trayId);
+        const linked = getLinkedSpool(slotActionPicker.amsId, slotActionPicker.trayId, slotActionPicker.tray);
+        return (
+          <div className="fixed inset-0 z-50 flex items-center justify-center">
+            <div
+              className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+              onClick={() => setSlotActionPicker(null)}
+            />
+            <div className="relative w-full max-w-sm mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+              <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+                <div className="flex items-center gap-2">
+                  {slotActionPicker.trayColor && (
+                    <span
+                      className="w-4 h-4 rounded-full border border-black/20"
+                      style={{ backgroundColor: `#${slotActionPicker.trayColor.slice(0, 6)}` }}
+                    />
+                  )}
+                  <h2 className="text-lg font-semibold text-white">{slotActionPicker.location}</h2>
+                  {slotActionPicker.traySubBrands && (
+                    <span className="text-sm text-bambu-gray">({slotActionPicker.traySubBrands})</span>
+                  )}
+                </div>
+                <button
+                  onClick={() => setSlotActionPicker(null)}
+                  className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+                >
+                  <X className="w-5 h-5" />
+                </button>
+              </div>
+              <div className="p-4 space-y-2">
+                {/* Currently assigned/linked spool info */}
+                {!spoolmanEnabled && assignment?.spool && (
+                  <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
+                    <p className="text-xs text-bambu-gray mb-1">{t('inventory.assignedSpool', 'Assigned spool')}</p>
+                    <div className="flex items-center gap-2">
+                      {assignment.spool.rgba && (
+                        <span
+                          className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
+                          style={{ backgroundColor: `#${assignment.spool.rgba.substring(0, 6)}` }}
+                        />
+                      )}
+                      <span className="text-sm text-white">
+                        {assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material}
+                        {assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''}
+                      </span>
+                    </div>
+                  </div>
+                )}
+                {spoolmanEnabled && linked && (
+                  <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
+                    <p className="text-xs text-bambu-gray mb-1">{t('spoolman.linkedSpool', 'Linked spool')}</p>
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm text-white">
+                        Spoolman #{linked.id}
+                        {linked.remaining_weight != null ? ` (${Math.round(linked.remaining_weight)}g)` : ''}
+                      </span>
+                    </div>
+                  </div>
+                )}
+
+                <button
+                  onClick={openConfigureFromPicker}
+                  className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-blue transition-colors text-left"
+                >
+                  <Settings2 className="w-5 h-5 text-bambu-blue flex-shrink-0" />
+                  <div>
+                    <p className="text-white font-medium">{t('configureAmsSlot.title')}</p>
+                    <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.configureDesc', 'Set filament preset, K-profile, and color')}</p>
+                  </div>
+                </button>
+
+                {/* Inventory: Assign or Unassign */}
+                {!spoolmanEnabled && (assignment ? (
+                  <button
+                    onClick={handleUnassignFromPicker}
+                    disabled={unassignMutation.isPending}
+                    className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left"
+                  >
+                    <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
+                    <div>
+                      <p className="text-amber-400 font-medium">{t('inventory.unassignSpool', 'Unassign')}</p>
+                      <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>
+                    </div>
+                  </button>
+                ) : (
+                  <button
+                    onClick={openAssignFromPicker}
+                    className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
+                  >
+                    <Package className="w-5 h-5 text-bambu-green flex-shrink-0" />
+                    <div>
+                      <p className="text-white font-medium">{t('inventory.assignSpool')}</p>
+                      <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.assignDesc', 'Track a spool from your inventory')}</p>
+                    </div>
+                  </button>
+                ))}
+
+                {/* Spoolman: Link or Unlink */}
+                {spoolmanEnabled && (linked?.id ? (
+                  <button
+                    onClick={() => unlinkSpoolMutation.mutate(linked.id)}
+                    disabled={unlinkSpoolMutation.isPending}
+                    className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left"
+                  >
+                    <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
+                    <div>
+                      <p className="text-amber-400 font-medium">{t('spoolman.unlinkSpool')}</p>
+                      <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unlinkDesc', 'Remove Spoolman link from this slot')}</p>
+                    </div>
+                  </button>
+                ) : (
+                  <button
+                    onClick={openLinkFromPicker}
+                    className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
+                  >
+                    <Link2 className="w-5 h-5 text-bambu-green flex-shrink-0" />
+                    <div>
+                      <p className="text-white font-medium">{t('spoolman.linkToSpoolman')}</p>
+                      <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.linkDesc', 'Link a Spoolman spool to this slot')}</p>
+                    </div>
+                  </button>
+                ))}
+              </div>
+            </div>
+          </div>
+        );
+      })()}
+
+      {/* Assign spool modal (inventory) */}
+      {assignSpoolModal && (
+        <AssignSpoolModal
+          isOpen={!!assignSpoolModal}
+          onClose={() => {
+            setAssignSpoolModal(null);
+            queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
+          }}
+          printerId={assignSpoolModal.printerId}
+          amsId={assignSpoolModal.amsId}
+          trayId={assignSpoolModal.trayId}
+          trayInfo={assignSpoolModal.trayInfo}
+        />
+      )}
+
+      {/* Link spool modal (Spoolman) */}
+      {linkSpoolModal && (
+        <LinkSpoolModal
+          isOpen={!!linkSpoolModal}
+          onClose={() => setLinkSpoolModal(null)}
+          tagUid={linkSpoolModal.tagUid}
+          trayUuid={linkSpoolModal.trayUuid}
+          printerId={linkSpoolModal.printerId}
+          amsId={linkSpoolModal.amsId}
+          trayId={linkSpoolModal.trayId}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 31 - 13
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -15,6 +15,20 @@ const SPOOL_COLORS = [
   '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
   '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
 ];
 ];
 
 
+function normalizeHexTag(value: string | null | undefined): string {
+  if (!value) return '';
+  return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
+}
+
+function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
+  const aNorm = normalizeHexTag(a);
+  const bNorm = normalizeHexTag(b);
+  if (!aNorm || !bNorm) return false;
+  if (aNorm === bNorm) return true;
+  // Some readers report shortened UID forms.
+  return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
+}
+
 // --- Idle state with color-cycling spool and NFC waves ---
 // --- Idle state with color-cycling spool and NFC waves ---
 function ColorCyclingSpool() {
 function ColorCyclingSpool() {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -156,9 +170,13 @@ export function SpoolBuddyDashboard() {
 
 
   // Find spool by tag_id in the loaded spools list
   // Find spool by tag_id in the loaded spools list
   const displayedSpool = useMemo(() => {
   const displayedSpool = useMemo(() => {
+    if (sbState.matchedSpool?.id) {
+      const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
+      if (byId) return byId;
+    }
     if (!displayedTagId) return null;
     if (!displayedTagId) return null;
-    return spools.find((s) => s.tag_uid === displayedTagId) ?? null;
-  }, [displayedTagId, spools]);
+    return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null;
+  }, [displayedTagId, sbState.matchedSpool, spools]);
 
 
   // Untagged spools for the Link feature
   // Untagged spools for the Link feature
   const untaggedSpools = useMemo(() => {
   const untaggedSpools = useMemo(() => {
@@ -363,26 +381,26 @@ export function SpoolBuddyDashboard() {
             <div className="flex-1 flex items-center justify-center min-h-0">
             <div className="flex-1 flex items-center justify-center min-h-0">
               {!sbState.deviceOnline ? (
               {!sbState.deviceOnline ? (
                 <DeviceOfflineState />
                 <DeviceOfflineState />
-              ) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? (
+              ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
                 <SpoolInfoCard
                 <SpoolInfoCard
                   spool={{
                   spool={{
-                    id: displayedSpool.id,
+                    id: displayedSpool?.id ?? sbState.matchedSpool!.id,
                     tag_uid: displayedTagId,
                     tag_uid: displayedTagId,
-                    material: displayedSpool.material,
-                    subtype: displayedSpool.subtype,
-                    color_name: displayedSpool.color_name,
-                    rgba: displayedSpool.rgba,
-                    brand: displayedSpool.brand,
-                    label_weight: displayedSpool.label_weight,
-                    core_weight: displayedSpool.core_weight,
-                    weight_used: displayedSpool.weight_used,
+                    material: displayedSpool?.material ?? sbState.matchedSpool!.material,
+                    subtype: displayedSpool?.subtype ?? sbState.matchedSpool!.subtype,
+                    color_name: displayedSpool?.color_name ?? sbState.matchedSpool!.color_name,
+                    rgba: displayedSpool?.rgba ?? sbState.matchedSpool!.rgba,
+                    brand: displayedSpool?.brand ?? sbState.matchedSpool!.brand,
+                    label_weight: displayedSpool?.label_weight ?? sbState.matchedSpool!.label_weight,
+                    core_weight: displayedSpool?.core_weight ?? sbState.matchedSpool!.core_weight,
+                    weight_used: displayedSpool?.weight_used ?? sbState.matchedSpool!.weight_used,
                   }}
                   }}
                   scaleWeight={liveWeight ?? displayedWeight}
                   scaleWeight={liveWeight ?? displayedWeight}
                   onSyncWeight={() => refetchSpools()}
                   onSyncWeight={() => refetchSpools()}
                   onAssignToAms={() => setShowAssignAmsModal(true)}
                   onAssignToAms={() => setShowAssignAmsModal(true)}
                   onClose={handleCloseSpoolCard}
                   onClose={handleCloseSpoolCard}
                 />
                 />
-              ) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? (
+              ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
                 <UnknownTagCard
                 <UnknownTagCard
                   tagUid={displayedTagId}
                   tagUid={displayedTagId}
                   scaleWeight={liveWeight ?? displayedWeight}
                   scaleWeight={liveWeight ?? displayedWeight}

+ 481 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -0,0 +1,481 @@
+import { useState, useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Search, X, Package } from 'lucide-react';
+import { api } from '../../api/client';
+import type { InventorySpool, SpoolAssignment } from '../../api/client';
+import { resolveSpoolColorName } from '../../utils/colors';
+import { formatSlotLabel } from '../../utils/amsHelpers';
+
+type FilterMode = 'all' | 'in_ams' | string; // string = material name
+
+function spoolColor(spool: InventorySpool): string {
+  if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;
+  return '#808080';
+}
+
+function spoolRemaining(spool: InventorySpool): number {
+  return Math.max(0, spool.label_weight - spool.weight_used);
+}
+
+function spoolPct(spool: InventorySpool): number {
+  if (spool.label_weight <= 0) return 0;
+  return Math.max(0, Math.min(100, ((spool.label_weight - spool.weight_used) / spool.label_weight) * 100));
+}
+
+function spoolDisplayName(spool: InventorySpool): string {
+  const parts = [spool.material];
+  if (spool.subtype) parts.push(spool.subtype);
+  return parts.join(' ');
+}
+
+function assignmentLabel(a: SpoolAssignment): string {
+  const isExternal = a.ams_id === 254 || a.ams_id === 255;
+  const isHt = !isExternal && a.ams_id >= 128;
+  return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
+}
+
+/* Spool circle — same style as AMS page tray slots */
+function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
+  return (
+    <svg width={size} height={size} viewBox="0 0 56 56">
+      <circle cx="28" cy="28" r="26" fill={color} />
+      <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
+      <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
+      <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
+      <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
+    </svg>
+  );
+}
+
+export function SpoolBuddyInventoryPage() {
+  const { t } = useTranslation();
+  const [searchQuery, setSearchQuery] = useState('');
+  const [filterMode, setFilterMode] = useState<FilterMode>('all');
+  const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
+
+  const { data: spoolmanSettings } = useQuery({
+    queryKey: ['spoolman-settings'],
+    queryFn: api.getSpoolmanSettings,
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const { data: spools = [], isLoading } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(false),
+    refetchInterval: 30000,
+  });
+
+  const { data: assignments = [] } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+    refetchInterval: 30000,
+  });
+
+  // Build assignment lookup: spool_id → assignment
+  const assignmentMap = useMemo(() => {
+    const map: Record<number, SpoolAssignment> = {};
+    assignments.forEach(a => { map[a.spool_id] = a; });
+    return map;
+  }, [assignments]);
+
+  const activeSpools = useMemo(() => spools.filter(s => !s.archived_at), [spools]);
+
+  // Spools that have an AMS assignment
+  const assignedSpoolIds = useMemo(() => new Set(assignments.map(a => a.spool_id)), [assignments]);
+  const inAmsCount = useMemo(() => activeSpools.filter(s => assignedSpoolIds.has(s.id)).length, [activeSpools, assignedSpoolIds]);
+
+  // Unique materials for filter pills
+  const materials = useMemo(() => {
+    const set = new Set<string>();
+    activeSpools.forEach(s => set.add(s.material));
+    return Array.from(set).sort();
+  }, [activeSpools]);
+
+  // Filter and sort
+  const filteredSpools = useMemo(() => {
+    let list = activeSpools;
+
+    if (filterMode === 'in_ams') {
+      list = list.filter(s => assignedSpoolIds.has(s.id));
+    } else if (filterMode !== 'all') {
+      list = list.filter(s => s.material === filterMode);
+    }
+
+    if (searchQuery.trim()) {
+      const q = searchQuery.toLowerCase().trim();
+      list = list.filter(s =>
+        s.material.toLowerCase().includes(q) ||
+        (s.subtype && s.subtype.toLowerCase().includes(q)) ||
+        (s.brand && s.brand.toLowerCase().includes(q)) ||
+        (s.color_name && s.color_name.toLowerCase().includes(q)) ||
+        (s.note && s.note.toLowerCase().includes(q))
+      );
+    }
+
+    // Sort: assigned spools first (by slot label), then by most recently updated
+    return [...list].sort((a, b) => {
+      const aAssigned = assignedSpoolIds.has(a.id) ? 0 : 1;
+      const bAssigned = assignedSpoolIds.has(b.id) ? 0 : 1;
+      if (aAssigned !== bAssigned) return aAssigned - bAssigned;
+      return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
+    });
+  }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
+
+  // Spoolman iframe mode
+  const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
+  if (spoolmanEnabled) {
+    return (
+      <div className="h-full flex flex-col">
+        <iframe
+          src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
+          className="flex-1 w-full border-0"
+          title="Spoolman"
+          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+        />
+      </div>
+    );
+  }
+
+  return (
+    <div className="h-full flex flex-col">
+      {/* Search + filter pills */}
+      <div className="px-3 pt-3 pb-2 space-y-2.5">
+        {/* Search */}
+        <div className="relative">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
+          <input
+            type="text"
+            value={searchQuery}
+            onChange={e => setSearchQuery(e.target.value)}
+            placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}
+            className="w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green"
+          />
+          {searchQuery && (
+            <button
+              onClick={() => setSearchQuery('')}
+              className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          )}
+        </div>
+
+        {/* Filter pills — inline scrollable row */}
+        <div className="flex gap-1.5 overflow-x-auto no-scrollbar">
+          <FilterPill
+            active={filterMode === 'all'}
+            onClick={() => setFilterMode('all')}
+            label={`${t('spoolbuddy.inventory.all', 'All')} (${activeSpools.length})`}
+            green
+          />
+          {inAmsCount > 0 && (
+            <FilterPill
+              active={filterMode === 'in_ams'}
+              onClick={() => setFilterMode('in_ams')}
+              label={`${t('spoolbuddy.inventory.inAms', 'In AMS')} (${inAmsCount})`}
+            />
+          )}
+          {materials.map(mat => (
+            <FilterPill
+              key={mat}
+              active={filterMode === mat}
+              onClick={() => setFilterMode(filterMode === mat ? 'all' : mat)}
+              label={mat}
+            />
+          ))}
+        </div>
+      </div>
+
+      {/* Spool grid */}
+      <div className="flex-1 overflow-y-auto px-3 pb-3">
+        {isLoading ? (
+          <div className="flex items-center justify-center py-16">
+            <div className="w-8 h-8 border-2 border-bambu-green border-t-transparent rounded-full animate-spin" />
+          </div>
+        ) : filteredSpools.length === 0 ? (
+          <div className="flex flex-col items-center justify-center py-16 text-white/30">
+            <Package className="w-12 h-12 mb-3" />
+            <p className="text-sm">
+              {searchQuery || filterMode !== 'all'
+                ? t('spoolbuddy.inventory.noResults', 'No spools match your filters')
+                : t('spoolbuddy.inventory.empty', 'No spools in inventory')}
+            </p>
+          </div>
+        ) : (
+          <div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2">
+            {filteredSpools.map(spool => (
+              <CatalogCard
+                key={spool.id}
+                spool={spool}
+                assignment={assignmentMap[spool.id]}
+                onClick={() => setSelectedSpoolId(spool.id)}
+              />
+            ))}
+          </div>
+        )}
+      </div>
+
+      {/* Detail modal — look up spool from live query data so it stays current */}
+      {selectedSpoolId != null && (() => {
+        const liveSpool = spools.find(s => s.id === selectedSpoolId);
+        if (!liveSpool) return null;
+        return (
+          <SpoolDetailModal
+            spool={liveSpool}
+            assignment={assignmentMap[liveSpool.id]}
+            onClose={() => setSelectedSpoolId(null)}
+          />
+        );
+      })()}
+    </div>
+  );
+}
+
+/* Filter pill button */
+function FilterPill({ active, onClick, label, green }: {
+  active: boolean;
+  onClick: () => void;
+  label: string;
+  green?: boolean;
+}) {
+  return (
+    <button
+      onClick={onClick}
+      className={`px-4 py-1.5 rounded-full text-sm font-medium border whitespace-nowrap shrink-0 transition-colors ${
+        active
+          ? green
+            ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/50'
+            : 'bg-white/10 text-white border-white/20'
+          : 'bg-transparent text-white/40 border-bambu-dark-tertiary hover:text-white/60'
+      }`}
+    >
+      {label}
+    </button>
+  );
+}
+
+/* Catalog-style spool card matching the mockup */
+function CatalogCard({ spool, assignment, onClick }: {
+  spool: InventorySpool;
+  assignment?: SpoolAssignment;
+  onClick: () => void;
+}) {
+  const color = spoolColor(spool);
+  const pct = spoolPct(spool);
+  const remaining = spoolRemaining(spool);
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
+
+  return (
+    <button
+      onClick={onClick}
+      className="bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors"
+    >
+      {/* Spool icon */}
+      <SpoolCircle color={color} size={56} />
+
+      {/* Material + Subtype */}
+      <p className="text-xs font-semibold text-white leading-tight truncate w-full">
+        {spoolDisplayName(spool)}
+      </p>
+
+      {/* Color dot + name */}
+      <div className="flex items-center gap-1 min-w-0 max-w-full">
+        <span
+          className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
+          style={{ backgroundColor: color }}
+        />
+        <span className="text-[11px] text-white/50 truncate">
+          {colorName || '-'}
+        </span>
+      </div>
+
+      {/* Fill bar + weight */}
+      <div className="w-full space-y-0.5">
+        <div className="h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+            style={{ width: `${Math.min(pct, 100)}%` }}
+          />
+        </div>
+        <p className="text-[11px] text-white/40">
+          {Math.round(remaining)}g ({Math.round(pct)}%)
+        </p>
+      </div>
+
+      {/* AMS location badge */}
+      {assignment && (
+        <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-bambu-green/20 text-bambu-green">
+          {assignmentLabel(assignment)}
+        </span>
+      )}
+    </button>
+  );
+}
+
+/* Detail bottom sheet */
+function SpoolDetailModal({ spool, assignment, onClose }: {
+  spool: InventorySpool;
+  assignment?: SpoolAssignment;
+  onClose: () => void;
+}) {
+  const { t } = useTranslation();
+  const color = spoolColor(spool);
+  const pct = spoolPct(spool);
+  const remaining = spoolRemaining(spool);
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
+
+  return (
+    <div className="fixed inset-0 z-50" onClick={onClose}>
+      <div
+        className="h-full w-full bg-bambu-dark overflow-y-auto"
+        onClick={e => e.stopPropagation()}
+      >
+        {/* Header with spool icon */}
+        <div className="flex items-center gap-4 p-4 pb-3">
+          <SpoolCircle color={color} size={72} />
+          <div className="flex-1 min-w-0">
+            <h2 className="text-lg font-semibold text-white">
+              {spoolDisplayName(spool)}
+            </h2>
+            {spool.brand && (
+              <p className="text-sm text-white/50">{spool.brand}</p>
+            )}
+            <div className="flex items-center gap-1.5 mt-1">
+              <span
+                className="w-3 h-3 rounded-full border border-white/10"
+                style={{ backgroundColor: color }}
+              />
+              <span className="text-sm text-white/60">
+                {colorName || '-'}
+              </span>
+            </div>
+          </div>
+        </div>
+
+        <div className="px-4 pb-4 space-y-4">
+          {/* Remaining bar */}
+          <div>
+            <div className="flex justify-between text-xs text-white/50 mb-1.5">
+              <span>{t('spoolbuddy.inventory.remaining', 'Remaining')}</span>
+              <span>{Math.round(remaining)}g ({Math.round(pct)}%)</span>
+            </div>
+            <div className="h-3 bg-bambu-dark-secondary rounded-full overflow-hidden">
+              <div
+                className={`h-full rounded-full transition-all ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+                style={{ width: `${Math.min(pct, 100)}%` }}
+              />
+            </div>
+          </div>
+
+          {/* AMS location */}
+          {assignment && (
+            <div className="flex items-center gap-2">
+              <span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
+                {assignmentLabel(assignment)}
+              </span>
+              {assignment.printer_name && (
+                <span className="text-xs text-white/40">{assignment.printer_name}</span>
+              )}
+            </div>
+          )}
+
+          {/* Detail grid */}
+          <div className="grid grid-cols-2 gap-2.5">
+            <DetailItem
+              label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
+              value={`${spool.label_weight}g`}
+            />
+            <DetailItem
+              label={t('spoolbuddy.inventory.weightUsed', 'Used')}
+              value={spool.weight_used > 0 ? `${Math.round(spool.weight_used)}g` : '-'}
+            />
+            <DetailItem
+              label={t('spoolbuddy.inventory.coreWeight', 'Core Weight')}
+              value={spool.core_weight > 0 ? `${spool.core_weight}g` : '-'}
+            />
+            <DetailItem
+              label={t('spoolbuddy.inventory.grossWeight', 'Gross Weight')}
+              value={`${spool.label_weight + spool.core_weight}g`}
+            />
+            {spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.nozzleTemp', 'Nozzle Temp')}
+                value={`${spool.nozzle_temp_min}-${spool.nozzle_temp_max}°C`}
+              />
+            )}
+            {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}
+                value={`${spool.cost_per_kg.toFixed(2)}/kg`}
+              />
+            )}
+            {spool.last_scale_weight != null && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.lastScaleWeight', 'Scale Weight')}
+                value={`${Math.round(spool.last_scale_weight)}g`}
+              />
+            )}
+            {spool.tag_uid && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.tagId', 'Tag')}
+                value={spool.tag_uid}
+                mono
+              />
+            )}
+            {(spool.slicer_filament_name || spool.slicer_filament) && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}
+                value={spool.slicer_filament_name || spool.slicer_filament || ''}
+              />
+            )}
+          </div>
+
+          {/* K-Profiles */}
+          {spool.k_profiles && spool.k_profiles.length > 0 && (
+            <div>
+              <p className="text-xs text-white/40 mb-1.5">{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}</p>
+              <div className="space-y-1">
+                {spool.k_profiles.map(kp => (
+                  <div key={kp.id} className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg px-3 py-2">
+                    <span className="text-sm text-white/70 truncate">
+                      {kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`}
+                    </span>
+                    <span className="text-sm font-mono text-bambu-green shrink-0 ml-2">
+                      {kp.k_value.toFixed(3)}
+                    </span>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Note */}
+          {spool.note && (
+            <div className="bg-bambu-dark-secondary rounded-lg p-3">
+              <p className="text-xs text-white/40 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
+              <p className="text-sm text-white/70">{spool.note}</p>
+            </div>
+          )}
+
+          {/* Close button */}
+          <button
+            onClick={onClose}
+            className="w-full py-3 rounded-xl bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/60 hover:text-white text-sm font-medium transition-colors"
+          >
+            {t('spoolbuddy.inventory.close', 'Close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg px-3 py-2">
+      <p className="text-[10px] text-white/40 uppercase tracking-wide">{label}</p>
+      <p className={`text-sm text-white mt-0.5 truncate ${mono ? 'font-mono text-xs' : ''}`}>{value}</p>
+    </div>
+  );
+}

+ 418 - 127
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -4,6 +4,10 @@ import { useOutletContext } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
 import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
+import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
+import { FileText, Wand2, Zap } from 'lucide-react';
+
+
 function formatUptime(seconds: number): string {
 function formatUptime(seconds: number): string {
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
@@ -36,20 +40,44 @@ const BLANK_OPTIONS = [
 
 
 function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
 function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null);
+  const [backendUrl, setBackendUrl] = useState('');
+  const [apiToken, setApiToken] = useState('');
+  const [systemBusy, setSystemBusy] = useState(false);
+  const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null);
 
 
-  return (
-    <div className="space-y-4">
-      {/* About */}
-      <div className="bg-zinc-800 rounded-lg p-4">
-        <div className="flex items-center gap-3 mb-2">
-          <img src="/img/spoolbuddy_logo_dark_small.png" alt="SpoolBuddy" className="h-7 w-auto" />
-        </div>
-        <p className="text-xs text-zinc-500 mb-1">Part of Bambuddy</p>
-        <span className="text-xs text-zinc-500">github.com/maziggy/bambuddy</span>
-      </div>
+  useEffect(() => {
+    if (!backendUrl && device.backend_url) {
+      setBackendUrl(device.backend_url);
+    }
+  }, [device.backend_url, backendUrl]);
 
 
+  const saveConfig = async () => {
+    if (!backendUrl.trim()) {
+      setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') });
+      return;
+    }
+
+    setSystemBusy(true);
+    setSystemMsg(null);
+    try {
+      await spoolbuddyApi.updateSystemConfig(
+        device.device_id,
+        backendUrl.trim(),
+        apiToken.trim() || undefined
+      );
+      setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') });
+    } catch (e) {
+      setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });
+    } finally {
+      setSystemBusy(false);
+    }
+  };
+
+  return (
+    <div className="space-y-2">
       {/* NFC Reader + Device Info side by side */}
       {/* NFC Reader + Device Info side by side */}
-      <div className="grid grid-cols-2 gap-3">
+      <div className="grid grid-cols-2 gap-2">
         {/* NFC Reader */}
         {/* NFC Reader */}
         <div className="bg-zinc-800 rounded-lg p-3">
         <div className="bg-zinc-800 rounded-lg p-3">
           <h3 className="text-sm font-semibold text-zinc-300 mb-2">
           <h3 className="text-sm font-semibold text-zinc-300 mb-2">
@@ -106,24 +134,90 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
               <span className="text-zinc-500">{t('spoolbuddy.settings.uptime', 'Uptime')}</span>
               <span className="text-zinc-500">{t('spoolbuddy.settings.uptime', 'Uptime')}</span>
               <span className="text-zinc-300">{formatUptime(device.uptime_s)}</span>
               <span className="text-zinc-300">{formatUptime(device.uptime_s)}</span>
             </div>
             </div>
-            <div className="flex justify-between items-center">
-              <span className="text-zinc-500">{t('spoolbuddy.status.status', 'Status')}</span>
-              <div className="flex items-center gap-1.5">
-                <div className={`w-2 h-2 rounded-full ${device.online ? 'bg-green-500' : 'bg-zinc-600'}`} />
-                <span className={device.online ? 'text-green-400' : 'text-zinc-500'}>
-                  {device.online ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
-                </span>
-              </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">ID</span>
+              <span className="text-zinc-400 font-mono truncate ml-2">{device.device_id}</span>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
-      {/* Device ID (full width, below cards) */}
-      <div className="bg-zinc-800 rounded-lg px-3 py-2 flex justify-between items-center text-xs">
-        <span className="text-zinc-500">Device ID</span>
-        <span className="text-zinc-400 font-mono">{device.device_id}</span>
+      {/* Backend/Auth + Diagnostics side by side */}
+      <div className="grid grid-cols-2 gap-2">
+        {/* Backend/Auth Config */}
+        <div className="bg-zinc-800 rounded-lg p-3 space-y-2">
+          <h3 className="text-sm font-semibold text-zinc-300">
+            {t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}
+          </h3>
+          <input
+            value={backendUrl}
+            onChange={(e) => setBackendUrl(e.target.value)}
+            placeholder="http://192.168.1.100:5000"
+            className="w-full px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-xs"
+          />
+          <div className="flex gap-2">
+            <input
+              type="password"
+              value={apiToken}
+              onChange={(e) => setApiToken(e.target.value)}
+              placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'API token')}
+              className="flex-1 px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-xs"
+            />
+            <button
+              onClick={saveConfig}
+              disabled={systemBusy}
+              className="px-3 py-1.5 rounded bg-green-700 hover:bg-green-600 disabled:bg-zinc-700 text-xs font-medium text-zinc-100"
+            >
+              {t('spoolbuddy.settings.saveConfig', 'Save')}
+            </button>
+          </div>
+          {systemMsg && (
+            <div className={`text-xs ${systemMsg.type === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
+              {systemMsg.text}
+            </div>
+          )}
+        </div>
+
+        {/* Diagnostic Buttons */}
+        <div className="bg-zinc-800 rounded-lg p-3 flex flex-col gap-2">
+          <button
+            onClick={() => setDiagnosticOpen('nfc')}
+            className="flex-1 bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-2 flex items-center gap-2"
+          >
+            <Wand2 className="w-4 h-4 text-blue-300 shrink-0" />
+            <span className="text-xs font-medium text-blue-100">
+              {t('spoolbuddy.settings.nfcDiagnostic', 'NFC Diagnostic')}
+            </span>
+          </button>
+          <button
+            onClick={() => setDiagnosticOpen('scale')}
+            className="flex-1 bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-2 flex items-center gap-2"
+          >
+            <Zap className="w-4 h-4 text-yellow-300 shrink-0" />
+            <span className="text-xs font-medium text-yellow-100">
+              {t('spoolbuddy.settings.scaleDiagnostic', 'Scale Diagnostic')}
+            </span>
+          </button>
+          <button
+            onClick={() => setDiagnosticOpen('read_tag')}
+            className="flex-1 bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-2 flex items-center gap-2"
+          >
+            <FileText className="w-4 h-4 text-emerald-300 shrink-0" />
+            <span className="text-xs font-medium text-emerald-100">
+              {t('spoolbuddy.settings.readTagDiagnostic', 'Read Tag')}
+            </span>
+          </button>
+        </div>
       </div>
       </div>
+
+      {/* Diagnostic Modal */}
+      {diagnosticOpen && device && (
+        <DiagnosticModal
+          type={diagnosticOpen}
+          deviceId={device.device_id}
+          onClose={() => setDiagnosticOpen(null)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }
@@ -502,26 +596,74 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 
 
 function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
 function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const [applying, setApplying] = useState(false);
+  const [busy, setBusy] = useState<'checking' | 'applying' | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [sshExpanded, setSSHExpanded] = useState(false);
+  const [copied, setCopied] = useState(false);
 
 
   const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';
   const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';
 
 
-  const { data: updateResult, isLoading: checking, refetch } = useQuery({
+  // When applying succeeds and device picks up the update, keep showing busy
+  useEffect(() => {
+    if (isUpdating && busy === 'applying') {
+      setBusy(null); // device has picked it up, isUpdating takes over the UI
+    }
+  }, [isUpdating, busy]);
+
+  // Reload the page when daemon comes back online after an update
+  useEffect(() => {
+    const handleOnline = () => {
+      if (isUpdating) {
+        // Daemon re-registered — reload to get fresh version + state
+        setTimeout(() => window.location.reload(), 1000);
+      }
+    };
+    window.addEventListener('spoolbuddy-online', handleOnline);
+    return () => window.removeEventListener('spoolbuddy-online', handleOnline);
+  }, [isUpdating]);
+
+  const { data: updateResult, refetch } = useQuery({
     queryKey: ['spoolbuddy-update-check', device.device_id],
     queryKey: ['spoolbuddy-update-check', device.device_id],
-    queryFn: () => spoolbuddyApi.checkDaemonUpdate(device.device_id, true),
-    staleTime: 4 * 60 * 1000,
+    queryFn: () => spoolbuddyApi.checkDaemonUpdate(device.device_id),
+    staleTime: 0,
   });
   });
 
 
+  const { data: sshKeyData } = useQuery({
+    queryKey: ['spoolbuddy-ssh-key'],
+    queryFn: () => spoolbuddyApi.getSSHPublicKey(),
+    enabled: sshExpanded,
+    staleTime: Infinity,
+  });
+
+  const checkForUpdates = async () => {
+    setBusy('checking');
+    setError(null);
+    try {
+      await refetch();
+    } finally {
+      setBusy(null);
+    }
+  };
+
   const applyUpdate = async () => {
   const applyUpdate = async () => {
-    setApplying(true);
+    setBusy('applying');
     setError(null);
     setError(null);
     try {
     try {
       await spoolbuddyApi.triggerUpdate(device.device_id);
       await spoolbuddyApi.triggerUpdate(device.device_id);
+      // Don't clear busy — keep showing spinner until isUpdating takes over or timeout
     } catch (e) {
     } catch (e) {
       setError(e instanceof Error ? e.message : 'Failed to trigger update');
       setError(e instanceof Error ? e.message : 'Failed to trigger update');
-    } finally {
-      setApplying(false);
+      setBusy(null);
+    }
+  };
+
+  const showSpinner = busy != null || isUpdating;
+
+  const copyKey = () => {
+    if (sshKeyData?.public_key) {
+      navigator.clipboard.writeText(sshKeyData.public_key);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
     }
     }
   };
   };
 
 
@@ -529,128 +671,275 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
     || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);
     || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);
 
 
   return (
   return (
-    <div className="space-y-4">
-      {/* Current version */}
-      <div className="bg-zinc-800 rounded-lg p-4">
-        <h3 className="text-sm font-semibold text-zinc-300 mb-3">
-          {t('spoolbuddy.settings.daemonVersion', 'Daemon Version')}
-        </h3>
+    <div className="space-y-3">
+      {/* Version + Update status + Check — single card */}
+      <div className="bg-zinc-800 rounded-lg p-3 space-y-3">
+        {/* Version row */}
         <div className="flex justify-between items-center text-sm">
         <div className="flex justify-between items-center text-sm">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.currentVersion', 'Current')}</span>
+          <span className="text-zinc-500">{t('spoolbuddy.settings.currentVersion', 'Current Version')}</span>
           <span className="text-zinc-200 font-mono">
           <span className="text-zinc-200 font-mono">
             {displayVersion || (
             {displayVersion || (
-              <span className="text-zinc-500 italic">{t('spoolbuddy.settings.versionPending', 'Waiting for daemon...')}</span>
+              <span className="text-zinc-500 italic text-xs">{t('spoolbuddy.settings.versionPending', 'Waiting for daemon...')}</span>
             )}
             )}
           </span>
           </span>
         </div>
         </div>
-      </div>
 
 
-      {/* Update progress (shown when update is in progress) */}
-      {isUpdating && (
-        <div className="bg-zinc-800 rounded-lg p-4">
-          <div className="flex items-center gap-3">
-            <svg className="w-5 h-5 animate-spin text-green-400 flex-shrink-0" viewBox="0 0 24 24" fill="none">
+        {/* Status / progress row */}
+        {showSpinner ? (
+          <div className="flex items-center gap-2">
+            <svg className="w-4 h-4 animate-spin text-green-400 flex-shrink-0" viewBox="0 0 24 24" fill="none">
               <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
               <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
               <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
               <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
             </svg>
             </svg>
-            <div>
-              <p className="text-sm font-medium text-green-300">
-                {t('spoolbuddy.settings.updating', 'Updating...')}
-              </p>
-              <p className="text-xs text-zinc-400 mt-0.5">
-                {device.update_message || t('spoolbuddy.settings.updateWaiting', 'Waiting for device...')}
-              </p>
-            </div>
-          </div>
-        </div>
-      )}
-
-      {/* Update complete */}
-      {device.update_status === 'complete' && (
-        <div className="rounded-lg p-3 text-sm bg-green-900/30 border border-green-800">
-          <div className="flex items-center gap-2">
-            <svg className="w-4 h-4 text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-              <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
-            </svg>
-            <p className="text-green-300">{device.update_message || t('spoolbuddy.settings.updateComplete', 'Update complete!')}</p>
+            <span className="text-green-300 text-xs">
+              {busy === 'checking' ? t('spoolbuddy.settings.checking', 'Checking for updates...')
+                : device.update_message || t('spoolbuddy.settings.updateWaiting', 'Updating...')}
+            </span>
           </div>
           </div>
-        </div>
-      )}
+        ) : device.update_status === 'error' ? (
+          <p className="text-xs text-red-300">{device.update_message || t('spoolbuddy.settings.updateFailed', 'Update failed')}</p>
+        ) : error ? (
+          <p className="text-xs text-red-300">{error}</p>
+        ) : updateResult?.update_available ? (
+          <p className="text-xs text-green-300">
+            {t('spoolbuddy.settings.updateAvailable', 'Update available')}: {displayVersion} → {updateResult.latest_version}
+          </p>
+        ) : null}
 
 
-      {/* Update error */}
-      {device.update_status === 'error' && (
-        <div className="rounded-lg p-3 text-sm bg-red-900/30 border border-red-800">
-          <p className="text-red-300">{device.update_message || t('spoolbuddy.settings.updateFailed', 'Update failed')}</p>
-        </div>
-      )}
+        {/* Action buttons */}
+        {!showSpinner && (
+          updateResult?.update_available ? (
+            <button
+              onClick={applyUpdate}
+              disabled={!device.online}
+              className="w-full px-3 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors"
+            >
+              {!device.online
+                ? t('spoolbuddy.settings.deviceOffline', 'Device Offline')
+                : t('spoolbuddy.settings.applyUpdate', 'Apply Update')}
+            </button>
+          ) : (
+            <div className="flex gap-2">
+              <button
+                onClick={checkForUpdates}
+                className="flex-1 px-3 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors"
+              >
+                {t('spoolbuddy.settings.checkUpdates', 'Check for Updates')}
+              </button>
+              <button
+                onClick={applyUpdate}
+                disabled={!device.online}
+                className="px-3 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-200 disabled:opacity-40 transition-colors"
+              >
+                {t('spoolbuddy.settings.forceUpdate', 'Force Update')}
+              </button>
+            </div>
+          )
+        )}
+      </div>
 
 
-      {/* Check for updates */}
-      <div className="bg-zinc-800 rounded-lg p-4 space-y-3">
+      {/* SSH Setup — collapsible */}
+      <div className="bg-zinc-800 rounded-lg p-3">
         <button
         <button
-          onClick={() => refetch()}
-          disabled={checking || isUpdating}
-          className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
+          onClick={() => setSSHExpanded(!sshExpanded)}
+          className="w-full flex justify-between items-center text-xs"
         >
         >
-          {checking && (
-            <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
-              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
-              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
-            </svg>
-          )}
-          {checking ? t('spoolbuddy.settings.checking', 'Checking...') : t('spoolbuddy.settings.checkUpdates', 'Check for Updates')}
+          <span className="font-medium text-zinc-400">
+            {t('spoolbuddy.settings.sshSetup', 'SSH Setup')}
+          </span>
+          <svg
+            className={`w-3 h-3 text-zinc-500 transition-transform ${sshExpanded ? 'rotate-180' : ''}`}
+            fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
+          >
+            <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
+          </svg>
         </button>
         </button>
 
 
-        {/* Error feedback */}
-        {error && (
-          <div className="rounded-lg p-3 text-sm bg-red-900/30 border border-red-800">
-            <p className="text-red-300">{error}</p>
-          </div>
-        )}
-
-        {/* Result feedback */}
-        {updateResult && (
-          <div className={`rounded-lg p-3 text-sm ${
-            updateResult.update_available
-              ? 'bg-green-900/30 border border-green-800'
-              : 'bg-zinc-700/50'
-          }`}>
-            {updateResult.update_available ? (
-              <div className="space-y-3">
-                <div className="space-y-1">
-                  <p className="text-green-300 font-medium">
-                    {t('spoolbuddy.settings.updateAvailable', 'Update available')}: v{updateResult.latest_version}
-                  </p>
-                  <p className="text-xs text-zinc-400">
-                    {displayVersion ? `${displayVersion} → ${updateResult.latest_version}` : ''}
-                  </p>
-                </div>
+        {sshExpanded && (
+          <div className="mt-2 space-y-2">
+            <p className="text-xs text-zinc-500">
+              {t('spoolbuddy.settings.sshDescription', 'SSH key is deployed automatically. For manual setup, add this key to ~/.ssh/authorized_keys on the device.')}
+            </p>
+            {sshKeyData?.public_key ? (
+              <div className="relative">
+                <pre className="bg-zinc-900 rounded p-2 text-[10px] text-zinc-400 font-mono break-all whitespace-pre-wrap">
+                  {sshKeyData.public_key}
+                </pre>
                 <button
                 <button
-                  onClick={applyUpdate}
-                  disabled={applying || isUpdating || !device.online}
-                  className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
+                  onClick={copyKey}
+                  className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-[10px] bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors"
                 >
                 >
-                  {applying && (
-                    <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
-                      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
-                      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
-                    </svg>
-                  )}
-                  {!device.online
-                    ? t('spoolbuddy.settings.deviceOffline', 'Device Offline')
-                    : t('spoolbuddy.settings.applyUpdate', 'Apply Update')}
+                  {copied ? t('common.copied', 'Copied!') : t('common.copy', 'Copy')}
                 </button>
                 </button>
               </div>
               </div>
             ) : (
             ) : (
-              <div className="flex items-center gap-2">
-                <svg className="w-4 h-4 text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
-                </svg>
-                <p className="text-zinc-300">{t('spoolbuddy.settings.upToDate', 'Up to date')}</p>
-              </div>
+              <span className="text-[10px] text-zinc-500 italic">
+                {t('spoolbuddy.settings.sshKeyLoading', 'Loading...')}
+              </span>
             )}
             )}
           </div>
           </div>
         )}
         )}
+      </div>
+    </div>
+  );
+}
+
+// --- System Tab ---
+
+function UsageBar({ percent, color }: { percent: number; color: string }) {
+  return (
+    <div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
+      <div
+        className={`h-full rounded-full transition-all ${color}`}
+        style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
+      />
+    </div>
+  );
+}
+
+function formatSystemUptime(seconds: number): string {
+  const d = Math.floor(seconds / 86400);
+  const h = Math.floor((seconds % 86400) / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  if (d > 0) return `${d}d ${h}h ${m}m`;
+  if (h > 0) return `${h}h ${m}m`;
+  return `${m}m`;
+}
+
+function SystemTab({ device }: { device: SpoolBuddyDevice }) {
+  const { t } = useTranslation();
+  const stats = device.system_stats;
+
+  if (!stats) {
+    return (
+      <div className="flex items-center justify-center h-32">
+        <p className="text-sm text-zinc-500">
+          {t('spoolbuddy.settings.systemStatsWaiting', 'Waiting for system stats...')}
+        </p>
+      </div>
+    );
+  }
+
+  const mem = stats.memory;
+  const disk = stats.disk;
+  const tempColor = (stats.cpu_temp_c ?? 0) >= 80 ? 'text-red-400' : (stats.cpu_temp_c ?? 0) >= 65 ? 'text-amber-400' : 'text-green-400';
+  const memColor = (mem?.percent ?? 0) >= 90 ? 'bg-red-500' : (mem?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';
+  const diskColor = (disk?.percent ?? 0) >= 90 ? 'bg-red-500' : (disk?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';
+
+  return (
+    <div className="space-y-2">
+      {/* CPU + Memory side by side */}
+      <div className="grid grid-cols-2 gap-2">
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-2">CPU</h3>
+          <div className="space-y-1.5 text-xs">
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.cores', 'Cores')}</span>
+              <span className="text-zinc-300 font-mono">{stats.cpu_count ?? '-'}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.loadAvg', 'Load Avg')}</span>
+              <span className="text-zinc-300 font-mono">
+                {stats.load_avg ? stats.load_avg.join(' / ') : '-'}
+              </span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.temp', 'Temp')}</span>
+              <span className={`font-mono font-medium ${tempColor}`}>
+                {stats.cpu_temp_c != null ? `${stats.cpu_temp_c}\u00B0C` : '-'}
+              </span>
+            </div>
+          </div>
+        </div>
 
 
+        {/* Memory */}
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-2">
+            {t('spoolbuddy.settings.memory', 'Memory')}
+          </h3>
+          {mem ? (
+            <div className="space-y-1.5">
+              <UsageBar percent={mem.percent ?? 0} color={memColor} />
+              <div className="space-y-1 text-xs">
+                <div className="flex justify-between">
+                  <span className="text-zinc-500">{t('spoolbuddy.settings.used', 'Used')}</span>
+                  <span className="text-zinc-300 font-mono">{mem.used_mb} / {mem.total_mb} MB</span>
+                </div>
+                <div className="flex justify-between">
+                  <span className="text-zinc-500">{t('spoolbuddy.settings.available', 'Free')}</span>
+                  <span className="text-zinc-300 font-mono">{mem.available_mb} MB</span>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <span className="text-xs text-zinc-500">-</span>
+          )}
+        </div>
+      </div>
+
+      {/* Disk — compact single row */}
+      <div className="bg-zinc-800 rounded-lg px-3 py-2">
+        <div className="flex items-center gap-3">
+          <h3 className="text-sm font-semibold text-zinc-300 shrink-0">
+            {t('spoolbuddy.settings.disk', 'Disk')}
+          </h3>
+          {disk ? (
+            <>
+              <div className="flex-1"><UsageBar percent={disk.percent ?? 0} color={diskColor} /></div>
+              <span className="text-xs text-zinc-300 font-mono shrink-0">{disk.used_gb} / {disk.total_gb} GB</span>
+            </>
+          ) : (
+            <span className="text-xs text-zinc-500">-</span>
+          )}
+        </div>
+      </div>
+
+      {/* OS + Runtime side by side */}
+      <div className="grid grid-cols-2 gap-2">
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-1.5">
+            {t('spoolbuddy.settings.osInfo', 'OS')}
+          </h3>
+          <div className="space-y-1 text-xs">
+            {stats.os?.os && (
+              <div className="flex justify-between">
+                <span className="text-zinc-500">{t('spoolbuddy.settings.distro', 'Distro')}</span>
+                <span className="text-zinc-300 truncate ml-2">{stats.os.os}</span>
+              </div>
+            )}
+            {stats.os?.kernel && (
+              <div className="flex justify-between">
+                <span className="text-zinc-500">{t('spoolbuddy.settings.kernel', 'Kernel')}</span>
+                <span className="text-zinc-300 font-mono truncate ml-2">{stats.os.kernel}</span>
+              </div>
+            )}
+            {stats.os?.arch && (
+              <div className="flex justify-between">
+                <span className="text-zinc-500">{t('spoolbuddy.settings.arch', 'Arch')}</span>
+                <span className="text-zinc-300 font-mono">{stats.os.arch}</span>
+              </div>
+            )}
+          </div>
+        </div>
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-1.5">
+            {t('spoolbuddy.settings.runtime', 'Runtime')}
+          </h3>
+          <div className="space-y-1 text-xs">
+            {stats.os?.python && (
+              <div className="flex justify-between">
+                <span className="text-zinc-500">Python</span>
+                <span className="text-zinc-300 font-mono">{stats.os.python}</span>
+              </div>
+            )}
+            {stats.system_uptime_s != null && (
+              <div className="flex justify-between">
+                <span className="text-zinc-500">{t('spoolbuddy.settings.systemUptime', 'Uptime')}</span>
+                <span className="text-zinc-300">{formatSystemUptime(stats.system_uptime_s)}</span>
+              </div>
+            )}
+          </div>
+        </div>
       </div>
       </div>
     </div>
     </div>
   );
   );
@@ -658,7 +947,7 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
 
 
 // --- Main Settings Page ---
 // --- Main Settings Page ---
 
 
-type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
+type SettingsTab = 'device' | 'display' | 'scale' | 'updates' | 'system';
 
 
 export function SpoolBuddySettingsPage() {
 export function SpoolBuddySettingsPage() {
   const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
   const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
@@ -682,6 +971,7 @@ export function SpoolBuddySettingsPage() {
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
     { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
     { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
     { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
     { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
+    { id: 'system', label: t('spoolbuddy.settings.tabSystem', 'System') },
   ];
   ];
 
 
   return (
   return (
@@ -734,6 +1024,7 @@ export function SpoolBuddySettingsPage() {
               />
               />
             )}
             )}
             {activeTab === 'updates' && <UpdatesTab device={device} />}
             {activeTab === 'updates' && <UpdatesTab device={device} />}
+            {activeTab === 'system' && <SystemTab device={device} />}
           </>
           </>
         )}
         )}
       </div>
       </div>

+ 596 - 150
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -3,12 +3,34 @@ import { useOutletContext } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
-import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
+import {
+  api,
+  spoolbuddyApi,
+  type InventorySpool,
+  type LocalPreset,
+  type SlicerSetting,
+  type SpoolCatalogEntry,
+} from '../../api/client';
+import { getCurrencySymbol } from '../../utils/currency';
+import { FilamentSection } from '../../components/spool-form/FilamentSection';
+import { ColorSection } from '../../components/spool-form/ColorSection';
+import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
+import { PAProfileSection } from '../../components/spool-form/PAProfileSection';
+import type { ColorPreset, PrinterWithCalibrations, SpoolFormData } from '../../components/spool-form/types';
+import { defaultFormData, validateForm } from '../../components/spool-form/types';
+import {
+  buildFilamentOptions,
+  extractBrandsFromPresets,
+  findPresetOption,
+  loadRecentColors,
+  parsePresetName,
+  saveRecentColor,
+} from '../../components/spool-form/utils';
+import { MATERIALS } from '../../components/spool-form/constants';
 
 
 type Tab = 'existing' | 'new' | 'replace';
 type Tab = 'existing' | 'new' | 'replace';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
-
-const COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
+const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
 
 
 export function SpoolBuddyWriteTagPage() {
 export function SpoolBuddyWriteTagPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -19,16 +41,10 @@ export function SpoolBuddyWriteTagPage() {
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
   const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
   const [writeMessage, setWriteMessage] = useState('');
   const [writeMessage, setWriteMessage] = useState('');
+  const [untagging, setUntagging] = useState(false);
   const [tagOnReader, setTagOnReader] = useState(false);
   const [tagOnReader, setTagOnReader] = useState(false);
   const [tagUid, setTagUid] = useState<string | null>(null);
   const [tagUid, setTagUid] = useState<string | null>(null);
 
 
-  // New spool form state
-  const [newMaterial, setNewMaterial] = useState('PLA');
-  const [newColorName, setNewColorName] = useState('');
-  const [newColorHex, setNewColorHex] = useState('#00AE42');
-  const [newBrand, setNewBrand] = useState('');
-  const [newWeight, setNewWeight] = useState(1000);
-  const [creating, setCreating] = useState(false);
 
 
   const { data: spools = [], refetch: refetchSpools } = useQuery({
   const { data: spools = [], refetch: refetchSpools } = useQuery({
     queryKey: ['inventory-spools'],
     queryKey: ['inventory-spools'],
@@ -42,8 +58,14 @@ export function SpoolBuddyWriteTagPage() {
     refetchInterval: 5000,
     refetchInterval: 5000,
   });
   });
 
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   const device = devices[0];
   const device = devices[0];
   const deviceOnline = sbState.deviceOnline;
   const deviceOnline = sbState.deviceOnline;
+  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
 
 
   // Filter spools based on tab
   // Filter spools based on tab
   const filteredSpools = useMemo(() => {
   const filteredSpools = useMemo(() => {
@@ -51,7 +73,7 @@ export function SpoolBuddyWriteTagPage() {
     if (activeTab === 'existing') {
     if (activeTab === 'existing') {
       list = spools.filter(s => !s.tag_uid && !s.archived_at);
       list = spools.filter(s => !s.tag_uid && !s.archived_at);
     } else if (activeTab === 'replace') {
     } else if (activeTab === 'replace') {
-      list = spools.filter(s => s.tag_uid && !s.archived_at);
+      list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at);
     } else {
     } else {
       return [];
       return [];
     }
     }
@@ -157,46 +179,40 @@ export function SpoolBuddyWriteTagPage() {
     setWriteMessage('');
     setWriteMessage('');
   };
   };
 
 
-  const handleCreateAndSelect = async () => {
-    setCreating(true);
+  const handleUntagSpool = async () => {
+    if (!selectedSpool || !isReplaceTagged(selectedSpool)) return;
+    setUntagging(true);
+    setWriteStatus('idle');
+    setWriteMessage('');
     try {
     try {
-      const rgba = newColorHex.replace('#', '') + 'FF';
-      const spool = await api.createSpool({
-        material: newMaterial,
-        subtype: null,
-        color_name: newColorName || null,
-        rgba,
-        brand: newBrand || null,
-        label_weight: newWeight,
-        core_weight: 250,
-        core_weight_catalog_id: null,
-        weight_used: 0,
-        slicer_filament: null,
-        slicer_filament_name: null,
-        nozzle_temp_min: null,
-        nozzle_temp_max: null,
-        note: null,
-        added_full: true,
-        last_used: null,
-        encode_time: null,
-        tag_uid: null,
-        tray_uuid: null,
-        data_origin: null,
-        tag_type: null,
-        cost_per_kg: null,
-        last_scale_weight: null,
-        last_weighed_at: null,
+      await api.linkTagToSpool(selectedSpool.id, {
+        tag_uid: '',
+        tray_uuid: '',
+        data_origin: 'manual',
       });
       });
-      setSelectedSpool(spool);
-      refetchSpools();
+      await refetchSpools();
+      setSelectedSpool(null);
+      setWriteStatus('success');
+      setWriteMessage(t('spoolbuddy.writeTag.untagSuccess', 'Tag removed from spool'));
+      setTimeout(() => {
+        setWriteStatus('idle');
+        setWriteMessage('');
+      }, 2500);
     } catch {
     } catch {
-      setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
       setWriteStatus('error');
       setWriteStatus('error');
+      setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool'));
     } finally {
     } finally {
-      setCreating(false);
+      setUntagging(false);
     }
     }
   };
   };
 
 
+  const handleSpoolCreated = useCallback((createdSpool: InventorySpool) => {
+    setSelectedSpool(createdSpool);
+    setWriteStatus('idle');
+    setWriteMessage('');
+    void refetchSpools();
+  }, [refetchSpools]);
+
   const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
   const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
 
 
   return (
   return (
@@ -227,19 +243,9 @@ export function SpoolBuddyWriteTagPage() {
         {/* Left panel — spool list or form */}
         {/* Left panel — spool list or form */}
         <div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
         <div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
           {activeTab === 'new' ? (
           {activeTab === 'new' ? (
-            <NewSpoolForm
-              material={newMaterial}
-              setMaterial={setNewMaterial}
-              colorName={newColorName}
-              setColorName={setNewColorName}
-              colorHex={newColorHex}
-              setColorHex={setNewColorHex}
-              brand={newBrand}
-              setBrand={setNewBrand}
-              weight={newWeight}
-              setWeight={setNewWeight}
-              creating={creating}
-              onSubmit={handleCreateAndSelect}
+            <NewSpoolTouchForm
+              currencySymbol={currencySymbol}
+              onCreated={handleSpoolCreated}
               selectedSpool={selectedSpool}
               selectedSpool={selectedSpool}
               t={t}
               t={t}
             />
             />
@@ -295,7 +301,10 @@ export function SpoolBuddyWriteTagPage() {
             deviceOnline={deviceOnline}
             deviceOnline={deviceOnline}
             canWrite={!!canWrite}
             canWrite={!!canWrite}
             isReplace={activeTab === 'replace'}
             isReplace={activeTab === 'replace'}
+            canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)}
+            untagging={untagging}
             onWrite={handleWriteTag}
             onWrite={handleWriteTag}
+            onUntag={handleUntagSpool}
             onCancel={handleCancelWrite}
             onCancel={handleCancelWrite}
             onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
             onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
             t={t}
             t={t}
@@ -306,6 +315,10 @@ export function SpoolBuddyWriteTagPage() {
   );
   );
 }
 }
 
 
+function isReplaceTagged(spool: InventorySpool): boolean {
+  return !!(spool.tag_uid || spool.tray_uuid);
+}
+
 // --- Spool list item ---
 // --- Spool list item ---
 function SpoolListItem({ spool, selected, showTag, onClick }: {
 function SpoolListItem({ spool, selected, showTag, onClick }: {
   spool: InventorySpool;
   spool: InventorySpool;
@@ -358,118 +371,535 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
   );
   );
 }
 }
 
 
-// --- New spool form ---
-function NewSpoolForm({ material, setMaterial, colorName, setColorName, colorHex, setColorHex, brand, setBrand, weight, setWeight, creating, onSubmit, selectedSpool, t }: {
-  material: string;
-  setMaterial: (v: string) => void;
-  colorName: string;
-  setColorName: (v: string) => void;
-  colorHex: string;
-  setColorHex: (v: string) => void;
-  brand: string;
-  setBrand: (v: string) => void;
-  weight: number;
-  setWeight: (v: number) => void;
-  creating: boolean;
-  onSubmit: () => void;
+type NewSpoolSubTab = 'filament' | 'pa-profile';
+type NewSpoolViewMode = 'simple' | 'full';
+
+// --- New spool touch form (mirrors Add Spool fields/options in kiosk-friendly layout) ---
+function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
+  currencySymbol: string;
+  onCreated: (spool: InventorySpool) => void;
   selectedSpool: InventorySpool | null;
   selectedSpool: InventorySpool | null;
   t: (key: string, fallback: string) => string;
   t: (key: string, fallback: string) => string;
 }) {
 }) {
-  if (selectedSpool) {
-    return (
-      <div className="flex flex-col items-center justify-center h-full p-6 text-center">
-        <div
-          className="w-12 h-12 rounded-full mb-4 border border-white/10"
-          style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
-        />
-        <p className="text-white font-medium">
-          {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
-        </p>
-        {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
-        <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
-        <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
-      </div>
-    );
-  }
+  const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
+  const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
+  const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
+  const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
+  const [quickAdd, setQuickAdd] = useState(false);
+  const [quantity, setQuantity] = useState(1);
+  const [creating, setCreating] = useState(false);
+  const [createError, setCreateError] = useState<string | null>(null);
+
+  const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
+  const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
+  const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
+  const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
+  const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
+  const [colorCatalog, setColorCatalog] = useState<
+    { manufacturer: string; color_name: string; hex_color: string; material: string | null }[]
+  >([]);
+  const [presetInputValue, setPresetInputValue] = useState('');
+  const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
+
+  const [printersWithCalibrations, setPrintersWithCalibrations] = useState<PrinterWithCalibrations[]>([]);
+  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
+  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
+
+  useEffect(() => {
+    setRecentColors(loadRecentColors());
+  }, []);
+
+  useEffect(() => {
+    const fetchData = async () => {
+      // Only load full data when in full view mode
+      if (viewMode !== 'full') {
+        return;
+      }
+
+      setLoadingCloudPresets(true);
+      try {
+        const status = await api.getCloudStatus();
+        setCloudAuthenticated(status.is_authenticated);
+        if (status.is_authenticated) {
+          const presets = await api.getFilamentPresets();
+          setCloudPresets(presets);
+        }
+      } catch {
+        setCloudAuthenticated(false);
+      } finally {
+        setLoadingCloudPresets(false);
+      }
+
+      api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);
+      api.getColorCatalog().then(setColorCatalog).catch(() => undefined);
+      api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);
+
+      try {
+        const printers = await api.getPrinters();
+        const statuses = await Promise.all(printers.map(p => api.getPrinterStatus(p.id).catch(() => null)));
+        const results: PrinterWithCalibrations[] = [];
+        for (let i = 0; i < printers.length; i++) {
+          const printer = printers[i];
+          const status = statuses[i];
+          const connected = status?.connected ?? false;
+          let calibrations: PrinterWithCalibrations['calibrations'] = [];
+          if (connected) {
+            try {
+              const kRes = await api.getKProfiles(printer.id);
+              calibrations = kRes.profiles.map(p => ({
+                cali_idx: p.slot_id,
+                filament_id: p.filament_id,
+                setting_id: p.setting_id || '',
+                name: p.name,
+                k_value: parseFloat(p.k_value) || 0,
+                n_coef: parseFloat(p.n_coef) || 0,
+                extruder_id: p.extruder_id,
+                nozzle_diameter: p.nozzle_diameter,
+              }));
+            } catch {
+              // ignore per-printer unsupported profile endpoints
+            }
+          }
+          results.push({ printer: { ...printer, connected }, calibrations });
+        }
+        setPrintersWithCalibrations(results);
+      } catch {
+        // ignore calibration loading errors on kiosk form
+      }
+    };
+
+    fetchData();
+  }, [viewMode]);
+
+  useEffect(() => {
+    if (printersWithCalibrations.length > 0) {
+      setExpandedPrinters(new Set(printersWithCalibrations.map(p => String(p.printer.id))));
+    }
+  }, [printersWithCalibrations]);
+
+  const filamentOptions = useMemo(
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresets),
+    [cloudPresets, localPresets],
+  );
+
+  const selectedPresetOption = useMemo(
+    () => findPresetOption(formData.slicer_filament, filamentOptions),
+    [formData.slicer_filament, filamentOptions],
+  );
+
+  const baseAvailableBrands = useMemo(() => {
+    const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
+    const catalogBrands = colorCatalog
+      .map(entry => entry.manufacturer?.trim())
+      .filter((brand): brand is string => !!brand);
+    return Array.from(new Set<string>([...presetBrands, ...catalogBrands])).sort((a, b) => a.localeCompare(b));
+  }, [cloudPresets, localPresets, colorCatalog]);
+
+  const baseAvailableMaterials = useMemo(() => {
+    const catalogMaterials = colorCatalog
+      .map(entry => entry.material?.trim())
+      .filter((material): material is string => !!material);
+    return Array.from(new Set<string>([...MATERIALS, ...catalogMaterials])).sort((a, b) => a.localeCompare(b));
+  }, [colorCatalog]);
+
+  const brandMaterialPairs = useMemo(() => {
+    const pairs: Array<{ brand: string; material: string }> = [];
+    for (const entry of colorCatalog) {
+      const brand = entry.manufacturer?.trim();
+      const material = entry.material?.trim();
+      if (brand && material) pairs.push({ brand, material });
+    }
+    for (const preset of cloudPresets) {
+      const parsed = parsePresetName(preset.name);
+      if (parsed.brand && parsed.material) pairs.push({ brand: parsed.brand, material: parsed.material });
+    }
+    for (const preset of localPresets) {
+      const parsed = parsePresetName(preset.name);
+      const brand = preset.filament_vendor?.trim() || parsed.brand;
+      const material = parsed.material;
+      if (brand && material) pairs.push({ brand, material });
+    }
+    return pairs;
+  }, [cloudPresets, colorCatalog, localPresets]);
+
+  const brandToMaterials = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    for (const pair of brandMaterialPairs) {
+      const brandKey = pair.brand.toLowerCase();
+      const materialKey = pair.material.toLowerCase();
+      if (!map.has(brandKey)) map.set(brandKey, new Set());
+      map.get(brandKey)!.add(materialKey);
+    }
+    return map;
+  }, [brandMaterialPairs]);
+
+  const materialToBrands = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    for (const pair of brandMaterialPairs) {
+      const brandKey = pair.brand.toLowerCase();
+      const materialKey = pair.material.toLowerCase();
+      if (!map.has(materialKey)) map.set(materialKey, new Set());
+      map.get(materialKey)!.add(brandKey);
+    }
+    return map;
+  }, [brandMaterialPairs]);
+
+  const availableBrands = useMemo(() => {
+    if (!formData.material) return baseAvailableBrands;
+    const materialKey = formData.material.toLowerCase();
+    const brandKeys = materialToBrands.get(materialKey);
+    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
+    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
+  }, [baseAvailableBrands, formData.material, materialToBrands]);
+
+  const availableMaterials = useMemo(() => {
+    if (!formData.brand) return baseAvailableMaterials;
+    const brandKey = formData.brand.toLowerCase();
+    const materialKeys = brandToMaterials.get(brandKey);
+    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
+    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
+  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
+
+  const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
+    setFormData(prev => ({ ...prev, [key]: value }));
+    if (errors[key]) {
+      setErrors(prev => ({ ...prev, [key]: undefined }));
+    }
+  };
+
+  const handleColorUsed = (color: ColorPreset) => {
+    setRecentColors(prev => saveRecentColor(color, prev));
+  };
+
+  const saveKProfiles = async (spoolId: number) => {
+    if (selectedProfiles.size === 0) {
+      try {
+        await api.saveSpoolKProfiles(spoolId, []);
+      } catch {
+        // ignore
+      }
+      return;
+    }
+
+    const profiles = [];
+    for (const key of selectedProfiles) {
+      const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
+      const printerId = parseInt(printerIdStr);
+      const caliIdx = parseInt(caliIdxStr);
+      const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
+
+      const pc = printersWithCalibrations.find(p => p.printer.id === printerId);
+      if (pc) {
+        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
+        if (cal) {
+          profiles.push({
+            printer_id: printerId,
+            extruder,
+            nozzle_diameter: cal.nozzle_diameter || '0.4',
+            k_value: cal.k_value,
+            name: cal.name || null,
+            cali_idx: cal.cali_idx,
+            setting_id: cal.setting_id || null,
+          });
+        }
+      }
+    }
+
+    if (profiles.length > 0) {
+      await api.saveSpoolKProfiles(spoolId, profiles);
+    }
+  };
+
+  const handleCreate = async () => {
+    setCreateError(null);
+    const validation = validateForm(formData, viewMode === 'simple' ? true : quickAdd);
+    if (!validation.isValid) {
+      setErrors(validation.errors);
+      setActiveSubTab('filament');
+      return;
+    }
+
+    const presetName = selectedPresetOption?.displayName || presetInputValue || null;
+    const payload = {
+      material: formData.material,
+      subtype: formData.subtype || null,
+      brand: formData.brand || null,
+      color_name: formData.color_name || null,
+      rgba: formData.rgba || null,
+      label_weight: formData.label_weight,
+      core_weight: formData.core_weight,
+      core_weight_catalog_id: formData.core_weight_catalog_id,
+      weight_used: formData.weight_used,
+      slicer_filament: formData.slicer_filament || null,
+      slicer_filament_name: presetName,
+      nozzle_temp_min: null,
+      nozzle_temp_max: null,
+      note: formData.note || null,
+      cost_per_kg: formData.cost_per_kg,
+      added_full: null,
+      last_used: null,
+      encode_time: null,
+      tag_uid: null,
+      tray_uuid: null,
+      data_origin: null,
+      tag_type: null,
+      last_scale_weight: null,
+      last_weighed_at: null,
+    };
+
+    setCreating(true);
+    try {
+      if (quantity > 1) {
+        const created = await api.bulkCreateSpools(payload, quantity);
+        for (const spool of created) {
+          await saveKProfiles(spool.id);
+        }
+        if (created.length > 0) onCreated(created[0]);
+      } else {
+        const created = await api.createSpool(payload);
+        await saveKProfiles(created.id);
+        onCreated(created);
+      }
+    } catch {
+      setCreateError(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  const simpleColorHex = `#${(formData.rgba || '808080FF').slice(0, 6)}`;
 
 
   return (
   return (
-    <div className="p-4 space-y-4 overflow-y-auto">
-      {/* Material */}
-      <div>
-        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
-        <select
-          value={material}
-          onChange={(e) => setMaterial(e.target.value)}
-          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
-        >
-          {COMMON_MATERIALS.map(m => <option key={m} value={m}>{m}</option>)}
-        </select>
+    <div className="p-3 space-y-3 overflow-y-auto h-full">
+      <div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+        <span className="text-sm text-zinc-200">{t('spoolbuddy.writeTag.viewMode', 'View')}</span>
+        <div className="flex rounded-lg overflow-hidden border border-bambu-dark-tertiary">
+          <button
+            type="button"
+            onClick={() => setViewMode('simple')}
+            className={`px-3 py-1.5 text-xs font-medium ${
+              viewMode === 'simple' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
+            }`}
+          >
+            {t('spoolbuddy.writeTag.simpleView', 'Simple')}
+          </button>
+          <button
+            type="button"
+            onClick={() => setViewMode('full')}
+            className={`px-3 py-1.5 text-xs font-medium ${
+              viewMode === 'full' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
+            }`}
+          >
+            {t('spoolbuddy.writeTag.fullView', 'Full')}
+          </button>
+        </div>
       </div>
       </div>
 
 
-      {/* Color name + picker */}
-      <div className="flex gap-3">
-        <div className="flex-1">
-          <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
-          <input
-            type="text"
-            value={colorName}
-            onChange={(e) => setColorName(e.target.value)}
-            placeholder="Jade White"
-            className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
-          />
-        </div>
-        <div>
-          <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
-          <input
-            type="color"
-            value={colorHex}
-            onChange={(e) => setColorHex(e.target.value)}
-            className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
-          />
-        </div>
+      {viewMode === 'simple' ? (
+        selectedSpool ? (
+          <div className="flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+            <div
+              className="w-12 h-12 rounded-full mb-4 border border-white/10"
+              style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+            />
+            <p className="text-white font-medium">
+              {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+            </p>
+            {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+            <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
+            <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
+          </div>
+        ) : (
+          <div className="p-4 space-y-4 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+            <div>
+              <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
+              <select
+                value={formData.material}
+                onChange={(e) => updateField('material', e.target.value)}
+                className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
+              >
+                {SIMPLE_COMMON_MATERIALS.map((m) => (
+                  <option key={m} value={m}>{m}</option>
+                ))}
+              </select>
+            </div>
+
+            <div className="flex gap-3">
+              <div className="flex-1">
+                <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
+                <input
+                  type="text"
+                  value={formData.color_name}
+                  onChange={(e) => updateField('color_name', e.target.value)}
+                  placeholder="Jade White"
+                  className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+                />
+              </div>
+              <div>
+                <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
+                <input
+                  type="color"
+                  value={simpleColorHex}
+                  onChange={(e) => updateField('rgba', e.target.value.replace('#', '').toUpperCase() + 'FF')}
+                  className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
+                />
+              </div>
+            </div>
+
+            <div>
+              <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
+              <input
+                type="text"
+                value={formData.brand}
+                onChange={(e) => updateField('brand', e.target.value)}
+                placeholder="Polymaker"
+                className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+
+            <div>
+              <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
+              <input
+                type="number"
+                value={formData.label_weight}
+                onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
+                min={0}
+                max={10000}
+                className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+
+            <button
+              onClick={handleCreate}
+              disabled={creating || !formData.material}
+              className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
+            >
+              {creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
+            </button>
+          </div>
+        )
+      ) : (
+        <>
+      <div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+        <span className="text-sm text-zinc-200">{t('inventory.quickAdd', 'Quick Add')}</span>
+        <button
+          type="button"
+          onClick={() => setQuickAdd((prev) => !prev)}
+          className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+            quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+          }`}
+        >
+          <span className={`inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform ${quickAdd ? 'translate-x-6' : 'translate-x-1'}`} />
+        </button>
       </div>
       </div>
 
 
-      {/* Brand */}
-      <div>
-        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
-        <input
-          type="text"
-          value={brand}
-          onChange={(e) => setBrand(e.target.value)}
-          placeholder="Polymaker"
-          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
-        />
+      <div className="flex border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+        <button
+          onClick={() => setActiveSubTab('filament')}
+          className={`flex-1 py-2.5 text-sm font-medium ${
+            activeSubTab === 'filament' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
+          }`}
+        >
+          {t('inventory.filamentInfoTab', 'Filament')}
+        </button>
+        {!quickAdd && (
+          <button
+            onClick={() => setActiveSubTab('pa-profile')}
+            className={`flex-1 py-2.5 text-sm font-medium ${
+              activeSubTab === 'pa-profile' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
+            }`}
+          >
+            {t('inventory.paProfileTab', 'PA Profile')}
+          </button>
+        )}
       </div>
       </div>
 
 
-      {/* Weight */}
-      <div>
-        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
-        <input
-          type="number"
-          value={weight}
-          onChange={(e) => setWeight(parseInt(e.target.value) || 0)}
-          min={0}
-          max={10000}
-          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
-        />
+      <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-3">
+        {activeSubTab === 'filament' ? (
+          <div className="space-y-4">
+            <FilamentSection
+              formData={formData}
+              updateField={updateField}
+              cloudAuthenticated={cloudAuthenticated}
+              loadingCloudPresets={loadingCloudPresets}
+              presetInputValue={presetInputValue}
+              setPresetInputValue={setPresetInputValue}
+              selectedPresetOption={selectedPresetOption}
+              filamentOptions={filamentOptions}
+              availableBrands={availableBrands}
+              availableMaterials={availableMaterials}
+              quickAdd={quickAdd}
+              quantity={quantity}
+              onQuantityChange={setQuantity}
+              errors={errors}
+            />
+
+            <ColorSection
+              formData={formData}
+              updateField={updateField}
+              recentColors={recentColors}
+              onColorUsed={handleColorUsed}
+              catalogColors={colorCatalog}
+            />
+
+            <AdditionalSection
+              formData={formData}
+              updateField={updateField}
+              spoolCatalog={spoolCatalog}
+              currencySymbol={currencySymbol}
+            />
+          </div>
+        ) : (
+          <PAProfileSection
+            formData={formData}
+            updateField={updateField}
+            printersWithCalibrations={printersWithCalibrations}
+            selectedProfiles={selectedProfiles}
+            setSelectedProfiles={setSelectedProfiles}
+            expandedPrinters={expandedPrinters}
+            setExpandedPrinters={setExpandedPrinters}
+          />
+        )}
       </div>
       </div>
+        </>
+      )}
 
 
-      {/* Create button */}
-      <button
-        onClick={onSubmit}
-        disabled={creating || !material}
-        className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
-      >
-        {creating
-          ? t('spoolbuddy.writeTag.creating', 'Creating...')
-          : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
-      </button>
+      {createError && (
+        <div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded-lg px-3 py-2">
+          {createError}
+        </div>
+      )}
+
+      {viewMode === 'full' && (
+        <button
+          onClick={handleCreate}
+          disabled={creating}
+          className="w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
+        >
+          {creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
+        </button>
+      )}
+
+      {viewMode === 'full' && selectedSpool && (
+        <div className="flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+          <div
+            className="w-12 h-12 rounded-full mb-4 border border-white/10"
+            style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+          />
+          <p className="text-white font-medium">
+            {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+          </p>
+          {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+          <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
+          <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }
 
 
 // --- NFC status panel ---
 // --- NFC status panel ---
-function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, onWrite, onCancel, onRetry, t }: {
+function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, canUntag, untagging, onWrite, onUntag, onCancel, onRetry, t }: {
   writeStatus: WriteStatus;
   writeStatus: WriteStatus;
   writeMessage: string;
   writeMessage: string;
   selectedSpool: InventorySpool | null;
   selectedSpool: InventorySpool | null;
@@ -478,7 +908,10 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
   deviceOnline: boolean;
   deviceOnline: boolean;
   canWrite: boolean;
   canWrite: boolean;
   isReplace: boolean;
   isReplace: boolean;
+  canUntag: boolean;
+  untagging: boolean;
   onWrite: () => void;
   onWrite: () => void;
+  onUntag: () => void;
   onCancel: () => void;
   onCancel: () => void;
   onRetry: () => void;
   onRetry: () => void;
   t: (key: string, fallback: string) => string;
   t: (key: string, fallback: string) => string;
@@ -630,6 +1063,19 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
           ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
           ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
           : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
           : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
       </button>
       </button>
+
+      {isReplace && canUntag && (
+        <button
+          onClick={onUntag}
+          disabled={untagging}
+          className="w-full py-2.5 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary disabled:opacity-40 disabled:cursor-not-allowed text-zinc-200 rounded-lg transition-colors text-sm"
+        >
+          {untagging
+            ? t('spoolbuddy.writeTag.untagging', 'Removing tag...')
+            : t('spoolbuddy.writeTag.untagSpool', 'Untag Selected Spool')}
+        </button>
+      )}
+
     </div>
     </div>
   );
   );
 }
 }

+ 1 - 0
frontend/src/utils/currency.ts

@@ -27,6 +27,7 @@ const CURRENCY_SYMBOLS: Record<string, string> = {
   RUB: '₽',
   RUB: '₽',
   HUF: 'Ft',
   HUF: 'Ft',
   ILS: '₪',
   ILS: '₪',
+  UAH: '₴',
 };
 };
 
 
 export function getCurrencySymbol(currencyCode: string): string {
 export function getCurrencySymbol(currencyCode: string): string {

+ 1 - 1
install/install.sh

@@ -532,7 +532,7 @@ RestartSec=5
 StandardOutput=journal
 StandardOutput=journal
 StandardError=journal
 StandardError=journal
 
 
-# Allow binding to privileged ports (322, 990) for Virtual Printer proxy mode
+# Allow binding to privileged ports (322, 990, 2024-2026) for Virtual Printer proxy mode
 AmbientCapabilities=CAP_NET_BIND_SERVICE
 AmbientCapabilities=CAP_NET_BIND_SERVICE
 
 
 # Security hardening
 # Security hardening

+ 11 - 30
spoolbuddy/README.md

@@ -53,18 +53,15 @@ ls /dev/i2c-*
 Add the following lines under the `[all]` section:
 Add the following lines under the `[all]` section:
 
 
 ```
 ```
-# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)
-dtparam=i2c_vc=on
+# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)
+dtparam=i2c_arm=on
 
 
 # SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)
 # SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)
 dtoverlay=spi0-0cs
 dtoverlay=spi0-0cs
 ```
 ```
 
 
-- `i2c_vc=on` enables I2C bus 0 (GPIO0/GPIO1). The default `i2c_arm` only
-  enables bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 0.
-- `spi0-0cs` disables the kernel SPI driver's automatic chip-select. We use
+- `i2c_arm=on` enables I2C bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 1.
   manual CS on GPIO23 because the driver's CS timing doesn't meet the PN5180's
   manual CS on GPIO23 because the driver's CS timing doesn't meet the PN5180's
-  requirements.
 
 
 Then reboot:
 Then reboot:
 
 
@@ -75,10 +72,10 @@ sudo reboot
 Verify after reboot:
 Verify after reboot:
 
 
 ```bash
 ```bash
-ls /dev/i2c-0
+ls /dev/i2c-1
 # Should exist
 # Should exist
 
 
-sudo i2cdetect -y 0
+sudo i2cdetect -y 1
 # Should show 0x2A (NAU7802)
 # Should show 0x2A (NAU7802)
 ```
 ```
 
 
@@ -90,9 +87,6 @@ sudo apt install python3-spidev python3-libgpiod gpiod libgpiod3 i2c-tools
 
 
 - `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access
 - `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access
 - `gpiod` — command-line GPIO tools (useful for debugging)
 - `gpiod` — command-line GPIO tools (useful for debugging)
-- `i2c-tools` — I2C diagnostic tools (`i2cdetect`, `i2cget`, etc.)
-
-#### 4. Install Python dependencies (in venv)
 
 
 ```bash
 ```bash
 pip install spidev gpiod smbus2
 pip install spidev gpiod smbus2
@@ -100,9 +94,6 @@ pip install spidev gpiod smbus2
 
 
 - `spidev` — Python SPI bindings (PN5180 NFC reader)
 - `spidev` — Python SPI bindings (PN5180 NFC reader)
 - `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5)
 - `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5)
-- `smbus2` — Python I2C bindings (NAU7802 scale)
-
-#### 5. Solder all connections
 
 
 Wago connectors or breadboard jumpers are unreliable for SPI — the PN5180
 Wago connectors or breadboard jumpers are unreliable for SPI — the PN5180
 is very sensitive to signal integrity issues (loose connections cause RF
 is very sensitive to signal integrity issues (loose connections cause RF
@@ -132,7 +123,7 @@ Place a tag on the reader. Supported tag types:
 |---------------------|--------|------------------------------|
 |---------------------|--------|------------------------------|
 | MIFARE Classic 1K   | `0x08` | Bambu Lab filament tags      |
 | MIFARE Classic 1K   | `0x08` | Bambu Lab filament tags      |
 | MIFARE Classic 4K   | `0x18` | Bambu Lab filament tags      |
 | MIFARE Classic 4K   | `0x18` | Bambu Lab filament tags      |
-| NTAG (213/215/216)  | `0x00` | SpoolEase / OpenPrintTag     |
+| NTAG (213/215/216)  | `0x00` / `0x04` | SpoolEase / OpenPrintTag     |
 
 
 ### Troubleshooting
 ### Troubleshooting
 
 
@@ -150,34 +141,24 @@ Place a tag on the reader. Supported tag types:
 
 
 - SPI speed: **500 kHz** (higher speeds cause communication errors)
 - SPI speed: **500 kHz** (higher speeds cause communication errors)
 - SPI mode: **0** (CPOL=0, CPHA=0)
 - SPI mode: **0** (CPOL=0, CPHA=0)
-- CS timing: **5µs** setup after CS LOW, **100µs** hold after CS HIGH
-- BUSY handshake: wait for BUSY **HIGH** (processing started) then **LOW** (done) — waiting only for LOW is incorrect
-- `setTransceiveMode()`: must write `0x03` to SYSTEM_CONFIG bits 0-2 before every `SEND_DATA`, or the PN5180 buffers data but never transmits on RF
-- Bambu tags use **MIFARE Classic** with per-sector keys derived via **HKDF-SHA256** from a master key + tag UID
-- NTAG reads require **CRC disabled** (unlike MIFARE Classic which needs CRC enabled)
-- The PN5180 handles Crypto1 encryption/decryption internally via the `MFC_AUTHENTICATE` (0x0C) host command
-
----
 
 
-## NAU7802 Scale (I2C)
 
 
 ### Wiring
 ### Wiring
 
 
 | NAU7802 Pin | Raspberry Pi Pin | GPIO   | Wire Color |
 | NAU7802 Pin | Raspberry Pi Pin | GPIO   | Wire Color |
 |-------------|------------------|--------|------------|
 |-------------|------------------|--------|------------|
 | VCC         | Pin 1            | —      | Red        |
 | VCC         | Pin 1            | —      | Red        |
-| SDA         | Pin 27           | GPIO 0 | Yellow     |
-| SCL         | Pin 28           | GPIO 1 | White      |
+| SDA         | Pin 3            | GPIO 2 | Yellow     |
+| SCL         | Pin 5            | GPIO 3 | White      |
 | GND         | Pin 30           | —      | Black      |
 | GND         | Pin 30           | —      | Black      |
 
 
-> **I2C Bus:** Uses I2C bus 0 (GPIO0/GPIO1), enabled via `dtparam=i2c_vc=on`
-> in config.txt. Bus 1 (GPIO2/GPIO3) is the default but those pins are not
-> used here.
+> **I2C Bus:** Uses I2C bus 1 (GPIO2/GPIO3), enabled via `dtparam=i2c_arm=on`
+> in config.txt.
 
 
 ### Verify
 ### Verify
 
 
 ```bash
 ```bash
-sudo i2cdetect -y 0
+sudo i2cdetect -y 1
 # Should show 0x2A
 # Should show 0x2A
 
 
 sudo python3 spoolbuddy/scale_diag.py
 sudo python3 spoolbuddy/scale_diag.py

+ 51 - 9
spoolbuddy/daemon/api_client.py

@@ -69,6 +69,7 @@ class APIClient:
         calibration_factor: float = 1.0,
         calibration_factor: float = 1.0,
         nfc_reader_type: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
         nfc_connection: str | None = None,
+        backend_url: str | None = None,
         has_backlight: bool = False,
         has_backlight: bool = False,
     ) -> dict | None:
     ) -> dict | None:
         while True:
         while True:
@@ -85,6 +86,7 @@ class APIClient:
                     "calibration_factor": calibration_factor,
                     "calibration_factor": calibration_factor,
                     "nfc_reader_type": nfc_reader_type,
                     "nfc_reader_type": nfc_reader_type,
                     "nfc_connection": nfc_connection,
                     "nfc_connection": nfc_connection,
+                    "backend_url": backend_url,
                     "has_backlight": has_backlight,
                     "has_backlight": has_backlight,
                 },
                 },
             )
             )
@@ -105,18 +107,24 @@ class APIClient:
         firmware_version: str | None = None,
         firmware_version: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
         nfc_connection: str | None = None,
+        backend_url: str | None = None,
+        system_stats: dict | None = None,
     ) -> dict | None:
     ) -> dict | None:
+        payload: dict = {
+            "nfc_ok": nfc_ok,
+            "scale_ok": scale_ok,
+            "uptime_s": uptime_s,
+            "ip_address": ip_address,
+            "firmware_version": firmware_version,
+            "nfc_reader_type": nfc_reader_type,
+            "nfc_connection": nfc_connection,
+            "backend_url": backend_url,
+        }
+        if system_stats is not None:
+            payload["system_stats"] = system_stats
         result = await self._post(
         result = await self._post(
             f"/devices/{device_id}/heartbeat",
             f"/devices/{device_id}/heartbeat",
-            {
-                "nfc_ok": nfc_ok,
-                "scale_ok": scale_ok,
-                "uptime_s": uptime_s,
-                "ip_address": ip_address,
-                "firmware_version": firmware_version,
-                "nfc_reader_type": nfc_reader_type,
-                "nfc_connection": nfc_connection,
-            },
+            payload,
         )
         )
         if result and self._buffer:
         if result and self._buffer:
             await self._flush_buffer()
             await self._flush_buffer()
@@ -188,3 +196,37 @@ class APIClient:
             f"/devices/{device_id}/update-status",
             f"/devices/{device_id}/update-status",
             {"status": status, "message": message},
             {"status": status, "message": message},
         )
         )
+
+    async def diagnostic_result(
+        self,
+        device_id: str,
+        diagnostic: str,
+        success: bool,
+        output: str,
+        exit_code: int,
+    ) -> dict | None:
+        return await self._post(
+            f"/diagnostics/{device_id}/result",
+            {
+                "diagnostic": diagnostic,
+                "success": success,
+                "output": output,
+                "exit_code": exit_code,
+            },
+        )
+
+    async def system_command_result(
+        self,
+        device_id: str,
+        command: str,
+        success: bool,
+        message: str | None = None,
+    ) -> dict | None:
+        return await self._post(
+            f"/devices/{device_id}/system/command-result",
+            {
+                "command": command,
+                "success": success,
+                "message": message,
+            },
+        )

+ 206 - 118
spoolbuddy/daemon/main.py

@@ -3,16 +3,14 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-import shutil
+import os
 import socket
 import socket
+import subprocess
 import sys
 import sys
 import time
 import time
 from pathlib import Path
 from pathlib import Path
 
 
-# Add scripts/ to sys.path so hardware drivers (read_tag, scale_diag) are importable
-sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
-
-from . import __version__
+from . import __version__, system_stats
 from .api_client import APIClient
 from .api_client import APIClient
 from .config import Config
 from .config import Config
 from .display_control import DisplayControl
 from .display_control import DisplayControl
@@ -25,6 +23,36 @@ logging.basicConfig(
     datefmt="%H:%M:%S",
     datefmt="%H:%M:%S",
 )
 )
 logger = logging.getLogger("spoolbuddy")
 logger = logging.getLogger("spoolbuddy")
+logging.getLogger("daemon.pn5180").setLevel(logging.DEBUG)
+
+
+def _spoolbuddy_env_path() -> Path:
+    # installer writes this at <install>/spoolbuddy/.env; allow override for custom setups/tests
+    override = os.environ.get("SPOOLBUDDY_ENV_FILE", "").strip()
+    if override:
+        return Path(override)
+    return Path(__file__).resolve().parent.parent / ".env"
+
+
+def _set_env_value(path: Path, key: str, value: str):
+    lines: list[str] = []
+    if path.exists():
+        lines = path.read_text(encoding="utf-8").splitlines()
+
+    updated = False
+    new_lines: list[str] = []
+    for line in lines:
+        if line.startswith(f"{key}="):
+            new_lines.append(f"{key}={value}")
+            updated = True
+        else:
+            new_lines.append(line)
+
+    if not updated:
+        new_lines.append(f"{key}={value}")
+
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
 
 
 
 
 def _get_ip() -> str:
 def _get_ip() -> str:
@@ -38,16 +66,45 @@ def _get_ip() -> str:
         return "unknown"
         return "unknown"
 
 
 
 
+def _deploy_ssh_key(public_key: str) -> None:
+    """Write Bambuddy's SSH public key to authorized_keys if not already present."""
+    home = Path.home()
+    ssh_dir = home / ".ssh"
+    auth_keys = ssh_dir / "authorized_keys"
+
+    try:
+        ssh_dir.mkdir(mode=0o700, exist_ok=True)
+
+        # Check if key already deployed
+        if auth_keys.exists():
+            existing = auth_keys.read_text()
+            if public_key.strip() in existing:
+                return
+
+        # Append key
+        with auth_keys.open("a") as f:
+            f.write(public_key.strip() + "\n")
+        auth_keys.chmod(0o600)
+        logger.info("SSH public key deployed to %s", auth_keys)
+    except Exception as e:
+        logger.warning("Failed to deploy SSH key: %s", e)
+
+
 async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
 async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
     """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
     """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
-    nfc: NFCReader = shared["nfc"]
     display: DisplayControl = shared["display"]
     display: DisplayControl = shared["display"]
-    if not nfc.ok:
-        logger.warning("NFC reader not available, skipping NFC polling")
-        return
 
 
     try:
     try:
         while True:
         while True:
+            if shared.get("nfc_scan_paused", False):
+                await asyncio.sleep(config.nfc_poll_interval)
+                continue
+
+            nfc: NFCReader | None = shared.get("nfc")
+            if not nfc or not nfc.ok:
+                await asyncio.sleep(config.nfc_poll_interval)
+                continue
+
             event_type, event_data = await asyncio.to_thread(nfc.poll)
             event_type, event_data = await asyncio.to_thread(nfc.poll)
 
 
             if event_type == "tag_detected":
             if event_type == "tag_detected":
@@ -67,21 +124,41 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
 
 
             # Check for pending write command
             # Check for pending write command
             pending = shared.get("pending_write")
             pending = shared.get("pending_write")
-            if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00:
-                logger.info("Executing pending tag write for spool %d", pending["spool_id"])
-                success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
-                await api.write_tag_result(
-                    device_id=config.device_id,
-                    spool_id=pending["spool_id"],
-                    tag_uid=nfc.current_uid or "",
-                    success=success,
-                    message=msg,
-                )
-                shared.pop("pending_write", None)
+            if pending and nfc.state == NFCState.TAG_PRESENT:
+                if nfc.current_sak in (0x00, 0x04):
+                    logger.info("Executing pending tag write for spool %d", pending["spool_id"])
+                    success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
+                    await api.write_tag_result(
+                        device_id=config.device_id,
+                        spool_id=pending["spool_id"],
+                        tag_uid=nfc.current_uid or "",
+                        success=success,
+                        message=msg,
+                    )
+                    shared.pop("pending_write", None)
+                else:
+                    # Fail fast when a non-NTAG is presented during write mode.
+                    # Without this, UI can appear stuck on "waiting for SpoolBuddy".
+                    sak = nfc.current_sak
+                    await api.write_tag_result(
+                        device_id=config.device_id,
+                        spool_id=pending["spool_id"],
+                        tag_uid=nfc.current_uid or "",
+                        success=False,
+                        message=f"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.",
+                    )
+                    logger.warning(
+                        "Write aborted for spool %d: incompatible tag type SAK=0x%02X",
+                        pending["spool_id"],
+                        sak,
+                    )
+                    shared.pop("pending_write", None)
 
 
             await asyncio.sleep(config.nfc_poll_interval)
             await asyncio.sleep(config.nfc_poll_interval)
     finally:
     finally:
-        nfc.close()
+        nfc: NFCReader | None = shared.get("nfc")
+        if nfc:
+            nfc.close()
 
 
 
 
 async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
 async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
@@ -123,93 +200,6 @@ async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
         scale.close()
         scale.close()
 
 
 
 
-async def _perform_update(config: Config, api: APIClient):
-    """Pull latest code from git, install deps, then exit for systemd restart."""
-    # Determine repo root (install path) — daemon runs from <repo>/spoolbuddy/
-    repo_root = Path(__file__).resolve().parent.parent.parent
-
-    await api.report_update_status(config.device_id, "updating", "Fetching latest code...")
-
-    git_path = shutil.which("git") or "/usr/bin/git"
-    git_config = ["-c", f"safe.directory={repo_root}"]
-
-    # git fetch origin main
-    proc = await asyncio.create_subprocess_exec(
-        git_path,
-        *git_config,
-        "fetch",
-        "origin",
-        "main",
-        cwd=str(repo_root),
-        stdout=asyncio.subprocess.PIPE,
-        stderr=asyncio.subprocess.PIPE,
-    )
-    _, stderr = await proc.communicate()
-    if proc.returncode != 0:
-        msg = f"git fetch failed: {stderr.decode()[:200]}"
-        logger.error(msg)
-        await api.report_update_status(config.device_id, "error", msg)
-        return
-
-    await api.report_update_status(config.device_id, "updating", "Applying update...")
-
-    # git reset --hard origin/main
-    proc = await asyncio.create_subprocess_exec(
-        git_path,
-        *git_config,
-        "reset",
-        "--hard",
-        "origin/main",
-        cwd=str(repo_root),
-        stdout=asyncio.subprocess.PIPE,
-        stderr=asyncio.subprocess.PIPE,
-    )
-    _, stderr = await proc.communicate()
-    if proc.returncode != 0:
-        msg = f"git reset failed: {stderr.decode()[:200]}"
-        logger.error(msg)
-        await api.report_update_status(config.device_id, "error", msg)
-        return
-
-    await api.report_update_status(config.device_id, "updating", "Installing dependencies...")
-
-    # pip install daemon deps (use the venv pip)
-    venv_pip = repo_root / "spoolbuddy" / "venv" / "bin" / "pip"
-    pip_packages = ["spidev", "gpiod", "smbus2", "httpx"]
-
-    if venv_pip.exists():
-        proc = await asyncio.create_subprocess_exec(
-            str(venv_pip),
-            "install",
-            "--upgrade",
-            *pip_packages,
-            cwd=str(repo_root),
-            stdout=asyncio.subprocess.PIPE,
-            stderr=asyncio.subprocess.PIPE,
-        )
-    else:
-        proc = await asyncio.create_subprocess_exec(
-            sys.executable,
-            "-m",
-            "pip",
-            "install",
-            "--upgrade",
-            *pip_packages,
-            cwd=str(repo_root),
-            stdout=asyncio.subprocess.PIPE,
-            stderr=asyncio.subprocess.PIPE,
-        )
-    await proc.communicate()
-    if proc.returncode != 0:
-        logger.warning("pip install returned non-zero (continuing anyway)")
-
-    await api.report_update_status(config.device_id, "complete", "Update complete, restarting...")
-    logger.info("Update complete, exiting for systemd restart")
-
-    # Exit cleanly — systemd Restart=always will bring us back with the new code
-    sys.exit(0)
-
-
 async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
 async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
     """Periodic heartbeat to keep device registered and pick up commands."""
     """Periodic heartbeat to keep device registered and pick up commands."""
     display: DisplayControl = shared["display"]
     display: DisplayControl = shared["display"]
@@ -221,6 +211,7 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
         nfc = shared.get("nfc")
         nfc = shared.get("nfc")
         scale = shared.get("scale")
         scale = shared.get("scale")
         uptime = int(time.monotonic() - start_time)
         uptime = int(time.monotonic() - start_time)
+        stats = await asyncio.to_thread(system_stats.collect)
         result = await api.heartbeat(
         result = await api.heartbeat(
             device_id=config.device_id,
             device_id=config.device_id,
             nfc_ok=nfc.ok if nfc else False,
             nfc_ok=nfc.ok if nfc else False,
@@ -230,19 +221,13 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
             firmware_version=__version__,
             firmware_version=__version__,
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
+            backend_url=config.backend_url,
+            system_stats=stats,
         )
         )
 
 
         if result:
         if result:
             cmd = result.get("pending_command")
             cmd = result.get("pending_command")
-            if cmd == "update":
-                logger.info("Update command received, starting update...")
-                try:
-                    await _perform_update(config, api)
-                except Exception as e:
-                    logger.error("Update failed: %s", e)
-                    await api.report_update_status(config.device_id, "error", str(e)[:255])
-                continue
-            elif cmd == "tare":
+            if cmd == "tare":
                 scale = shared.get("scale")
                 scale = shared.get("scale")
                 if scale and scale.ok:
                 if scale and scale.ok:
                     new_offset = await asyncio.to_thread(scale.tare)
                     new_offset = await asyncio.to_thread(scale.tare)
@@ -253,6 +238,103 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
                     logger.warning("Tare command received but scale not available")
                     logger.warning("Tare command received but scale not available")
                 # Skip calibration sync — this heartbeat response predates the tare
                 # Skip calibration sync — this heartbeat response predates the tare
                 continue
                 continue
+            elif cmd == "apply_system_config":
+                payload = result.get("pending_system_payload") or {}
+                backend_url = str(payload.get("backend_url", "")).strip()
+                api_key_value = payload.get("api_key")
+                api_key = str(api_key_value).strip() if api_key_value is not None else ""
+
+                if not backend_url:
+                    await api.system_command_result(
+                        config.device_id,
+                        "apply_system_config",
+                        False,
+                        "Missing backend_url payload",
+                    )
+                    continue
+
+                try:
+                    env_path = _spoolbuddy_env_path()
+                    await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_BACKEND_URL", backend_url)
+                    if api_key:
+                        await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_API_KEY", api_key)
+
+                    await api.system_command_result(
+                        config.device_id,
+                        "apply_system_config",
+                        True,
+                        f"Updated {env_path}",
+                    )
+
+                    logger.info("Applied system config update")
+                except Exception as e:
+                    logger.exception("Failed to apply system config")
+                    await api.system_command_result(
+                        config.device_id,
+                        "apply_system_config",
+                        False,
+                        str(e),
+                    )
+                continue
+            elif cmd in ("run_nfc_diag", "run_scale_diag", "run_read_tag_diag"):
+                if cmd == "run_scale_diag":
+                    diagnostic = "scale"
+                    script_name = "scale_diag.py"
+                elif cmd == "run_read_tag_diag":
+                    diagnostic = "read_tag"
+                    script_name = "read_tag.py"
+                else:
+                    diagnostic = "nfc"
+                    script_name = "pn5180_diag.py"
+                script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name
+
+                if diagnostic in ("nfc", "read_tag"):
+                    logger.info("Pausing NFC continuous scan for diagnostic")
+                    shared["nfc_scan_paused"] = True
+                    nfc_for_diag = shared.get("nfc")
+                    if nfc_for_diag:
+                        await asyncio.to_thread(nfc_for_diag.close)
+                        shared["nfc"] = None
+
+                logger.info("Running %s diagnostic via %s", diagnostic, script_path)
+                try:
+                    proc = await asyncio.to_thread(
+                        subprocess.run,
+                        [sys.executable, str(script_path)],
+                        capture_output=True,
+                        text=True,
+                        timeout=45,
+                    )
+                    output = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
+                    await api.diagnostic_result(
+                        config.device_id,
+                        diagnostic,
+                        proc.returncode == 0,
+                        output,
+                        proc.returncode,
+                    )
+                except subprocess.TimeoutExpired:
+                    await api.diagnostic_result(
+                        config.device_id,
+                        diagnostic,
+                        False,
+                        "Diagnostic timed out after 45 seconds",
+                        -1,
+                    )
+                except Exception as e:
+                    await api.diagnostic_result(
+                        config.device_id,
+                        diagnostic,
+                        False,
+                        f"Diagnostic execution failed: {e}",
+                        -1,
+                    )
+                finally:
+                    if diagnostic in ("nfc", "read_tag"):
+                        logger.info("Reinitializing NFC continuous scan after diagnostic")
+                        shared["nfc"] = NFCReader()
+                        shared["nfc_scan_paused"] = False
+                continue
             elif cmd == "write_tag":
             elif cmd == "write_tag":
                 write_payload = result.get("pending_write_payload")
                 write_payload = result.get("pending_write_payload")
                 if write_payload:
                 if write_payload:
@@ -313,6 +395,7 @@ async def main():
         calibration_factor=config.calibration_factor,
         calibration_factor=config.calibration_factor,
         nfc_reader_type=nfc.reader_type,
         nfc_reader_type=nfc.reader_type,
         nfc_connection=nfc.connection,
         nfc_connection=nfc.connection,
+        backend_url=config.backend_url,
         has_backlight=display.has_backlight,
         has_backlight=display.has_backlight,
     )
     )
 
 
@@ -322,9 +405,14 @@ async def main():
         config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
         config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
         scale.update_calibration(config.tare_offset, config.calibration_factor)
         scale.update_calibration(config.tare_offset, config.calibration_factor)
 
 
+        # Auto-deploy Bambuddy's SSH public key for remote updates
+        ssh_key = reg.get("ssh_public_key")
+        if ssh_key:
+            _deploy_ssh_key(ssh_key)
+
     logger.info("Device registered, starting poll loops")
     logger.info("Device registered, starting poll loops")
 
 
-    shared: dict = {"nfc": nfc, "scale": scale, "display": display}
+    shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False}
     try:
     try:
         await asyncio.gather(
         await asyncio.gather(
             nfc_poll_loop(config, api, shared),
             nfc_poll_loop(config, api, shared),

+ 238 - 0
spoolbuddy/daemon/nau7802.py

@@ -0,0 +1,238 @@
+"""NAU7802 24-bit ADC driver for load cell / scale applications.
+
+I2C address: 0x2A
+Bus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)
+"""
+
+import logging
+import os
+import struct
+import time
+
+import smbus2
+
+logger = logging.getLogger(__name__)
+
+
+def _env_int(name: str, default: int) -> int:
+    value = os.environ.get(name)
+    if value is None or value == "":
+        return default
+    try:
+        return int(value)
+    except ValueError:
+        return default
+
+
+I2C_BUS = _env_int("SPOOLBUDDY_I2C_BUS", 1)
+NAU7802_ADDR = 0x2A
+
+# Register addresses
+REG_PU_CTRL = 0x00
+REG_CTRL1 = 0x01
+REG_CTRL2 = 0x02
+REG_ADCO_B2 = 0x12  # ADC output MSB
+REG_ADCO_B1 = 0x13
+REG_ADCO_B0 = 0x14  # ADC output LSB
+REG_ADC = 0x15
+REG_PGA = 0x1B
+REG_PWR_CTRL = 0x1C
+REG_REVISION = 0x1F
+
+# PU_CTRL bits
+PU_RR = 0x01  # Register reset
+PU_PUD = 0x02  # Power up digital
+PU_PUA = 0x04  # Power up analog
+PU_PUR = 0x08  # Power up ready (read-only)
+PU_CS = 0x10  # Cycle start
+PU_CR = 0x20  # Cycle ready (read-only)
+PU_OSCS = 0x40  # Oscillator select
+PU_AVDDS = 0x80  # AVDD source select
+
+
+class NAU7802:
+    def __init__(self, bus: int = I2C_BUS, addr: int = NAU7802_ADDR):
+        self._bus_num = bus
+        self._bus = smbus2.SMBus(bus)
+        self._addr = addr
+
+    # CTRL2 bits for AFE calibration
+    _CTRL2_CALS = 1 << 2
+    _CTRL2_CAL_ERROR = 1 << 3
+
+    def close(self):
+        self._bus.close()
+
+    def read_reg(self, reg: int) -> int:
+        return self._bus.read_byte_data(self._addr, reg)
+
+    def write_reg(self, reg: int, val: int):
+        self._bus.write_byte_data(self._addr, reg, val & 0xFF)
+
+    def _update_bits(self, reg: int, mask: int, value: int):
+        cur = self.read_reg(reg)
+        self.write_reg(reg, (cur & ~mask) | (value & mask))
+
+    def _set_bit(self, reg: int, bit: int, enabled: bool):
+        mask = 1 << bit
+        self._update_bits(reg, mask, mask if enabled else 0)
+
+    def _set_field(self, reg: int, shift: int, width: int, value: int):
+        mask = ((1 << width) - 1) << shift
+        self._update_bits(reg, mask, value << shift)
+
+    def init(self):
+        """Initialize NAU7802 per datasheet power-on sequencing (Section 8.1).
+
+        Datasheet steps:
+          1. RR=1 (reset all registers)
+          2. RR=0, PUD=1 (enter normal operation; PUD auto-starts AD conversion)
+          3. Wait ~200µs for PUR=1
+          4. Configure (LDO, gain, rate, etc.)
+          5. Tuning (ADC chopper, PGA caps)
+          6. (Optional) calibration and flush transients
+        """
+
+        # Step 1: Reset (set RR=1, then RR=0)
+        self._set_bit(REG_PU_CTRL, 0, True)  # RR=1
+        time.sleep(0.010)
+        self._set_bit(REG_PU_CTRL, 0, False)  # RR=0 exits reset
+        # Datasheet says "about 200 microseconds" before PUR is set
+        time.sleep(0.001)
+
+        # Step 2: Power up digital (PUD=1 auto-starts AD conversion)
+        self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
+        # Step 2b: Power up analog (PUA=1)
+        self._set_bit(REG_PU_CTRL, 2, True)  # PUA=1
+        time.sleep(0.600)  # Wait for LDO and analog section to stabilize
+
+        # Step 3: Wait for power-up ready (PUR bit 3)
+
+        for _ in range(100):
+            status = self.read_reg(REG_PU_CTRL)
+            if status & PU_PUR:
+                logger.debug("  Power-up ready")
+                break
+            time.sleep(0.001)
+        else:
+            raise TimeoutError("NAU7802 power-up timeout (PUR bit not set)")
+
+        # Check revision register low nibble (datasheet expects 0xF).
+
+        revision = self.read_reg(REG_REVISION)
+        logger.debug(f"  Revision: 0x{revision:02X}")
+        if (revision & 0x0F) != 0x0F:
+            raise RuntimeError(f"Unexpected NAU7802 revision: 0x{revision:02X} (expected 0x_F)")
+
+        # Step 4: Configure device
+        # Internal LDO enable (AVDDS=1, bit 7) and set voltage to 3.0V
+
+        self._set_bit(REG_PU_CTRL, 7, True)  # AVDDS=1
+        self._set_field(REG_CTRL1, shift=3, width=3, value=0b101)  # VLDO=3.0V
+        logger.debug("  LDO: 3.0V (internal)")
+
+        # Set gain to 128x (CTRL1 bits 2:0 = 0b111)
+
+        self._set_field(REG_CTRL1, shift=0, width=3, value=0b111)
+        logger.debug("  Gain: 128x")
+
+        # Set sample rate to 10 SPS (CTRL2 bits 6:4 = 0b000)
+        # Note: At 10 SPS, each sample takes ~100ms; first 4 samples = ~400ms to settle
+
+        self._set_field(REG_CTRL2, shift=4, width=3, value=0b000)
+        logger.debug("  Sample rate: 10 SPS")
+
+        # Step 5: Tuning per application notes
+        # Disable ADC chopper clock (ADC bits 5:4 = 0b11)
+        self._set_field(REG_ADC, shift=4, width=2, value=0b11)
+        # Enable low-ESR caps on PGA (PGA bit 6 = 0 for improved accuracy)
+        self._set_bit(REG_PGA, 6, False)
+
+        # Step 6: Trigger fresh AD conversion and wait for first result
+        # CS bit transition 0→1 starts fresh conversion; takes ~4-sample time for result
+
+        self._set_bit(REG_PU_CTRL, 4, True)  # CS=1
+        logger.debug("  Conversion started")
+
+        # Flush startup transients before calibration
+        # At 10 SPS, initial 4 samples may contain settling artifacts
+
+        self.flush_readings(count=4, timeout_s=1.5)
+
+        # Run AFE calibration (internal mode), then flush result
+        self.calibrate_afe(timeout_ms=1000, mode=0)
+        self.flush_readings(count=2, timeout_s=1.0)
+        logger.debug("  Initialization complete")
+
+    def begin_calibrate_afe(self, mode: int = 0) -> None:
+        """Start asynchronous AFE calibration.
+
+        mode values match NAU7802 CALMOD: 0=internal, 1=offset, 2=gain.
+        """
+        ctrl2 = self.read_reg(REG_CTRL2)
+        ctrl2 &= 0xFC  # clear CALMOD bits[1:0]
+        ctrl2 |= mode & 0x03
+        self.write_reg(REG_CTRL2, ctrl2)
+
+        # Set CALS (bit 2) to start calibration.
+        self.write_reg(REG_CTRL2, self.read_reg(REG_CTRL2) | self._CTRL2_CALS)
+
+    def wait_for_calibrate_afe(self, timeout_ms: int = 1000) -> bool:
+        deadline = time.monotonic() + (timeout_ms / 1000.0) if timeout_ms > 0 else None
+
+        while True:
+            ctrl2 = self.read_reg(REG_CTRL2)
+            if (ctrl2 & self._CTRL2_CALS) == 0:
+                return (ctrl2 & self._CTRL2_CAL_ERROR) == 0
+
+            if deadline is not None and time.monotonic() >= deadline:
+                return False
+            time.sleep(0.001)
+
+    def calibrate_afe(self, timeout_ms: int = 1000, mode: int = 0) -> None:
+        """Run AFE calibration per datasheet CTRL2[2] CALS bit sequence.
+
+        Datasheet says:
+          - Write 1 to CALS to start (mode in CALMOD bits [1:0])
+          - CALS=1 during calibration, 0 when complete
+          - Check CAL_ERR bit after completion
+        """
+        self.begin_calibrate_afe(mode=mode)
+        if not self.wait_for_calibrate_afe(timeout_ms=timeout_ms):
+            raise RuntimeError(f"NAU7802 AFE calibration timed out after {timeout_ms}ms")
+        # Check CAL_ERR bit to ensure no error during calibration
+        ctrl2 = self.read_reg(REG_CTRL2)
+        if ctrl2 & self._CTRL2_CAL_ERROR:
+            raise RuntimeError("NAU7802 AFE calibration completed with CAL_ERR set")
+        logger.debug("  AFE calibration: OK")
+
+    def wait_data_ready(self, timeout_s: float = 1.0) -> bool:
+        deadline = time.monotonic() + timeout_s
+        while time.monotonic() < deadline:
+            if self.data_ready():
+                return True
+            time.sleep(0.001)
+        return False
+
+    def flush_readings(self, count: int = 4, timeout_s: float = 1.0) -> None:
+        flushed = 0
+        while flushed < count:
+            if not self.wait_data_ready(timeout_s=timeout_s):
+                raise TimeoutError("Timeout while flushing startup scale readings")
+            _ = self.read_raw()
+            flushed += 1
+
+    def data_ready(self) -> bool:
+        return bool(self.read_reg(REG_PU_CTRL) & PU_CR)
+
+    def read_raw(self) -> int:
+        """Read 24-bit signed ADC value."""
+        b2 = self.read_reg(REG_ADCO_B2)
+        b1 = self.read_reg(REG_ADCO_B1)
+        b0 = self.read_reg(REG_ADCO_B0)
+        raw = (b2 << 16) | (b1 << 8) | b0
+        # Sign extend 24-bit to 32-bit
+        if raw & 0x800000:
+            raw |= 0xFF000000
+            raw = struct.unpack("i", struct.pack("I", raw))[0]
+        return raw

+ 18 - 7
spoolbuddy/daemon/nfc_reader.py

@@ -28,7 +28,7 @@ class NFCReader:
         self._last_status_log = 0.0
         self._last_status_log = 0.0
 
 
         try:
         try:
-            from read_tag import PN5180
+            from .pn5180 import PN5180
 
 
             self._nfc = PN5180()
             self._nfc = PN5180()
             self._init_rf()
             self._init_rf()
@@ -98,7 +98,7 @@ class NFCReader:
         """
         """
         if self._state != NFCState.TAG_PRESENT:
         if self._state != NFCState.TAG_PRESENT:
             return False, "No tag present"
             return False, "No tag present"
-        if self._current_sak != 0x00:
+        if self._current_sak not in (0x00, 0x04):
             return False, f"Not an NTAG (SAK=0x{self._current_sak:02X})"
             return False, f"Not an NTAG (SAK=0x{self._current_sak:02X})"
         if not self._nfc:
         if not self._nfc:
             return False, "NFC reader not available"
             return False, "NFC reader not available"
@@ -205,7 +205,7 @@ class NFCReader:
 
 
                 # Try reading Bambu tag data
                 # Try reading Bambu tag data
                 tray_uuid = None
                 tray_uuid = None
-                tag_type = "mifare_classic" if sak in (0x08, 0x18) else "ntag" if sak == 0x00 else "unknown"
+                tag_type = "mifare_classic" if sak in (0x08, 0x18) else "ntag" if sak in (0x00, 0x04) else "unknown"
 
 
                 if sak in (0x08, 0x18):
                 if sak in (0x08, 0x18):
                     blocks = self._nfc.read_bambu_tag(uid_bytes)
                     blocks = self._nfc.read_bambu_tag(uid_bytes)
@@ -240,13 +240,24 @@ class NFCReader:
 
 
 def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
 def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
     """Extract tray_uuid from Bambu MIFARE Classic data blocks."""
     """Extract tray_uuid from Bambu MIFARE Classic data blocks."""
-    # Block 4-5 contain the 32-char tray UUID (first 16 bytes from block 4 + 5)
+    # Block 4-5 contain the tray UUID as 32 ASCII hex chars across 32 bytes.
     if 4 in blocks and 5 in blocks:
     if 4 in blocks and 5 in blocks:
         raw = blocks[4] + blocks[5]
         raw = blocks[4] + blocks[5]
-        # UUID is stored as ASCII hex in the first 16 bytes of blocks 4-5
-        uuid_bytes = raw[:16]
         try:
         try:
-            uuid_str = uuid_bytes.hex().upper()
+            # Preferred path: decode full ASCII payload, keep only hex chars.
+            ascii_candidate = raw.decode("ascii", errors="ignore")
+            hex_chars = "".join(ch for ch in ascii_candidate if ch in "0123456789abcdefABCDEF")
+            if len(hex_chars) >= 32:
+                uuid_str = hex_chars[:32].upper()
+                if uuid_str != "0" * 32:
+                    return uuid_str
+        except Exception:
+            pass
+
+        try:
+            # Fallback for partially decoded payloads: use first 16 raw bytes as hex.
+            # This preserves compatibility with older decoding behavior.
+            uuid_str = raw[:16].hex().upper()
             if uuid_str and uuid_str != "0" * 32:
             if uuid_str and uuid_str != "0" * 32:
                 return uuid_str
                 return uuid_str
         except Exception:
         except Exception:

+ 570 - 0
spoolbuddy/daemon/pn5180.py

@@ -0,0 +1,570 @@
+"""PN5180 NFC frontend driver — ported from working Pico firmware (pico-nfc-bridge.ino).
+
+Key learnings from pico-nfc-bridge.ino:
+- Must call setTransceiveMode() before every SEND_DATA
+- waitBusy() must wait for HIGH then LOW (not just LOW)
+- Bambu tags are MIFARE Classic 1K (ISO 14443A), not ISO 15693
+- SPI at 500kHz, 5us CS setup, 100us post-CS delay
+- MFC_AUTHENTICATE (0x0C) is a PN5180 host command — Crypto1 handled in hardware
+- HKDF-SHA256 derives per-sector keys from master key + UID
+"""
+
+import hashlib
+import hmac
+import logging
+import os
+import time
+
+import gpiod
+import spidev
+
+logger = logging.getLogger(__name__)
+
+
+def _env_int(name: str, default: int) -> int:
+    value = os.environ.get(name)
+    if value is None or value == "":
+        return default
+    try:
+        return int(value)
+    except ValueError:
+        return default
+
+
+BUSY_PIN = _env_int("SPOOLBUDDY_NFC_BUSY_PIN", 25)
+RST_PIN = _env_int("SPOOLBUDDY_NFC_RST_PIN", 24)
+NSS_PIN = _env_int("SPOOLBUDDY_NFC_NSS_PIN", 23)  # Manual CS by default
+SPI_BUS = _env_int("SPOOLBUDDY_NFC_SPI_BUS", 0)
+SPI_DEVICE = _env_int("SPOOLBUDDY_NFC_SPI_DEVICE", 0)
+SPI_SPEED_HZ = _env_int("SPOOLBUDDY_NFC_SPI_SPEED_HZ", 500_000)
+
+# Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino)
+BAMBU_MASTER_KEY = bytes(
+    [
+        0x9A,
+        0x75,
+        0x9C,
+        0xF2,
+        0xC4,
+        0xF7,
+        0xCA,
+        0xFF,
+        0x22,
+        0x2C,
+        0xB9,
+        0x76,
+        0x9B,
+        0x41,
+        0xBC,
+        0x96,
+    ]
+)
+BAMBU_CONTEXT = b"RFID-A\x00"  # 7 bytes including null terminator
+
+# Blocks to read for Bambu tag data
+BAMBU_BLOCKS = [1, 2, 4, 5]
+
+
+def hkdf_derive_keys(uid: bytes) -> bytes:
+    """Derive 96 bytes of MIFARE key material (16 sectors * 6 bytes each).
+
+    Uses HKDF-SHA256 with the Bambu master key as salt and the tag UID as IKM.
+    """
+    # HKDF-Extract: PRK = HMAC-SHA256(salt=master_key, IKM=uid)
+    prk = hmac.new(BAMBU_MASTER_KEY, uid, hashlib.sha256).digest()
+
+    # HKDF-Expand: generate 96 bytes using context "RFID-A\0"
+    okm = b""
+    t = b""
+    counter = 1
+    while len(okm) < 96:
+        t = hmac.new(prk, t + BAMBU_CONTEXT + bytes([counter]), hashlib.sha256).digest()
+        okm += t
+        counter += 1
+    return okm[:96]
+
+
+def get_sector_key(keys: bytes, block: int) -> bytes:
+    """Get the 6-byte key for the sector containing the given block."""
+    sector = block // 4
+    return keys[sector * 6 : sector * 6 + 6]
+
+
+def _find_gpio_chip():
+    for path in ["/dev/gpiochip4", "/dev/gpiochip0"]:
+        try:
+            chip = gpiod.Chip(path)
+            if "pinctrl" in chip.get_info().label:
+                return chip
+            chip.close()
+        except (FileNotFoundError, PermissionError, OSError):
+            continue
+    raise RuntimeError("No GPIO chip")
+
+
+class PN5180:
+    def __init__(self):
+        self._chip = _find_gpio_chip()
+        self._lines = self._chip.request_lines(
+            consumer="pn5180",
+            config={
+                BUSY_PIN: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT),
+                RST_PIN: gpiod.LineSettings(
+                    direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE
+                ),
+                NSS_PIN: gpiod.LineSettings(
+                    direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE
+                ),
+            },
+        )
+        self._spi = spidev.SpiDev()
+        self._spi.open(SPI_BUS, SPI_DEVICE)
+        self._spi.max_speed_hz = SPI_SPEED_HZ
+        self._spi.mode = 0b00
+        self._spi.no_cs = True
+
+    def close(self):
+        self._spi.close()
+        self._lines.release()
+        self._chip.close()
+
+    def _cs_low(self):
+        self._lines.set_value(NSS_PIN, gpiod.line.Value.INACTIVE)
+        time.sleep(0.000005)  # 5us setup
+
+    def _cs_high(self):
+        self._lines.set_value(NSS_PIN, gpiod.line.Value.ACTIVE)
+        time.sleep(0.000100)  # 100us post-CS delay
+
+    def _wait_busy(self, timeout_s=1.0):
+        """Wait for BUSY to go HIGH (processing) then LOW (done) — matches Pico firmware."""
+        deadline = time.monotonic() + min(timeout_s, 0.010)
+        # Wait for BUSY HIGH (PN5180 started processing)
+        while self._lines.get_value(BUSY_PIN) != gpiod.line.Value.ACTIVE:
+            if time.monotonic() > deadline:
+                break  # Timeout waiting for HIGH — command may have processed already
+            time.sleep(0.00001)
+        # Wait for BUSY LOW (PN5180 done)
+        deadline = time.monotonic() + timeout_s
+        while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE:
+            if time.monotonic() > deadline:
+                raise TimeoutError("BUSY timeout")
+            time.sleep(0.0001)
+
+    def _cmd(self, data):
+        self._cs_low()
+        self._spi.xfer2(list(data))
+        self._cs_high()
+        self._wait_busy()
+
+    def _read_response(self, n):
+        self._cs_low()
+        result = self._spi.xfer2([0xFF] * n)
+        self._cs_high()
+        return result
+
+    # -- Register ops --
+
+    def write_reg(self, reg, val):
+        self._cmd([0x00, reg, val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
+
+    def write_reg_or(self, reg, mask):
+        self._cmd([0x01, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF])
+
+    def write_reg_and(self, reg, mask):
+        self._cmd([0x02, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF])
+
+    def read_reg(self, reg):
+        self._cmd([0x04, reg])
+        time.sleep(0.000100)  # Extra 100us before read
+        return int.from_bytes(self._read_response(4), "little")
+
+    def read_eeprom(self, addr, length):
+        self._cmd([0x07, addr, length])
+        time.sleep(0.000100)
+        return bytes(self._read_response(length))
+
+    # -- Commands --
+
+    def reset(self):
+        self._lines.set_value(RST_PIN, gpiod.line.Value.INACTIVE)
+        time.sleep(0.050)
+        self._lines.set_value(RST_PIN, gpiod.line.Value.ACTIVE)
+        time.sleep(0.100)
+        self._wait_busy(2.0)
+        time.sleep(0.050)
+
+    def load_rf_config(self, tx, rx):
+        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs first
+        time.sleep(0.000100)
+        self._cmd([0x11, tx, rx])
+        time.sleep(0.010)
+
+    def rf_on(self):
+        self._cmd([0x16, 0x00])
+        time.sleep(0.010)
+
+    def rf_off(self):
+        self._cmd([0x17, 0x00])
+        time.sleep(0.005)
+
+    def set_pin(self, pin: int, value: bool) -> None:
+        """Set the state of a control pin (NSS or RST). Value: True=ACTIVE, False=INACTIVE."""
+        if pin not in (NSS_PIN, RST_PIN):
+            raise ValueError("Only NSS_PIN and RST_PIN can be set via set_pin().")
+        self._lines.set_value(pin, gpiod.line.Value.ACTIVE if value else gpiod.line.Value.INACTIVE)
+
+    def get_pin(self, pin: int) -> bool:
+        """Get the state of a control pin (NSS or RST). Returns True if ACTIVE, False if INACTIVE."""
+        if pin not in (NSS_PIN, RST_PIN):
+            raise ValueError("Only NSS_PIN and RST_PIN can be read via get_pin().")
+        return self._lines.get_value(pin) == gpiod.line.Value.ACTIVE
+
+    def set_transceive_mode(self):
+        """Set SYSTEM_CONFIG command bits to TRANSCEIVE (0x03) — CRITICAL!"""
+        sys_cfg = self.read_reg(0x00)
+        sys_cfg = (sys_cfg & 0xFFFFFFF8) | 0x03
+        self.write_reg(0x00, sys_cfg)
+
+    def send_data(self, data, valid_bits=0x00):
+        self._cs_low()
+        self._spi.xfer2([0x09, valid_bits] + list(data))
+        self._cs_high()
+        time.sleep(0.000100)
+        self._wait_busy()
+
+    def read_data(self, length):
+        self._cmd([0x0A, 0x00])
+        return bytes(self._read_response(length))
+
+    # -- ISO 14443A --
+
+    def activate_type_a(self):
+        """Full Type A activation: WUPA -> Anticollision -> SELECT. Returns (uid, sak) or None."""
+        # Crypto off, CRC off
+        self.write_reg_and(0x00, 0xFFFFFFBF)
+        self.write_reg_and(0x12, 0xFFFFFFFE)
+        self.write_reg_and(0x19, 0xFFFFFFFE)
+        self.write_reg(0x03, 0xFFFFFFFF)
+
+        # Reset to IDLE then TRANSCEIVE
+        sys_cfg = self.read_reg(0x00)
+        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE
+        time.sleep(0.001)
+        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE
+        time.sleep(0.002)
+
+        # WUPA (7-bit)
+        self.send_data([0x52], valid_bits=0x07)
+        time.sleep(0.005)
+
+        rx_status = self.read_reg(0x13)
+        rx_len = rx_status & 0x1FF
+        if rx_len < 2 or rx_len == 511:
+            # Try REQA
+            self.write_reg(0x03, 0xFFFFFFFF)
+            time.sleep(0.002)
+            self.set_transceive_mode()
+            time.sleep(0.002)
+            self.send_data([0x26], valid_bits=0x07)
+            time.sleep(0.005)
+            rx_status = self.read_reg(0x13)
+            rx_len = rx_status & 0x1FF
+            if rx_len < 2 or rx_len == 511:
+                return None
+
+        atqa = self.read_data(2)
+        if atqa[0] == 0xFF or atqa[0] == 0x00:
+            return None
+
+        # Anti-collision Level 1
+        self.write_reg(0x03, 0xFFFFFFFF)
+        self.set_transceive_mode()
+        time.sleep(0.002)
+
+        self.send_data([0x93, 0x20])
+        time.sleep(0.010)
+
+        rx_status = self.read_reg(0x13)
+        rx_len = rx_status & 0x1FF
+        if rx_len < 5 or rx_len > 64:
+            return None
+
+        uid_buf = self.read_data(5)
+        uid = uid_buf[:4]
+        bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]
+        if bcc != uid_buf[4]:
+            return None
+
+        # SELECT
+        self.write_reg(0x03, 0xFFFFFFFF)
+        self.set_transceive_mode()
+        time.sleep(0.002)
+
+        # Enable CRC for SELECT
+        self.write_reg_or(0x19, 0x01)
+        self.write_reg_or(0x12, 0x01)
+
+        self.send_data([0x93, 0x70, uid[0], uid[1], uid[2], uid[3], bcc])
+        time.sleep(0.010)
+
+        rx_status = self.read_reg(0x13)
+        rx_len = rx_status & 0x1FF
+        if rx_len < 1:
+            return None
+
+        sak_buf = self.read_data(min(rx_len, 3))
+        sak = sak_buf[0]
+
+        return bytes(uid), sak
+
+    # -- MIFARE Classic --
+
+    def mfc_authenticate(self, block: int, key: bytes, uid: bytes) -> bool:
+        """MIFARE Classic authentication via PN5180 MFC_AUTHENTICATE (0x0C).
+
+        The PN5180 handles Crypto1 internally. After success, bit 6 of
+        SYSTEM_CONFIG is set (MFC_CRYPTO1_ON) and all subsequent RF
+        communication is encrypted/decrypted by the hardware.
+
+        Args:
+            block: Block number to authenticate
+            key: 6-byte MIFARE Key A
+            uid: 4-byte tag UID
+        Returns:
+            True if authentication succeeded
+        """
+        # Wait for BUSY LOW before starting
+        deadline = time.monotonic() + 0.100
+        while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE:
+            if time.monotonic() > deadline:
+                return False
+            time.sleep(0.001)
+
+        # MFC_AUTHENTICATE: [0x0C][key 6B][keyType][blockNo][uid 4B] = 13 bytes
+        cmd = [0x0C] + list(key) + [0x60, block] + list(uid[:4])
+        self._cs_low()
+        self._spi.xfer2(cmd)
+        self._cs_high()
+
+        # Wait for BUSY HIGH then LOW (auth can take up to 1s)
+        self._wait_busy(timeout_s=1.0)
+
+        # Read 1-byte response: 0x00 = success
+        self._cs_low()
+        response = self._spi.xfer2([0xFF])
+        self._cs_high()
+
+        return response[0] == 0x00
+
+    def mfc_read_block(self, block: int) -> bytes | None:
+        """Read a 16-byte MIFARE Classic block (must be authenticated first).
+
+        Returns 16 bytes of block data, or None on failure.
+        """
+        # Clear IRQs
+        self.write_reg(0x03, 0xFFFFFFFF)
+
+        # Set transceive mode (Crypto1 stays active from MFC_AUTHENTICATE)
+        self.set_transceive_mode()
+        time.sleep(0.001)
+
+        # Enable TX and RX CRC for encrypted read
+        self.write_reg_or(0x19, 0x01)
+        self.write_reg_or(0x12, 0x01)
+
+        # Send MIFARE READ command: 0x30 + block number
+        self.send_data([0x30, block])
+        time.sleep(0.010)
+
+        # Check RX status
+        rx_status = self.read_reg(0x13)
+        rx_len = rx_status & 0x1FF
+        if rx_len != 16:
+            return None
+
+        return self.read_data(16)
+
+    def ntag_read_pages(self, start_page: int, num_pages: int) -> bytes | None:
+        """Read NTAG pages (4 bytes each). No authentication required.
+
+        Uses NTAG READ command (0x30) which returns 4 pages (16 bytes) at a time.
+        """
+        # One-time setup: Crypto1 off, TX CRC on, RX CRC off, IDLE→TRANSCEIVE
+        self.write_reg_and(0x00, 0xFFFFFFBF)  # Crypto1 off
+        self.write_reg_or(0x19, 0x01)  # TX CRC on
+        self.write_reg_and(0x12, 0xFFFFFFFE)  # RX CRC off
+        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs
+
+        sys_cfg = self.read_reg(0x00)
+        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE
+        time.sleep(0.001)
+        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE
+        time.sleep(0.002)
+
+        result = bytearray()
+        pages_read = 0
+        while pages_read < num_pages:
+            if pages_read > 0:
+                # Subsequent iterations: just clear IRQs and re-enter TRANSCEIVE
+                self.write_reg(0x03, 0xFFFFFFFF)
+                self.set_transceive_mode()
+                time.sleep(0.001)
+
+            # READ command: 0x30 + page number -> returns 16 bytes (4 pages)
+            self.send_data([0x30, start_page + pages_read])
+            time.sleep(0.010)
+
+            rx_status = self.read_reg(0x13)
+            rx_len = rx_status & 0x1FF
+            if rx_len < 16:
+                logger.warning(
+                    "NTAG read page %d: rx_len=%d (expected >=16), rx_status=0x%08X",
+                    start_page + pages_read,
+                    rx_len,
+                    rx_status,
+                )
+                return None
+
+            data = self.read_data(16)
+            pages_to_copy = min(4, num_pages - pages_read)
+            result.extend(data[: pages_to_copy * 4])
+            pages_read += 4
+
+        return bytes(result)
+
+    def reactivate_card(self) -> tuple[bytes, int] | None:
+        """RF cycle and full re-select of the card. Returns (uid, sak) or None."""
+        self.rf_off()
+        time.sleep(0.010)
+
+        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs
+        self.load_rf_config(0x00, 0x80)  # ISO 14443A
+        time.sleep(0.005)
+
+        self.rf_on()
+        time.sleep(0.020)
+
+        return self.activate_type_a()
+
+    def read_bambu_tag(self, uid: bytes) -> dict[int, bytes] | None:
+        """Read Bambu tag data blocks using HKDF-derived keys.
+
+        Args:
+            uid: 4-byte tag UID (from activate_type_a)
+        Returns:
+            Dict mapping block number -> 16 bytes of data, or None on failure
+        """
+        # Derive per-sector keys from UID
+        keys = hkdf_derive_keys(uid)
+
+        # Clear Crypto1 state and IRQs
+        self.write_reg_and(0x00, 0xFFFFFFBF)  # Clear MFC_CRYPTO1_ON (bit 6)
+        self.write_reg(0x03, 0xFFFFFFFF)
+
+        # Reactivate card (may have timed out)
+        result = self.reactivate_card()
+        if result is None:
+            logger.debug("Failed to reactivate card for Bambu tag read")
+            return None
+
+        uid_check, _ = result
+        if uid_check != uid:
+            logger.debug("UID mismatch after reactivation: %s != %s", uid_check.hex(), uid.hex())
+            return None
+
+        # Read blocks with per-sector authentication
+        blocks = {}
+        current_sector = -1
+
+        for block in BAMBU_BLOCKS:
+            sector = block // 4
+
+            # Authenticate when entering a new sector
+            if sector != current_sector:
+                key = get_sector_key(keys, block)
+                if not self.mfc_authenticate(block, key, uid):
+                    logger.debug("Auth failed for block %d (sector %d)", block, sector)
+                    return None
+                current_sector = sector
+
+            # Read the block
+            data = self.mfc_read_block(block)
+            if data is None:
+                logger.debug("Read failed for block %d", block)
+                return None
+            blocks[block] = data
+
+        return blocks
+
+    def ntag_write_page(self, page: int, data: bytes) -> bool:
+        """Write 4 bytes to a single NTAG page.
+
+        NTAG WRITE command: 0xA2 + page_number + 4 bytes data.
+        TX CRC on (tag requires it). Always returns True — the 4-bit ACK
+        cannot be captured by the PN5180, so verification is deferred to
+        ntag_write_pages() which reads back all written data.
+        """
+        if len(data) != 4:
+            return False
+
+        # Crypto1 off, TX CRC on (tag expects CRC), RX CRC off (ACK is 4-bit, no CRC)
+        self.write_reg_and(0x00, 0xFFFFFFBF)  # Crypto1 off
+        self.write_reg_or(0x19, 0x01)  # TX CRC on
+        self.write_reg_and(0x12, 0xFFFFFFFE)  # RX CRC off
+        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs
+
+        # Reset state machine: IDLE then TRANSCEIVE
+        sys_cfg = self.read_reg(0x00)
+        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE
+        time.sleep(0.001)
+        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE
+        time.sleep(0.002)
+
+        # WRITE command: 0xA2 + page + 4 bytes
+        self.send_data([0xA2, page] + list(data))
+        time.sleep(0.010)
+
+        # The NTAG ACK is only 4 bits (0x0A). The PN5180 detects SOF but
+        # cannot capture sub-byte frames — RX_IRQ never fires. Skip ACK
+        # checking; the tag's SOF response confirms it received the command.
+        return True
+
+    def ntag_write_pages(self, start_page: int, data: bytes) -> bool:
+        """Write data to consecutive NTAG pages starting at start_page.
+
+        Pads last chunk to 4 bytes. Verification is skipped — the PN5180
+        cannot reliably read back NTAG pages after a batch write (the
+        second READ command gets no response). The write itself is reliable:
+        the tag ACKs each page (RX SOF detected on every response).
+        """
+        # Pad to 4-byte boundary
+        padded = bytearray(data)
+        while len(padded) % 4 != 0:
+            padded.append(0x00)
+
+        # Write page by page
+        num_pages = len(padded) // 4
+        for i in range(0, len(padded), 4):
+            page = start_page + (i // 4)
+            chunk = bytes(padded[i : i + 4])
+            if not self.ntag_write_page(page, chunk):
+                logger.warning("NTAG write failed at page %d (of %d pages)", page, num_pages)
+                return False
+            time.sleep(0.002)
+
+        logger.info("NTAG write complete (%d pages)", num_pages)
+        return True
+
+    def read_ntag(self, uid: bytes) -> bytes | None:
+        """Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed.
+
+        Used for SpoolEase / OpenPrintTag community tags.
+        """
+        # Reactivate card
+        result = self.reactivate_card()
+        if result is None:
+            logger.debug("Failed to reactivate card for NTAG read")
+            return None
+
+        return self.ntag_read_pages(start_page=4, num_pages=17)

+ 8 - 2
spoolbuddy/daemon/scale_reader.py

@@ -20,12 +20,18 @@ class ScaleReader:
         self._last_raw = 0
         self._last_raw = 0
 
 
         try:
         try:
-            from scale_diag import NAU7802
+            from .nau7802 import NAU7802
 
 
             self._scale = NAU7802()
             self._scale = NAU7802()
             self._scale.init()
             self._scale.init()
             self._ok = True
             self._ok = True
-            logger.info("Scale initialized (tare=%d, cal=%.6f)", tare_offset, calibration_factor)
+            bus_num = getattr(self._scale, "_bus_num", "?")
+            logger.info(
+                "Scale initialized on I2C bus %s (tare=%d, cal=%.6f)",
+                bus_num,
+                tare_offset,
+                calibration_factor,
+            )
         except Exception as e:
         except Exception as e:
             logger.info("Scale not available: %s", e)
             logger.info("Scale not available: %s", e)
 
 

+ 137 - 0
spoolbuddy/daemon/system_stats.py

@@ -0,0 +1,137 @@
+"""Collect OS-level system stats from the Raspberry Pi using stdlib only."""
+
+import os
+import platform
+
+
+def _read_file(path: str) -> str | None:
+    try:
+        with open(path) as f:
+            return f.read().strip()
+    except OSError:
+        return None
+
+
+def _cpu_temp() -> float | None:
+    raw = _read_file("/sys/class/thermal/thermal_zone0/temp")
+    if raw is None:
+        return None
+    try:
+        return round(int(raw) / 1000, 1)
+    except (ValueError, TypeError):
+        return None
+
+
+def _memory_info() -> dict | None:
+    raw = _read_file("/proc/meminfo")
+    if raw is None:
+        return None
+    info: dict[str, int] = {}
+    for line in raw.splitlines():
+        parts = line.split()
+        if len(parts) >= 2 and parts[0].endswith(":"):
+            key = parts[0][:-1]
+            try:
+                info[key] = int(parts[1])  # kB
+            except ValueError:
+                continue
+    total = info.get("MemTotal", 0)
+    available = info.get("MemAvailable", 0)
+    if total == 0:
+        return None
+    return {
+        "total_mb": round(total / 1024),
+        "available_mb": round(available / 1024),
+        "used_mb": round((total - available) / 1024),
+        "percent": round((total - available) / total * 100, 1),
+    }
+
+
+def _disk_info() -> dict | None:
+    try:
+        st = os.statvfs("/")
+    except OSError:
+        return None
+    total = st.f_frsize * st.f_blocks
+    free = st.f_frsize * st.f_bavail
+    used = total - free
+    if total == 0:
+        return None
+    return {
+        "total_gb": round(total / (1024**3), 1),
+        "used_gb": round(used / (1024**3), 1),
+        "free_gb": round(free / (1024**3), 1),
+        "percent": round(used / total * 100, 1),
+    }
+
+
+def _load_avg() -> list[float] | None:
+    try:
+        load = os.getloadavg()
+        return [round(x, 2) for x in load]
+    except OSError:
+        return None
+
+
+def _cpu_count() -> int | None:
+    return os.cpu_count()
+
+
+def _os_info() -> dict:
+    uname = platform.uname()
+    os_release = _read_file("/etc/os-release")
+    pretty_name = None
+    if os_release:
+        for line in os_release.splitlines():
+            if line.startswith("PRETTY_NAME="):
+                pretty_name = line.split("=", 1)[1].strip().strip('"')
+                break
+    return {
+        "os": pretty_name or f"{uname.system} {uname.release}",
+        "kernel": uname.release,
+        "arch": uname.machine,
+        "python": platform.python_version(),
+    }
+
+
+def _system_uptime() -> int | None:
+    raw = _read_file("/proc/uptime")
+    if raw is None:
+        return None
+    try:
+        return int(float(raw.split()[0]))
+    except (ValueError, IndexError):
+        return None
+
+
+def collect() -> dict:
+    """Collect all system stats. Returns a flat dict safe for JSON serialization."""
+    stats: dict = {}
+
+    stats["os"] = _os_info()
+
+    temp = _cpu_temp()
+    if temp is not None:
+        stats["cpu_temp_c"] = temp
+
+    cpu_count = _cpu_count()
+    if cpu_count is not None:
+        stats["cpu_count"] = cpu_count
+
+    load = _load_avg()
+    if load is not None:
+        stats["load_avg"] = load
+
+    mem = _memory_info()
+    if mem is not None:
+        stats["memory"] = mem
+
+    disk = _disk_info()
+    if disk is not None:
+        stats["disk"] = disk
+
+    uptime = _system_uptime()
+    if uptime is not None:
+        stats["system_uptime_s"] = uptime
+
+    return stats

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