Просмотр исходного кода

Merge pull request #721 from maziggy/0.2.2

**# Bambuddy v0.2.2**

**## Highlights**
- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. Select filament type with official BambuStudio temperature/duration presets, or set temperature manually. Supported on X1/X1C, P1P/P1S, H2D, H2D Pro, and X1E.
- **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints when AMS humidity exceeds the configured threshold. Uses conservative parameters for mixed filament types, with optional "block queue" mode to delay prints until drying completes.
- **Ambient Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable "Ambient drying" in Settings → Print Queue.
- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When disabled, prints are added to the queue but wait for manual dispatch instead of starting automatically.
- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. A "Queue All N Plates" toggle adds every plate as a separate queue entry, each individually editable.
- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label shows serial number, firmware version, and an editable friendly name. Slot numbers now display inside each filament color circle. Contributed by @cadtoolbox.
- **In-App Bug Reporting** — A floating bug report button lets users submit reports directly from the UI, including description, optional screenshot, and automatically collected diagnostic data with full sensitive-data redaction.

**## New Features**

- **First Layer Complete Notification** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely. Enable the "First Layer Complete" toggle on any notification provider.
- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. Select filament type with official BambuStudio temperature/duration presets, or set temperature manually. Supported on X1/X1C, P1P/P1S, H2D, H2D Pro, and X1E.
- **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints when AMS humidity exceeds the configured threshold. Uses conservative parameters for mixed filament types, with optional "block queue" mode to delay prints until drying completes.
- **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets, with separate presets for AMS 2 Pro and AMS-HT.
- **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying. Reads `dry_sf_reason` from firmware and surfaces HMS error codes for power issues.
- **Ambient Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable "Ambient drying" in Settings → Print Queue.
- **Assign Spool to Empty AMS Slot** ([#717](https://github.com/maziggy/bambuddy/issues/717)) — The "Assign Spool" option now appears on empty (unconfigured) AMS slots. Selecting a spool auto-configures the slot with the correct filament profile, color, and K-profile in one step.
- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Send persistent notifications to the HA dashboard. HA automations can forward them to mobile apps, WhatsApp, or any other service.
- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When disabled, prints are added to the queue but wait for manual dispatch instead of starting automatically.
- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. A "Queue All N Plates" toggle adds every plate as a separate queue entry, each individually editable.
- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking.
- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start/progress notifications. Shows estimated wall-clock completion time based on the user's configured time format (12h/24h).
- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament.
- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a "Force Color Match" option for "Print to Any" queue scheduling, requiring strict color matching when assigning prints to printers. Contributed by @cadtoolbox.
- **Israeli New Shekel Currency** — Added ILS to the list of supported currencies for filament cost tracking.
- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label shows serial number, firmware version, and an editable friendly name. Slot numbers now display inside each filament color circle. Contributed by @cadtoolbox.
- **In-App Bug Reporting** — A floating bug report button lets users submit reports directly from the UI, including description, optional screenshot, and automatically collected diagnostic data with full sensitive-data redaction.
- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — Write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. Supports writing for existing spools, creating new spools, or replacing damaged tags.
- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI since the Raspberry Pi has no physical keyboard. Auto-shows when text inputs are focused.
- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the reader now shows spool info directly in the dashboard panel instead of a modal overlay, with action buttons for AMS assignment, weight sync, and more.
- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page now displays external spool slots and AMS-HT units. Clicking any slot opens the configuration modal for filament type and color.
- **SpoolBuddy Dashboard Redesign** — Redesigned with a two-column layout showing device connection status and printer badges on the left, spool card on the right. Removed the separate inventory page in favor of main Bambuddy UI.
- **SpoolBuddy Kiosk Auth Bypass via API Key** — The `/auth/me` endpoint now accepts API keys, and the install script includes the API key in the kiosk URL for automatic authentication on boot.
- **Daily Beta Builds** — Added a release script for daily multi-arch Docker beta builds pushed to GHCR and Docker Hub. Beta images are never tagged as `latest`.
- **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.

**## Fixed**

- **Library Upload Doesn't Show New File Until Page Reload** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — The upload endpoint used `db.flush()` instead of `db.commit()`, so the file list was stale until page reload. Fixed the same race condition in folder create/update and file update endpoints.
- **Printer File Manager Doesn't Auto-Refresh** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — The printer SD card browser only fetched files once when opened. Now auto-refreshes every 30 seconds.
- **Database Connection Pool Exhaustion Under Load** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — Background tasks held database sessions during slow network I/O, exhausting the pool. Doubled the pool to 30 connections.
- **Block Mode Skips Humidity Auto-Stop** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — When "Wait for drying to complete" was enabled, drying sessions that reached humidity target continued indefinitely. Block mode now only prevents starting new drying, not stopping completed sessions.
- **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — Stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value due to nullish coalescing not falling through on `0`. Now bypasses inventory when it says 0% but AMS hardware reports positive remain.
- **Virtual Printer Proxy Mode Always Shows X1C Model** — Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer.
- **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — Bambu Cloud credentials were stored globally instead of per-user. Each user now logs into their own Cloud account independently. Also fixed cloud endpoints requiring wrong permissions.
- **Local Profiles Not Shown in AMS Slot Configuration** — The `compatible_printers` filter parsed JSON as semicolon-delimited, silently skipping all local presets. Removed the filter entirely.
- **Interface Aliases Not Shown in Virtual Printer Interface Select** — Interface aliases (e.g. `eth0:1`) were invisible because the Docker image lacked `iproute2`. Added it to the image.
- **P2S Camera Stream Disconnects After a Few Seconds** ([#661](https://github.com/maziggy/bambuddy/issues/661)) — GnuTLS rejected TLS behaviors some printer firmwares rely on. Added a local TLS termination proxy using OpenSSL. Also reduced reconnect delay and fixed choppy external camera streams.
- **iOS/iPadOS Cannot Reposition Floating Camera** ([#687](https://github.com/maziggy/bambuddy/issues/687)) — The floating camera viewer only handled mouse events. Added touch event support for drag and resize.
- **PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — Added a filament type equivalence system so PA variants are treated as compatible in scheduler assignment, AMS slot matching, and filament override dropdowns.
- **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — The click target now covers only the checkbox, icon, and label text instead of the entire row.
- **HA Switch Badge Always Sends Turn On Instead of Toggle** — Now sends `toggle` for non-script entities so the badge click actually toggles the switch state.
- **Multiple Plugs Per Printer Crashes Auto-On/Off** — `scalar_one_or_none()` raised `MultipleResultsFound` when multiple smart plugs were assigned. Now fetches all plugs and returns the main power plug.
- **Multiple HA Switches Per Printer UNIQUE Constraint** — The migration to remove the UNIQUE constraint on `smart_plugs.printer_id` now uses regex matching and also checks standalone UNIQUE indexes.
- **HMS Notifications for Unknown/Phantom Error Codes** — Flipped notification logic from "notify all, suppress specific" to "only notify for errors with known descriptions", matching the frontend behavior.
- **Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Three bugs: wrong detection method (bit 18 vs wifi_signal heuristic), bad import path crashing MQTT thread, and WiFi-only models not excluded.
- **Inventory Usage Tracker Missing External Spool Mapping** ([#677](https://github.com/maziggy/bambuddy/issues/677)) — Fallback `slot_id - 1` could never reach external spool IDs. Added position-based resolution using sorted available tray IDs. Contributed by @shrunbr.
- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Cloud API returns only the base `filament_id` for versioned setting IDs, ignoring variants. Added a cross-check that corrects the filament ID via reverse lookup.
- **Debug Logging Endpoint 500 Error** — Duration calculation mixed timezone-aware and naive datetimes. Now strips timezone info when reading stored timestamps.
- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — Partial MQTT updates didn't include `bed_temper`, so cached temperature never dropped. The monitor now sends periodic `pushall` commands to force fresh data.
- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The create endpoint omitted 8 event toggles (`on_bed_cooled` and 7 queue events), so they always defaulted to false regardless of user selection.
- **Clear Plate Prompt Shown for Staged Queue Items** — The prompt now only appears when there are auto-dispatchable items that the scheduler will actually start.
- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating.
- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud login.
- **GitHub Backup Log Timestamps Off by 1 Hour** — Timestamps were displayed in UTC instead of the user's local timezone. Now uses `parseUTCDate` for correct conversion.
- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — Three bugs in AMS `info` field parsing: decimal instead of hex, wrong bit extraction, and partial updates overwriting full map. All fixed.
- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — The FTP upload skipped the server's 226 "Transfer complete" response, sending the print command before the file was flushed to disk. Now waits for confirmation.
- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S uses steel shafts, not carbon rods. Added a new `steel_rod` category with appropriate maintenance tasks.
- **Dispatch Toast Stuck After Second Print** — The dedup guard was never reset between batches, leaving the progress toast stuck. Now resets on dismiss and new batch start.
- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — Buttons now clip cleanly with ellipsis instead of overlapping at narrow viewport widths.
- **Debug Logging Banner Timer Shows Negative Time** — Mixed local/UTC timestamps caused negative duration display. Now stores and compares all timestamps in UTC.
- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — Generates a fallback tag from printer/AMS/tray IDs for spools without RFID identifiers. Also added an "Unlink" button. Contributed by @shrunbr.
- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking now sets the Spoolman location to printer name, AMS name, and slot number; unlinking clears it. Contributed by @shrunbr.
- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — The dispatch toast now stays visible for 3 seconds after completion showing a success message before auto-dismissing.
- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — The printer selector now shows live state badges and grays out busy printers in reprint mode. Queue mode still allows selecting busy printers.
- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Fixed incorrect icon dimensions, split the `"any maskable"` purpose, and added required screenshots to the manifest.
- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Only files with `status="completed"` (actually printed) now count toward completion stats, excluding archived files.
- **Python 3.10 Compatibility** — Added a compatibility shim for `enum.StrEnum` which was added in Python 3.11.
- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress above the bug report bubble.
- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — Added RSA key exchange ciphers to the client SSL context for compatibility with Bambu printers.
- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — ffmpeg cleanup broadcast `CTRL_C_EVENT` to the entire process group. Now spawns ffmpeg in its own process group.
- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — The multi-printer filament mapping path was missing the `nozzle_id` filter that the single-printer path already had.
- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — Now shows `tray_sub_brands` (e.g. "PLA Basic", "PLA Matte") in all filament dropdowns, with separate dedup entries for different subtypes.
- **Archive Card Shows "Source" Badge for Sliced .3mf Files** — Now checks `total_layers` and `print_time_seconds` metadata to determine if a `.3mf` file is sliced. Also passes the original filename when creating archives.
- **AMS Slot Shows Wrong Material for "Support for" Profiles** — The name parser returned the first material match ("PLA") instead of the one after "Support for" ("PETG"). Both frontend and backend parsers now detect the naming pattern.
- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
- **Spurious Error Notifications During Normal Printing (0300_0002)** — HMS error codes with low 16 bits below `0x4000` are status indicators, not faults. Now skips them in both parsers.
- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — SQLAlchemy lazy-loaded `spool.assignments` outside the async greenlet. Now initializes the collection on new spools and eagerly loads it for existing ones.
- **SpoolBuddy Link Tag Missing tag_type** — Now uses the dedicated `linkTagToSpool` endpoint with proper `tag_type` and `data_origin` values.
- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — Now fetches inventory assignments and computes fill levels from weight data, falling back to AMS remain when no assignment exists.
- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — Guards against `tray_now=255` (idle sentinel) before ext slot active check.
- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — Print progress info now shows for both `RUNNING` and `PAUSE` states. Status label correctly reads "Paused".
- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — Fixed version suffix handling in cloud API calls, correct `filament_id` resolution, and `SlotPresetMapping` saving.
- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — Port 3002 now uses TLS for slicer compatibility, while port 3000 remains plain TCP.
- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — Normalizes `"aborted"` status to `"cancelled"` before storing. A startup fixup converts existing bad rows.
- **Tests Send Real Maintenance Notifications** — Tests now cancel spawned background tasks before mock context exits.
- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — `sync_from_db()` now compares critical fields and restarts VPs when config changes are detected.
- **Sidebar Navigation Ignores User Permissions** — Each nav item is now hidden when the user lacks the corresponding read permission. Also added missing `inventory:*` permissions to the frontend type definition.
- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Camera button now requires `camera:view` permission. Added proper process cleanup with background disconnect monitoring and periodic orphan scanning.
- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — Removed redundant hash verification block that used unparseable multi-line `for /f` syntax.
- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The badge count now applies the same filament compatibility filter as the queue widget.
- **SpoolBuddy Daemon Can't Find Hardware Drivers** — Added `scripts/` to `sys.path` at daemon startup and moved imports inside try/except blocks. Demoted hardware-not-available logs to INFO.
- **SpoolBuddy Scale Tare & Calibration Not Applied** — Fixed five bugs in the tare/calibration chain including missing API endpoints, stale values overwriting new ones, and self-referential factor computation.
- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Non-UTF-8 MQTT payloads now fall back to `decode(errors="replace")` instead of being silently dropped.
- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — Added `O1C2` to all model ID maps across backend and frontend, fixing camera protocol, display names, and more.
- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Masks subnet octets, correctly detects Docker network mode, and parses `fun` field from top-level MQTT payload.
- **SpoolBuddy Scale Calibration Lost After Reboot** — Device ID generation now sorts network interfaces alphabetically for deterministic selection across reboots.
- **SpoolBuddy NFC Reader Fails to Detect Tags** — Fixed PN5180 polling state corruption with full hardware reset before each idle poll, and RF cycle before each active poll. Added auto-recovery after consecutive errors.

**## Improved**

- **Shorter Inventory Location Labels** — Location column now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3".
- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options. The previous 120s max was insufficient for large 3MF files during active printing.
- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — New `inventory:view_assignments` permission lets users see AMS spool assignments without accessing the full Inventory page.
- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added `bambuddy_build_info` gauge metric exposing version, Python version, platform, and architecture. Contributed by @sw1nn.
- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys across Settings, Smart Plug, Notification, and Backup/Restore components. Added ~600 new keys to all 7 locales.
- **Install Script: Branch Selection** — The native install script now supports a `--branch` option and interactive branch prompt for beta testers.
- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging showing skip summaries and per-printer state details to help diagnose why queued prints aren't starting.
- **SpoolBuddy Settings Page Redesign** — Redesigned with a tabbed layout (Device, Display, Scale, Updates) including brightness slider, screen blank timeout, step-indicator calibration wizard, and update checker.
- **SpoolBuddy Language & Time Format Support** — The kiosk now respects Bambuddy's configured UI language and time format, with full translations for all 6 languages.
- **SpoolBuddy Kiosk Stability** — Disabled swipe-to-navigate gesture and added `video` group for DSI backlight access.
- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the kiosk UI for comfortable finger use on the 1024×600 touchscreen, meeting 44px minimum tap targets.
- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers on ethernet now show a green "Ethernet" badge with cable icon instead of WiFi signal strength.
- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards now use a responsive grid aligned with regular AMS card columns.
- **SpoolBuddy Scale Value Stabilization** — Suppresses redundant scale reports (only sends on ≥2g change), increased moving average window, and added frontend 3g display threshold.
- **SpoolBuddy TopBar: Online Printer Selection** — Printer selector now only shows online printers and auto-selects the first available. Auto-switches if selected printer goes offline.
- **SpoolBuddy Assign to AMS Redesign** — Full-screen overlay reusing AMS page components with one-click slot assignment via a single API call.
- **Filament ID Conversion Utility** — Extracted filament_id/setting_id conversion into a shared utility. The `assign_spool` endpoint now correctly sends both `tray_info_idx` and `setting_id`.
- **Updates Card Separates Firmware and Software Settings** — Split into labeled "Printer Firmware" and "Bambuddy Software" sections.
- **SpoolBuddy Test Coverage** — Added 21 backend integration tests and 20 frontend component tests covering all SpoolBuddy API endpoints and main components.
- **Cleanup Obsolete Settings** — Startup migration now deletes orphaned settings keys no longer used by the application.
- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to supported currencies.
- **FTP Upload Progress & Speed** — Reduced chunk size from 1MB to 64KB for smoother progress reporting. Removed post-upload wait that caused 30+ second hangs on H2D. Added transfer speed logging.
- **Wider Print & Schedule Modals** — Increased modal width from 512px to 672px to accommodate long filament profile names.

**## Changed**

- **CI: Node.js 20 → 22** — Updated GitHub Actions workflows to Node.js 22 LTS ahead of GitHub's Node 20 deprecation.
- **Daily Builds Falsely Trigger Update Notification** — The version parser misclassified daily build tags as full releases. Now strips the daily suffix before parsing.
- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. Community contributions and usage are unaffected.

**## Security**

- **Stored XSS via Project Notes** — Project notes rendered with `dangerouslySetInnerHTML` without sanitization. Now sanitized with DOMPurify.
- **Stored XSS via 3MF Description (Sanitizer Bypass)** — Hand-rolled HTML sanitizer allowed attribute injection via quote break-out. Replaced with DOMPurify.
- **Unauthenticated Auth Toggle via Setup Endpoint** — The `/api/v1/auth/setup` endpoint could disable authentication without being authenticated. Now returns 403 when auth is already enabled.
- **PyJWT ≥2.12.0** — Bumped minimum version to address CVE-2026-32597.
- **flatted ≥3.4.0** — Updated transitive ESLint dependency to address GHSA-25h7-pfq9-p65f (unbounded recursion DoS).
- **Access Code Redacted from Support Logs** — Printer access codes in RTSP URLs were not redacted in support bundles. Extended sanitizer to cover `rtsps://` URLs and added access codes to exact-match redaction.

**## Merged Pull Requests**

- [#508](https://github.com/maziggy/bambuddy/pull/508) — feat(queue): show spool grams left in filament slot mapping (@cimdDev)
- [#531](https://github.com/maziggy/bambuddy/pull/531) — Add customizable low stock threshold, add low stock filter (@Keybored02)
- [#561](https://github.com/maziggy/bambuddy/pull/561) — Statistics page: timeframe filtering, new widgets & fix success rate (@aneopsy)
- [#569](https://github.com/maziggy/bambuddy/pull/569) — Printer Page: print button & drop zone on printer card (@aneopsy)
- [#570](https://github.com/maziggy/bambuddy/pull/570) — Feature AMS Info Card (@cadtoolbox)
- [#573](https://github.com/maziggy/bambuddy/pull/573) — Add energy cost to archive card (@Keybored02)
- [#578](https://github.com/maziggy/bambuddy/pull/578) — Fix AMS slot modal infinite scroll loop on Windows (@aneopsy)
- [#580](https://github.com/maziggy/bambuddy/pull/580) — Fix AMS slot modal infinite scroll loop on Windows (@aneopsy)
- [#581](https://github.com/maziggy/bambuddy/pull/581) — Refactor frontend utility functions to reduce duplication (@aneopsy)
- [#625](https://github.com/maziggy/bambuddy/pull/625) — Feature: Force Color Match (@cadtoolbox)
- [#627](https://github.com/maziggy/bambuddy/pull/627) — Sync i18n locales: add 655 translated keys, remove 127 stale keys (@aneopsy)
- [#633](https://github.com/maziggy/bambuddy/pull/633) — feat(metrics): add bambuddy_build_info metric (@sw1nn)
- [#653](https://github.com/maziggy/bambuddy/pull/653) — Non-Bambu Lab Spools: fully link/unlink to Spoolman (@shrunbr)
- [#669](https://github.com/maziggy/bambuddy/pull/669) — Update Spoolman location when linking/unlinking (@shrunbr)
- [#686](https://github.com/maziggy/bambuddy/pull/686) — Properly track filament usage data in Spoolman (@shrunbr)
- [#700](https://github.com/maziggy/bambuddy/pull/700) — Allow SMTP to be configured before auth is enabled (@shrunbr)
- [#710](https://github.com/maziggy/bambuddy/pull/710) — SMTP testing uses saved database settings (@shrunbr)
- [#711](https://github.com/maziggy/bambuddy/pull/711) — Ensures AMS-HT devices are properly identified in Spoolman (@shrunbr)

**## Community Contributors**

- **@shrunbr** — Spoolman integration improvements: non-Bambu spool linking/unlinking, location updates, filament usage tracking, AMS-HT identification, SMTP configuration and testing fixes.
- **@aneopsy** — Statistics page enhancements with timeframe filtering and new widgets, printer card print button and drop zone, AMS slot modal scroll fix, frontend utility refactoring, i18n locale sync, and print dispatch toast fix.
- **@cadtoolbox** — AMS Info Card with custom labels and slot numbers, Force Color Match for queue scheduling, and multiple bug reports for AMS/permission/ethernet issues.
- **@Keybored02** — Customizable low stock threshold with filter, and energy cost display on archive cards.
- **@cimdDev** — Spool grams remaining display in queue filament slot mapping.
- **@sw1nn** — Prometheus `bambuddy_build_info` metric for version tracking and alerting.
MartinNYHC 5 месяцев назад
Родитель
Сommit
d6bf494c0b
100 измененных файлов с 9478 добавлено и 617 удалено
  1. 1 1
      .github/workflows/ci.yml
  2. 1 1
      .github/workflows/security.yml
  3. 264 1
      CHANGELOG.md
  4. 1 0
      Dockerfile
  5. 661 21
      LICENSE
  6. 12 2
      README.md
  7. 118 14
      backend/app/api/routes/archives.py
  8. 100 18
      backend/app/api/routes/auth.py
  9. 95 0
      backend/app/api/routes/bug_report.py
  10. 203 112
      backend/app/api/routes/camera.py
  11. 82 74
      backend/app/api/routes/cloud.py
  12. 1 1
      backend/app/api/routes/groups.py
  13. 221 32
      backend/app/api/routes/inventory.py
  14. 4 4
      backend/app/api/routes/library.py
  15. 18 3
      backend/app/api/routes/maintenance.py
  16. 17 0
      backend/app/api/routes/metrics.py
  17. 14 0
      backend/app/api/routes/notifications.py
  18. 196 5
      backend/app/api/routes/printers.py
  19. 4 4
      backend/app/api/routes/projects.py
  20. 10 3
      backend/app/api/routes/settings.py
  21. 282 3
      backend/app/api/routes/spoolbuddy.py
  22. 70 13
      backend/app/api/routes/spoolman.py
  23. 116 13
      backend/app/api/routes/support.py
  24. 3 0
      backend/app/api/routes/updates.py
  25. 26 3
      backend/app/api/routes/virtual_printers.py
  26. 11 0
      backend/app/core/compat.py
  27. 2 1
      backend/app/core/config.py
  28. 139 2
      backend/app/core/database.py
  29. 5 1
      backend/app/core/permissions.py
  30. 127 20
      backend/app/main.py
  31. 2 0
      backend/app/models/__init__.py
  32. 30 0
      backend/app/models/ams_label.py
  33. 20 0
      backend/app/models/bug_report.py
  34. 1 0
      backend/app/models/notification.py
  35. 6 0
      backend/app/models/notification_template.py
  36. 2 0
      backend/app/models/spool.py
  37. 8 1
      backend/app/models/spoolbuddy_device.py
  38. 4 0
      backend/app/models/user.py
  39. 1 0
      backend/app/models/virtual_printer.py
  40. 21 0
      backend/app/schemas/archive.py
  41. 0 9
      backend/app/schemas/auth.py
  42. 2 1
      backend/app/schemas/github_backup.py
  43. 9 1
      backend/app/schemas/notification.py
  44. 8 4
      backend/app/schemas/notification_template.py
  45. 17 0
      backend/app/schemas/printer.py
  46. 33 1
      backend/app/schemas/settings.py
  47. 3 0
      backend/app/schemas/spool.py
  48. 40 0
      backend/app/schemas/spoolbuddy.py
  49. 11 1
      backend/app/services/archive.py
  50. 1 0
      backend/app/services/background_dispatch.py
  51. 54 15
      backend/app/services/bambu_ftp.py
  52. 296 41
      backend/app/services/bambu_mqtt.py
  53. 142 0
      backend/app/services/bug_report.py
  54. 122 1
      backend/app/services/camera.py
  55. 85 11
      backend/app/services/external_camera.py
  56. 34 14
      backend/app/services/failure_analysis.py
  57. 16 0
      backend/app/services/firmware_check.py
  58. 1 1
      backend/app/services/firmware_update.py
  59. 106 1
      backend/app/services/notification_service.py
  60. 103 0
      backend/app/services/opentag3d.py
  61. 14 1
      backend/app/services/orca_profiles.py
  62. 513 20
      backend/app/services/print_scheduler.py
  63. 67 0
      backend/app/services/printer_manager.py
  64. 20 2
      backend/app/services/smart_plug_manager.py
  65. 10 2
      backend/app/services/spool_tag_matcher.py
  66. 10 5
      backend/app/services/spoolman.py
  67. 187 46
      backend/app/services/spoolman_tracking.py
  68. 18 3
      backend/app/services/usage_tracker.py
  69. 42 8
      backend/app/services/virtual_printer/bind_server.py
  70. 32 0
      backend/app/services/virtual_printer/manager.py
  71. 27 12
      backend/app/services/virtual_printer/tcp_proxy.py
  72. 78 0
      backend/app/utils/filament_ids.py
  73. 49 3
      backend/app/utils/printer_models.py
  74. 10 2
      backend/tests/conftest.py
  75. 6 1
      backend/tests/integration/test_advanced_auth_api.py
  76. 207 0
      backend/tests/integration/test_ams_labels_api.py
  77. 150 0
      backend/tests/integration/test_archives_api.py
  78. 62 0
      backend/tests/integration/test_auth_api.py
  79. 219 0
      backend/tests/integration/test_available_filaments.py
  80. 391 0
      backend/tests/integration/test_cloud_auth.py
  81. 117 0
      backend/tests/integration/test_notifications_api.py
  82. 22 1
      backend/tests/integration/test_print_lifecycle.py
  83. 247 0
      backend/tests/integration/test_print_queue_api.py
  84. 115 0
      backend/tests/integration/test_projects_api.py
  85. 21 0
      backend/tests/integration/test_settings_api.py
  86. 743 0
      backend/tests/integration/test_spoolbuddy.py
  87. 19 1
      backend/tests/integration/test_spoolman_api.py
  88. 68 0
      backend/tests/integration/test_virtual_printer_api.py
  89. 29 48
      backend/tests/unit/services/test_bambu_ftp.py
  90. 81 8
      backend/tests/unit/services/test_bambu_mqtt.py
  91. 155 0
      backend/tests/unit/services/test_camera_tls_proxy.py
  92. 376 0
      backend/tests/unit/services/test_notification_service.py
  93. 122 0
      backend/tests/unit/services/test_printer_manager.py
  94. 92 0
      backend/tests/unit/services/test_smart_plug_manager.py
  95. 354 0
      backend/tests/unit/services/test_spool_tag_matcher.py
  96. 99 0
      backend/tests/unit/services/test_spoolman_tracking.py
  97. 310 0
      backend/tests/unit/services/test_virtual_printer.py
  98. 336 0
      backend/tests/unit/test_bug_report.py
  99. 22 0
      backend/tests/unit/test_catalog_bulk_delete.py
  100. 56 0
      backend/tests/unit/test_maintenance_rod_filtering.py

+ 1 - 1
.github/workflows/ci.yml

@@ -12,7 +12,7 @@ on:
 
 env:
   PYTHON_VERSION: '3.11'
-  NODE_VERSION: '20'
+  NODE_VERSION: '22'
 
 # Cancel in-progress runs for the same branch
 concurrency:

+ 1 - 1
.github/workflows/security.yml

@@ -27,7 +27,7 @@ on:
 
 env:
   PYTHON_VERSION: '3.11'
-  NODE_VERSION: '20'
+  NODE_VERSION: '22'
 
 # Default permissions for all jobs
 permissions:

+ 264 - 1
CHANGELOG.md

@@ -2,6 +2,270 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.2.2] - 2026-03-16
+
+### New Features
+- **First Layer Complete Notification** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely without watching the whole print. Enable the "First Layer Complete" toggle on any notification provider. Fires once per print when layer 2 begins (confirming layer 1 is done), with a guard against spurious triggers on printer reconnect. Requested by community.
+- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.
+- **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional "block queue" mode delays the next print until drying completes.
+- **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.
+- **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads `dry_sf_reason` from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.
+- **Ambient Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable "Ambient drying" in Settings → Print Queue to have the scheduler start drying on any idle printer whose AMS humidity exceeds the configured threshold — no scheduled prints required. Uses the same humidity threshold, drying presets, and power constraint detection as queue auto-drying. Both modes can be enabled simultaneously. Requested by community.
+- **Assign Spool to Empty AMS Slot** ([#717](https://github.com/maziggy/bambuddy/issues/717)) — Previously, the "Assign Spool" button only appeared on AMS slots that already had a filament profile configured, requiring users to first configure the slot manually before assigning an inventory spool — even though the assignment auto-configures the slot anyway. The "Assign Spool" option now appears on empty (unconfigured) slots as well. Selecting a spool auto-configures the slot with the correct filament profile, color, and K-profile in one step. Also fixed the AMS slot profile label showing the generic material type (e.g. "PLA") instead of the spool's actual slicer preset name (e.g. "PolyLite PLA Pro") after assignment. Requested by @RosdasHH.
+- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
+- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
+- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
+- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
+- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
+- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
+- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a "Force Color Match" option for "Print to Any" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.
+- **Israeli New Shekel Currency** — Added ILS (₪) to the list of supported currencies for filament cost tracking.
+- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.
+- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
+- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
+- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
+- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
+- **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 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`.
+- **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
+- **Library Upload Doesn't Show New File Until Page Reload** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — After uploading a file in the Library file manager, the file list didn't update until the user reloaded the browser. The upload endpoint used `db.flush()` instead of `db.commit()`, so the new row was only written to the database *after* the response was sent to the client. The frontend immediately refetched the file list upon receiving the response, but a new database session couldn't see the uncommitted row — resulting in stale data. Fixed by committing before the response is returned. Also fixed the same race condition in folder create, folder update, and file update endpoints. Reported by @shadowjig.
+- **Printer File Manager Doesn't Auto-Refresh** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — The printer file manager (SD card browser) only fetched the file list once when opened. Files uploaded from BambuStudio/OrcaSlicer while the modal was open wouldn't appear until the user clicked the refresh button or reopened the modal. Now auto-refreshes every 30 seconds while open. Reported by @shadowjig.
+- **Database Connection Pool Exhaustion Under Load** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — Background tasks (print scheduler FTP uploads, camera captures, notification sends, timelapse stitching) held database sessions open during slow network I/O, consuming connection pool slots for seconds at a time. With the default pool of 15 connections (size 5 + overflow 10), concurrent operations during print start/complete events could exhaust the pool, causing `QueuePool limit reached` errors and `greenlet_spawn` failures in RFID spool auto-assignment. Doubled the pool to 30 connections (size 10 + overflow 20). Reported by @shadowjig.
+- **Block Mode Skips Humidity Auto-Stop** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — When "Wait for drying to complete" was enabled and a printer had pending queue items, the scheduler skipped the humidity auto-stop check entirely. A drying session that reached its humidity target would continue indefinitely instead of stopping after the 30-minute minimum. Now, block mode only prevents starting new drying — already-drying printers still have their humidity checked and stopped when the threshold is met.
+- **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — When authentication was enabled with advanced permissions, users with `inventory:view_assignments` permission saw 0% fill level on AMS slots where inventory spool data had stale `weight_used` values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (`??`), which doesn't fall through on `0` — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked `inventory:view_assignments`, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox.
+- **Virtual Printer Proxy Mode Always Shows X1C Model** — Creating a virtual printer in Proxy mode always set the model to X1C regardless of the destination printer, because the frontend hides the model dropdown in proxy mode and the backend defaulted to X1C. Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer (e.g. a proxy pointing at a P1S correctly presents itself as P1S to the slicer). The model also auto-updates when changing the target printer or switching to proxy mode.
+- **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (`/cloud/settings`, `/cloud/fields`, preset CRUD) requiring `settings:read` / `settings:update` permissions instead of `cloud:auth` — users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.
+- **Local Profiles Not Shown in AMS Slot Configuration** — Imported local filament profiles were hidden in the AMS slot configure modal when a printer model was set. The `compatible_printers` filter parsed the stored JSON array as a semicolon-delimited string, so the matching always failed and every local preset was silently skipped. Removed the filter entirely — user-imported profiles should be available on any printer.
+- **Interface Aliases Not Shown in Virtual Printer Interface Select** — Interface aliases (e.g. `eth0:1`) added for multi-virtual-printer setups were invisible in the bind IP dropdown. The Docker image didn't include `iproute2`, so the `ip` command wasn't available and the code fell back to ioctl-based enumeration which can only return one IP per interface. Added `iproute2` to the Docker image.
+- **P2S Camera Stream Disconnects After a Few Seconds** ([#661](https://github.com/maziggy/bambuddy/issues/661)) — The P2S firmware drops RTSP sessions after a few seconds with an I/O error. Root cause: ffmpeg in the Docker image uses GnuTLS for TLS, and Debian's hardened GnuTLS defaults reject TLS behaviors (renegotiation, legacy ciphers) that some printer firmwares rely on. Added a local TLS termination proxy that uses Python's ssl module (OpenSSL) to handle the TLS connection to the printer, exposing a plain RTSP port to ffmpeg. The proxy rewrites RTSP request-line URLs while preserving Digest auth headers. Also reduced RTSP reconnect delay from 1.0s to 0.2s, added ffmpeg fast-start flags for lower startup latency, and fixed external camera streams being choppy due to double rate-limiting in the proxy layer. Reported by @ddetton, confirmed by @DMoenning.
+- **iOS/iPadOS Cannot Reposition Floating Camera** ([#687](https://github.com/maziggy/bambuddy/issues/687)) — The floating camera viewer (embedded camera window on the dashboard) could not be dragged or resized on iOS/iPadOS because it only handled mouse events. Touch input scrolled the page underneath instead of moving the camera window. Added touch event support (`touchstart`/`touchmove`/`touchend`) to both the header drag handle and the resize handle, with `preventDefault` to stop page scrolling during drag. Reported by @dsmitty166.
+- **PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable, but the print scheduler and filament override UI used exact string matching. If a 3MF required PA-CF but the AMS had PA12-CF loaded, the scheduler wouldn't assign the job and the filament override dropdown was empty/disabled. Added a filament type equivalence system so these PA variants are treated as compatible in scheduler assignment, AMS slot matching, force color match validation, and the filament override dropdown. Reported by @aneopsy.
+- **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — In the Schedule Print modal, clicking anywhere on the "Force color match" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.
+- **HA Switch Badge Always Sends Turn On Instead of Toggle** — Clicking a non-script Home Assistant entity (switch, light, input_boolean) on the printer card always sent `turn_on`, which is a no-op when the switch is already on. Now sends `toggle` for non-script entities so the badge click actually toggles the switch state. Script entities still use `turn_on` (stateless trigger).
+- **Multiple Plugs Per Printer Crashes Auto-On/Off** — When multiple smart plugs were assigned to the same printer (e.g., a Tasmota plug + an HA switch), the auto-on/auto-off handler called `scalar_one_or_none()` which raises `MultipleResultsFound`. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior.
+- **Multiple HA Switches Per Printer UNIQUE Constraint** — The migration that removes the UNIQUE constraint on `smart_plugs.printer_id` (to allow multiple HA switches per printer) used an exact string match to detect the constraint in the SQLite schema. Databases created with older SQLAlchemy versions expressed the constraint differently (e.g. quoted column names, table-level `UNIQUE(printer_id)`, or separate indexes), so the migration silently skipped them. Users hit `IntegrityError: UNIQUE constraint failed` when assigning a second HA switch to a printer. Now uses regex pattern matching and also checks for standalone UNIQUE indexes.
+- **HMS Notifications for Unknown/Phantom Error Codes** — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from "notify all, suppress specific codes" to "only notify for errors with known descriptions", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.
+- **Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Three bugs in the ethernet badge feature: (1) `home_flag` bit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded `-90 dBm` sentinel, while real WiFi signals vary. (2) The lazy import used `from app.utils.printer_models` which crashes with `ModuleNotFoundError` in paho-mqtt's background thread (correct path is `backend.app.utils.printer_models`). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox.
+- **Inventory Usage Tracker Missing External Spool Mapping** ([#677](https://github.com/maziggy/bambuddy/issues/677)) — When all higher-priority slot-to-tray mapping methods failed (MQTT mapping, print command mapping, queue mapping, color matching), the internal inventory usage tracker fell back to `slot_id - 1` which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr.
+- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for "Generic PLA Silk"). Reported by @peter-k-de.
+- **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
+- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
+- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.
+- **Clear Plate Prompt Shown for Staged Queue Items** — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.
+- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — The printer card network badge always showed "Ethernet" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.
+- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
+- **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.
+- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.
+- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — After printing one file, subsequent prints could fail with `0500-C010 "MicroSD Card read/write exception"` until Bambuddy was restarted. The FTP upload used `transfercmd()` for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.
+- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S was incorrectly classified as a carbon rod printer, showing "Lubricate Carbon Rods" and "Clean Carbon Rods" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new `steel_rod` motion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.
+- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`"first-complete:1:0"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.
+- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — The "Reprint" and "Schedule" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based `sm:` breakpoint that didn't account for actual card width. Added `overflow-hidden` to the flex buttons and `truncate` to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.
+- **Debug Logging Banner Timer Shows Negative Time** — When enabling debug logging, the banner showed a negative duration (e.g. "-60m -59s") equal to the server's UTC offset. The `enabled_at` timestamp was stored using `datetime.now()` (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.
+- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — The "Link to Spoolman" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an "Unlink from Spoolman" button for non-Bambu spools that are already linked. Contributed by @shrunbr.
+- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. "P2S-1 - AMS-A 3") when linking, and clears it when unlinking. Contributed by @shrunbr.
+- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. "1 print started successfully") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.
+- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
+- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `"any maskable"` purpose into a dedicated `"maskable"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.
+- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status="completed"` (actually printed via a printer) now count toward completion stats. Files with `status="archived"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.
+- **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.
+- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
+- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
+- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
+- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
+- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.
+- **Archive Card Shows "Source" Badge for Sliced .3mf Files** — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).
+- **AMS Slot Shows Wrong Material for "Support for" Profiles** — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).
+- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
+- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.
+- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — RFID spool auto-assignment logged `WARNING greenlet_spawn has not been called; can't call await_only() here` and silently failed. The `Spool.assignments` relationship was never eagerly loaded: when `auto_assign_spool()` created a new `SpoolAssignment` and called `db.add()`, SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized `spool.assignments` collection. The previous fix only covered `spool.k_profiles`. Now also initializes `spool.assignments = []` on newly created spools in `create_spool_from_tray()`, and adds `selectinload(Spool.assignments)` to both queries in `get_spool_by_tag()` for existing spools. Added `exc_info=True` to the error handlers for full tracebacks in future logs.
+- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
+- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.
+- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.
+- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
+- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
+- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
+- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
+- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
+- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.
+- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
+- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
+- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f "usebackq"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.
+- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
+- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
+- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.
+- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
+- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
+- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported "bridge" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.
+- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.
+- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
+
+### Changed
+- **CI: Node.js 20 → 22** — Updated GitHub Actions workflows (`ci.yml`, `security.yml`) from Node.js 20 to Node.js 22 LTS ahead of [GitHub's Node 20 deprecation](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/).
+- **Daily Builds Falsely Trigger Update Notification** — The version parser misclassified daily build tags (e.g. `0.2.2b4-daily.20260313`) as full releases instead of betas, because the `-daily.YYYYMMDD` suffix pushed the last dot-segment to a pure number (`20260313`), bypassing the prerelease detection. Users running the same beta version saw a spurious "update available" notification after each daily build. Now strips the daily suffix before parsing.
+- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
+- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
+
+### Improved
+- **Shorter Inventory Location Labels** — The location column in the Inventory table now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3". External spool holders show "Ext" instead of "External". AMS-HT labels remain unchanged ("HT-A").
+- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.
+- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.
+- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.
+- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys (`t()` calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (`PROVIDER_LABELS`, `EVENT_LABELS`, `CATEGORY_LABELS`) in favor of dynamic translation key lookups with fallbacks.
+- **Install Script: Branch Selection** — The native install script (`install.sh`) now supports a `--branch` option and an interactive branch prompt (defaults to `main`). Previously the script hardcoded `origin/main`, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use `git clone --branch`, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches.
+- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.
+- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
+- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).
+- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.
+- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
+- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.
+- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
+- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
+- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
+- **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
+- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
+- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
+- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
+- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).
+- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
+- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.
+- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").
+
+### Security
+- **Stored XSS via Project Notes** — Project notes were rendered with `dangerouslySetInnerHTML` without sanitization, allowing injected `<script>` or event handler payloads to execute in any viewer's browser and steal JWT tokens from localStorage. Now sanitized with DOMPurify before rendering.
+- **Stored XSS via 3MF Description (Sanitizer Bypass)** — The hand-rolled HTML sanitizer in the Project Page modal reconstructed `<a>` tags by interpolating the `href` attribute without escaping embedded quotes. A crafted 3MF file with a single-quoted `href` containing a double-quote break-out could inject `onmouseover` event handlers through the sanitizer. Replaced the custom sanitizer with DOMPurify.
+- **Unauthenticated Auth Toggle via Setup Endpoint** — The `/api/v1/auth/setup` endpoint could be called without authentication even when auth was already enabled, allowing any network client to disable authentication entirely. Now returns 403 when auth is already enabled; use the authenticated admin panel to modify auth settings.
+- **PyJWT ≥2.12.0** — Bumped minimum version to address CVE-2026-32597.
+- **flatted ≥3.4.0** — Updated transitive ESLint dependency to address GHSA-25h7-pfq9-p65f (unbounded recursion DoS).
+- **Access Code Redacted from Support Logs** — Printer access codes embedded in RTSP stream URLs were not redacted in support bundles and bug report logs. Extended the URL credential sanitizer to cover `rtsps://` URLs and added access codes to the sensitive string collection for exact-match redaction.
+
+## [0.2.2b3] - 2026-03-12
+
+### New Features
+- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
+- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
+- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
+- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
+- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
+- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
+- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a "Force Color Match" option for "Print to Any" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.
+- **Israeli New Shekel Currency** — Added ILS (₪) to the list of supported currencies for filament cost tracking.
+
+### Changes
+- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
+
+### Improved
+- **Shorter Inventory Location Labels** — The location column in the Inventory table now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3". External spool holders show "Ext" instead of "External". AMS-HT labels remain unchanged ("HT-A").
+- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.
+- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.
+- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.
+
+### Fixed
+- **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
+- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
+- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.
+- **Clear Plate Prompt Shown for Staged Queue Items** — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.
+- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — The printer card network badge always showed "Ethernet" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.
+- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
+- **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.
+- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.
+- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — After printing one file, subsequent prints could fail with `0500-C010 "MicroSD Card read/write exception"` until Bambuddy was restarted. The FTP upload used `transfercmd()` for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.
+- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S was incorrectly classified as a carbon rod printer, showing "Lubricate Carbon Rods" and "Clean Carbon Rods" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new `steel_rod` motion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.
+- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`"first-complete:1:0"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.
+- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — The "Reprint" and "Schedule" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based `sm:` breakpoint that didn't account for actual card width. Added `overflow-hidden` to the flex buttons and `truncate` to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.
+- **Debug Logging Banner Timer Shows Negative Time** — When enabling debug logging, the banner showed a negative duration (e.g. "-60m -59s") equal to the server's UTC offset. The `enabled_at` timestamp was stored using `datetime.now()` (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.
+- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — The "Link to Spoolman" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an "Unlink from Spoolman" button for non-Bambu spools that are already linked. Contributed by @shrunbr.
+- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. "P2S-1 - AMS-A 3") when linking, and clears it when unlinking. Contributed by @shrunbr.
+
+
+## [0.2.2b2] - 2026-03-06
+
+### New Features
+- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.
+
+### Changes
+- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
+
+### Improved
+- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys (`t()` calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (`PROVIDER_LABELS`, `EVENT_LABELS`, `CATEGORY_LABELS`) in favor of dynamic translation key lookups with fallbacks.
+- **Install Script: Branch Selection** — The native install script (`install.sh`) now supports a `--branch` option and an interactive branch prompt (defaults to `main`). Previously the script hardcoded `origin/main`, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use `git clone --branch`, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches.
+- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.
+
+### Fixed
+- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. "1 print started successfully") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.
+- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
+- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `"any maskable"` purpose into a dedicated `"maskable"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.
+- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status="completed"` (actually printed via a printer) now count toward completion stats. Files with `status="archived"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.
+- **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.
+- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
+- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
+- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
+- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
+- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.
+
+## [0.2.2b1] - 2026-03-03
+
+### Improved
+- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
+- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).
+- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.
+- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
+- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.
+
+### New Features
+- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
+- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
+- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
+- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
+- **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 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`.
+- **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
+- **Archive Card Shows "Source" Badge for Sliced .3mf Files** — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).
+- **AMS Slot Shows Wrong Material for "Support for" Profiles** — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).
+- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
+- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.
+- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — RFID spool auto-assignment logged `WARNING greenlet_spawn has not been called; can't call await_only() here` and silently failed. The `Spool.assignments` relationship was never eagerly loaded: when `auto_assign_spool()` created a new `SpoolAssignment` and called `db.add()`, SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized `spool.assignments` collection. The previous fix only covered `spool.k_profiles`. Now also initializes `spool.assignments = []` on newly created spools in `create_spool_from_tray()`, and adds `selectinload(Spool.assignments)` to both queries in `get_spool_by_tag()` for existing spools. Added `exc_info=True` to the error handlers for full tracebacks in future logs.
+- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
+- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.
+- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.
+- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
+- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
+- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
+- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
+- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
+- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.
+- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
+- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
+- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f "usebackq"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.
+- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
+- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
+- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.
+- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
+- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
+- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported "bridge" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.
+- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.
+- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
+
+### Improved
+- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
+- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
+- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
+- **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
+- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
+- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
+- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
+- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).
+- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
+- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.
+- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").
+
 ## [0.2.1.1] - 2026-02-28
 
 
@@ -13,7 +277,6 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
 - **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
 
-
 ## [0.2.1] - 2026-02-27
 
 ### Fixed

+ 1 - 0
Dockerfile

@@ -23,6 +23,7 @@ ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     ffmpeg \
+    iproute2 \
     libcap2-bin \
     && rm -rf /var/lib/apt/lists/*
 

+ 661 - 21
LICENSE

@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2025 MartinNYHC
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.

+ 12 - 2
README.md

@@ -90,6 +90,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
+- AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
+- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets; automatic PSU detection and HMS power error reporting
+- **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
+- **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
+- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history and clear errors
 - Print success rates & trends
@@ -142,6 +147,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 ### 🔔 Notifications
 - WhatsApp, Telegram, Discord
 - Email, Pushover, ntfy
+- Home Assistant persistent notifications
 - Custom webhooks
 - Quiet hours & daily digest
 - Customizable message templates with per-filament usage details
@@ -149,6 +155,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Filament usage and progress in failed/cancelled print notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
+- First layer complete alert (with camera snapshot)
 - Bed cooled alerts (configurable threshold)
 - Queue events (waiting, skipped, failed)
 
@@ -191,15 +198,17 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
+- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), automatic diagnostic log collection (30s debug capture with printer push), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
-- Group-based permissions (50+ granular permissions)
+- Group-based permissions (80+ granular permissions)
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - Comprehensive API protection (200+ endpoints secured)
 - User management (create, edit, delete, groups)
 - User activity tracking (who uploaded archives, library files, queued prints, started prints)
+- **Per-user Bambu Cloud accounts** — Each user has their own independent Cloud login for profiles
 - **Advanced Auth via Email** — SMTP integration for automated user onboarding and self-service password resets
 - Admin creates users with email — system sends secure random password automatically
 - Users can reset their own password from the login screen (no admin needed)
@@ -616,12 +625,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
 
 ## 📄 License
 
-MIT License — see [LICENSE](LICENSE) for details.
+AGPL-3.0 License — see [LICENSE](LICENSE) for details.
 
 ---
 
 ## 🙏 Acknowledgments
 
+- [SpoolEase](https://github.com/yanshay/SpoolEase) by yanshay — early inspiration for NFC-based spool tracking and AMS inventory concepts
 - [Bambu Lab](https://bambulab.com/) for amazing printers
 - The reverse engineering community for protocol documentation
 - All testers and contributors

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

@@ -2,6 +2,7 @@ import io
 import json
 import logging
 import zipfile
+from datetime import date, datetime, time, timezone
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
@@ -21,7 +22,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
@@ -122,6 +123,8 @@ def archive_to_response(
 async def list_archives(
     printer_id: int | None = None,
     project_id: int | None = None,
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
@@ -132,6 +135,8 @@ async def list_archives(
     archives = await service.list_archives(
         printer_id=printer_id,
         project_id=project_id,
+        date_from=date_from,
+        date_to=date_to,
         limit=limit,
         offset=offset,
     )
@@ -149,6 +154,78 @@ async def list_archives(
     return result
 
 
+@router.get("/slim", response_model=list[ArchiveSlim])
+async def list_archives_slim(
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
+    limit: int = Query(default=10000, le=50000),
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Lightweight archive listing for stats/dashboard widgets.
+
+    Returns only the fields needed for client-side aggregation,
+    skipping duplicate detection, file paths, and extra_data.
+    """
+    filters = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at <= dt_to)
+
+    query = (
+        select(
+            PrintArchive.printer_id,
+            PrintArchive.print_name,
+            PrintArchive.print_time_seconds,
+            PrintArchive.started_at,
+            PrintArchive.completed_at,
+            PrintArchive.filament_used_grams,
+            PrintArchive.filament_type,
+            PrintArchive.filament_color,
+            PrintArchive.status,
+            PrintArchive.cost,
+            PrintArchive.quantity,
+            PrintArchive.created_at,
+        )
+        .where(*filters)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    result = await db.execute(query)
+    rows = result.all()
+
+    return [
+        {
+            "printer_id": r.printer_id,
+            "print_name": r.print_name,
+            "print_time_seconds": r.print_time_seconds,
+            "actual_time_seconds": (
+                int((r.completed_at - r.started_at).total_seconds())
+                if r.started_at
+                and r.completed_at
+                and r.status == "completed"
+                and (r.completed_at - r.started_at).total_seconds() > 0
+                else None
+            ),
+            "filament_used_grams": r.filament_used_grams,
+            "filament_type": r.filament_type,
+            "filament_color": r.filament_color,
+            "status": r.status,
+            "started_at": r.started_at,
+            "completed_at": r.completed_at,
+            "cost": r.cost,
+            "quantity": r.quantity,
+            "created_at": r.created_at,
+        }
+        for r in rows
+    ]
+
+
 @router.get("/search", response_model=list[ArchiveResponse])
 async def search_archives(
     q: str = Query(..., min_length=2, description="Search query"),
@@ -277,7 +354,9 @@ async def rebuild_search_index(
 
 @router.get("/analysis/failures")
 async def analyze_failures(
-    days: int = 30,
+    days: int | None = None,
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
@@ -297,6 +376,8 @@ async def analyze_failures(
     service = FailureAnalysisService(db)
     return await service.analyze_failures(
         days=days,
+        date_from=date_from,
+        date_to=date_to,
         printer_id=printer_id,
         project_id=project_id,
     )
@@ -440,25 +521,42 @@ async def export_stats(
 
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(
+    date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
+    date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Get statistics across all archives."""
+    # Build date filter conditions
+    base_conditions = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        base_conditions.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        base_conditions.append(PrintArchive.created_at <= dt_to)
+
     # Total counts
-    total_result = await db.execute(select(func.count(PrintArchive.id)))
+    total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
     total_prints = total_result.scalar() or 0
 
-    successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
+    successful_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
+    )
     successful_prints = successful_result.scalar() or 0
 
-    failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
+    failed_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
+    )
     failed_prints = failed_result.scalar() or 0
 
     # Totals - use actual print time from timestamps (not slicer estimates)
     # For archives with both started_at and completed_at, calculate actual duration
     # Fall back to print_time_seconds only for archives without timestamps
     archives_for_time = await db.execute(
-        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds)
+        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
+            *base_conditions
+        )
     )
     total_seconds = 0
     for started_at, completed_at, print_time_seconds in archives_for_time.all():
@@ -473,15 +571,17 @@ async def get_archive_stats(
     total_time = total_seconds / 3600  # Convert to hours
 
     # Sum filament directly - filament_used_grams already contains the total for the print job
-    filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    filament_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
+    )
     total_filament = filament_result.scalar() or 0
 
-    cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
+    cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
 
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
-        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None))
+        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
     )
     prints_by_filament: dict[str, int] = {}
     for (filament_types,) in filament_type_result.all():
@@ -493,7 +593,9 @@ async def get_archive_stats(
 
     # By printer
     printer_result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        .where(*base_conditions)
+        .group_by(PrintArchive.printer_id)
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
@@ -501,7 +603,7 @@ async def get_archive_stats(
     # Get all completed archives with both estimated and actual times
     accuracy_result = await db.execute(
         select(PrintArchive)
-        .where(PrintArchive.status == "completed")
+        .where(PrintArchive.status == "completed", *base_conditions)
         .where(PrintArchive.print_time_seconds.isnot(None))
         .where(PrintArchive.started_at.isnot(None))
         .where(PrintArchive.completed_at.isnot(None))
@@ -540,7 +642,9 @@ async def get_archive_stats(
     energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh")
     energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15
 
-    if energy_tracking_mode == "total":
+    # When date filters are active, smart plug lifetime totals can't be
+    # filtered by date range — fall back to per-print archive data instead.
+    if energy_tracking_mode == "total" and not date_from and not date_to:
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.services.homeassistant import homeassistant_service
@@ -575,10 +679,10 @@ async def get_archive_stats(
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
     else:
         # Print mode: sum up per-print energy from archives
-        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
+        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
-        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)))
+        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
         total_energy_cost = energy_cost_result.scalar() or 0
 
     return ArchiveStats(

+ 100 - 18
backend/app/api/routes/auth.py

@@ -1,6 +1,8 @@
 from datetime import timedelta
+from typing import Annotated
 
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, Header, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -8,8 +10,11 @@ from sqlalchemy.orm import selectinload
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
+    ALGORITHM,
+    SECRET_KEY,
     Permission,
     RequirePermissionIfAuthEnabled,
+    _validate_api_key,
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
@@ -17,8 +22,10 @@ from backend.app.core.auth import (
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
+    security,
 )
 from backend.app.core.database import get_db
+from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -61,6 +68,21 @@ def _user_to_response(user: User) -> UserResponse:
     )
 
 
+def _api_key_to_user_response(api_key) -> UserResponse:
+    """Create a synthetic admin UserResponse for a valid API key."""
+    return UserResponse(
+        id=0,
+        username=f"api-key:{api_key.key_prefix}",
+        email=None,
+        role="admin",
+        is_active=True,
+        is_admin=True,
+        groups=[],
+        permissions=sorted(ALL_PERMISSIONS),
+        created_at=api_key.created_at.isoformat(),
+    )
+
+
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
@@ -135,7 +157,13 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
     logger = logging.getLogger(__name__)
 
     try:
-        # If auth_enabled is true but no users exist, allow re-setup (recovery scenario)
+        # If auth is currently enabled, block unauthenticated setup changes.
+        # Use the admin panel (/disable endpoint) to modify auth when it's already on.
+        if await is_auth_enabled(db):
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Authentication is already configured. Use the admin panel to modify auth settings.",
+            )
 
         admin_created = False
 
@@ -308,14 +336,74 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
 
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
-    current_user: User = Depends(get_current_active_user),
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get current user information."""
-    # Reload user with groups for proper permission calculation
-    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
-    user = result.scalar_one()
-    return _user_to_response(user)
+    """Get current user information.
+
+    Accepts JWT tokens (via Authorization: Bearer header) and API keys
+    (via X-API-Key header or Authorization: Bearer bb_xxx).
+    API keys return a synthetic admin user with all permissions.
+    """
+    import jwt
+    from jwt.exceptions import PyJWTError as JWTError
+
+    # Check for API key via X-API-Key header
+    if x_api_key:
+        api_key = await _validate_api_key(db, x_api_key)
+        if api_key:
+            return _api_key_to_user_response(api_key)
+
+    # Check for Bearer token (could be JWT or API key)
+    if credentials is not None:
+        token = credentials.credentials
+        # Check if it's an API key (starts with bb_)
+        if token.startswith("bb_"):
+            api_key = await _validate_api_key(db, token)
+            if api_key:
+                return _api_key_to_user_response(api_key)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Invalid API key",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+        # Otherwise treat as JWT
+        try:
+            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+            username: str = payload.get("sub")
+            if username is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+        except JWTError:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+        user = await get_user_by_username(db, username)
+        if user is None or not user.is_active:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+        # Reload with groups for proper permission calculation
+        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+        user = result.scalar_one()
+        return _user_to_response(user)
+
+    # No credentials provided
+    raise HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Authentication required",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
 
 
 @router.post("/logout")
@@ -333,21 +421,15 @@ async def test_smtp_connection(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Test SMTP connection with provided settings (admin only when auth enabled)."""
+    """Test SMTP connection using saved settings (admin only when auth enabled)."""
     import logging
 
     logger = logging.getLogger(__name__)
 
     try:
-        smtp_settings = SMTPSettings(
-            smtp_host=test_request.smtp_host,
-            smtp_port=test_request.smtp_port,
-            smtp_username=test_request.smtp_username,
-            smtp_password=test_request.smtp_password,
-            smtp_security=test_request.smtp_security,
-            smtp_auth_enabled=test_request.smtp_auth_enabled,
-            smtp_from_email=test_request.smtp_from_email,
-        )
+        smtp_settings = await get_smtp_settings(db)
+        if not smtp_settings:
+            return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
 
         # Send test email
         send_email(

+ 95 - 0
backend/app/api/routes/bug_report.py

@@ -0,0 +1,95 @@
+"""Bug report endpoint for submitting user bug reports to GitHub."""
+
+import asyncio
+import logging
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from backend.app.api.routes.support import (
+    _apply_log_level,
+    _collect_support_info,
+    _get_debug_setting,
+    _get_recent_sanitized_logs,
+    _set_debug_setting,
+)
+from backend.app.core.database import async_session
+from backend.app.services.bug_report import submit_report
+from backend.app.services.printer_manager import printer_manager
+
+router = APIRouter(prefix="/bug-report", tags=["bug-report"])
+logger = logging.getLogger(__name__)
+
+LOG_COLLECTION_SECONDS = 30
+
+
+class BugReportRequest(BaseModel):
+    description: str
+    email: str | None = None
+    screenshot_base64: str | None = None
+    include_support_info: bool = True
+
+
+class BugReportResponse(BaseModel):
+    success: bool
+    message: str
+    issue_url: str | None = None
+    issue_number: int | None = None
+
+
+async def _collect_debug_logs() -> str:
+    """Enable debug logging, push all printers, wait, then collect logs."""
+    # Check if debug was already enabled
+    async with async_session() as db:
+        was_debug, _ = await _get_debug_setting(db)
+
+    # Enable debug logging
+    if not was_debug:
+        async with async_session() as db:
+            await _set_debug_setting(db, True)
+        _apply_log_level(True)
+        logger.info("Bug report: temporarily enabled debug logging")
+
+    # Send push_all to all connected printers
+    for printer_id in list(printer_manager._clients.keys()):
+        try:
+            printer_manager.request_status_update(printer_id)
+        except Exception:
+            logger.debug("Failed to push_all for printer %s", printer_id)
+
+    # Wait for logs to accumulate
+    await asyncio.sleep(LOG_COLLECTION_SECONDS)
+
+    # Collect logs
+    logs = await _get_recent_sanitized_logs()
+
+    # Restore previous log level if it wasn't debug before
+    if not was_debug:
+        async with async_session() as db:
+            await _set_debug_setting(db, False)
+        _apply_log_level(False)
+        logger.info("Bug report: restored normal logging")
+
+    return logs
+
+
+@router.post("/submit", response_model=BugReportResponse)
+async def submit_bug_report(report: BugReportRequest):
+    """Submit a bug report. No auth required — anyone should be able to report bugs."""
+    support_info = None
+    if report.include_support_info:
+        try:
+            support_info = await _collect_support_info()
+            logs = await _collect_debug_logs()
+            if logs:
+                support_info["recent_logs"] = logs
+        except Exception:
+            logger.exception("Failed to collect support info for bug report")
+
+    result = await submit_report(
+        description=report.description,
+        reporter_email=report.email,
+        screenshot_base64=report.screenshot_base64,
+        support_info=support_info,
+    )
+    return BugReportResponse(**result)

+ 203 - 112
backend/app/api/routes/camera.py

@@ -2,6 +2,8 @@
 
 import asyncio
 import logging
+import subprocess
+import sys
 from collections.abc import AsyncGenerator
 
 from fastapi import APIRouter, Depends, HTTPException, Request
@@ -16,6 +18,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.services.camera import (
     capture_camera_frame,
+    create_tls_proxy,
     generate_chamber_image_stream,
     get_camera_port,
     get_ffmpeg_path,
@@ -161,6 +164,43 @@ async def generate_chamber_mjpeg_stream(
         logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
 
+async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
+    """Terminate an ffmpeg process gracefully, then kill if needed."""
+    if process.returncode is not None:
+        return  # Already dead
+    try:
+        process.terminate()
+        try:
+            await asyncio.wait_for(process.wait(), timeout=2.0)
+        except TimeoutError:
+            logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
+            process.kill()
+            await process.wait()
+    except ProcessLookupError:
+        pass  # Already dead
+    except OSError as e:
+        logger.warning("Error terminating ffmpeg: %s", e)
+    _spawned_ffmpeg_pids.pop(process.pid, None)
+
+
+async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
+    """Read ffmpeg stderr for diagnostics (best-effort, non-blocking)."""
+    if not process or not process.stderr:
+        return None
+    try:
+        data = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
+        return data.decode(errors="replace") if data else None
+    except (TimeoutError, Exception):
+        return None
+
+
+# Max consecutive RTSP reconnections before giving up.
+# Some printer firmwares (notably P2S) drop RTSP sessions after a few seconds,
+# so we transparently respawn ffmpeg to keep the MJPEG stream alive.
+_RTSP_MAX_RECONNECTS = 30
+_RTSP_RECONNECT_DELAY = 0.2  # seconds between respawns
+
+
 async def generate_rtsp_mjpeg_stream(
     ip_address: str,
     access_code: str,
@@ -173,6 +213,7 @@ async def generate_rtsp_mjpeg_stream(
     """Generate MJPEG stream from printer camera using ffmpeg/RTSP.
 
     This is for X1/H2/P2 models that support RTSP streaming.
+    Auto-reconnects when the printer drops the RTSP session (common on P2S).
     """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
@@ -181,17 +222,15 @@ async def generate_rtsp_mjpeg_stream(
         return
 
     port = get_camera_port(model)
-    camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
+
+    # Use a local TLS proxy so Python's OpenSSL handles TLS instead of
+    # ffmpeg's GnuTLS.  This fixes P2S (and potentially other models)
+    # dropping the RTSP session after a few seconds due to GnuTLS's
+    # hardened Debian defaults rejecting TLS renegotiation.
+    proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
+    camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
 
     # ffmpeg command to output MJPEG stream to stdout
-    # -rtsp_transport tcp: Use TCP for reliability
-    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
-    # -timeout: Connection timeout in microseconds (30 seconds)
-    # -buffer_size: Larger buffer for network jitter
-    # -max_delay: Maximum demuxing delay
-    # -f mjpeg: Output as MJPEG
-    # -q:v 5: Quality (lower = better, 2-10 is good range)
-    # -r: Output framerate
     cmd = [
         ffmpeg,
         "-rtsp_transport",
@@ -204,6 +243,14 @@ async def generate_rtsp_mjpeg_stream(
         "1024000",  # 1MB buffer
         "-max_delay",
         "500000",  # 0.5 seconds max delay
+        "-probesize",
+        "32",  # Minimal probing for faster startup
+        "-analyzeduration",
+        "0",  # Skip format analysis for faster startup
+        "-fflags",
+        "nobuffer",  # Reduce internal buffering
+        "-flags",
+        "low_delay",  # Minimize decode latency
         "-i",
         camera_url,
         "-f",
@@ -221,101 +268,161 @@ async def generate_rtsp_mjpeg_stream(
     )
     logger.debug("ffmpeg command: %s ... (url hidden)", ffmpeg)
 
+    # On Windows, spawn ffmpeg in its own process group so that
+    # terminate() doesn't broadcast CTRL_C_EVENT to uvicorn (#605).
+    spawn_kwargs: dict = {}
+    if sys.platform == "win32":
+        spawn_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
+
+    jpeg_start = b"\xff\xd8"
+    jpeg_end = b"\xff\xd9"
+    reconnect_count = 0
     process = None
-    try:
-        process = await asyncio.create_subprocess_exec(
-            *cmd,
-            stdout=asyncio.subprocess.PIPE,
-            stderr=asyncio.subprocess.PIPE,
-        )
+    got_any_frames = False
 
-        # Track active process for cleanup
-        if stream_id:
-            _active_streams[stream_id] = process
-        import time as _time
+    try:
+        while reconnect_count <= _RTSP_MAX_RECONNECTS:
+            # Check for client disconnect before (re)connecting
+            if disconnect_event and disconnect_event.is_set():
+                break
 
-        _spawned_ffmpeg_pids[process.pid] = _time.time()
+            if reconnect_count > 0:
+                logger.info(
+                    "RTSP reconnecting (%d/%d) for %s (stream_id=%s)",
+                    reconnect_count,
+                    _RTSP_MAX_RECONNECTS,
+                    ip_address,
+                    stream_id,
+                )
+                await asyncio.sleep(_RTSP_RECONNECT_DELAY)
+                if disconnect_event and disconnect_event.is_set():
+                    break
 
-        # Give ffmpeg a moment to start and check for immediate failures
-        await asyncio.sleep(0.5)
-        if process.returncode is not None:
-            stderr = await process.stderr.read()
-            logger.error("ffmpeg failed immediately: %s", stderr.decode())
-            yield (
-                b"--frame\r\n"
-                b"Content-Type: text/plain\r\n\r\n"
-                b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
+            # Spawn ffmpeg
+            process = await asyncio.create_subprocess_exec(
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+                **spawn_kwargs,
             )
-            return
 
-        # Read JPEG frames from ffmpeg output
-        # JPEG images start with 0xFFD8 and end with 0xFFD9
-        buffer = b""
-        jpeg_start = b"\xff\xd8"
-        jpeg_end = b"\xff\xd9"
+            if stream_id:
+                _active_streams[stream_id] = process
+            import time as _time
 
-        while True:
-            # Check if client disconnected
-            if disconnect_event and disconnect_event.is_set():
-                logger.info("Client disconnected, stopping stream %s", stream_id)
-                break
+            _spawned_ffmpeg_pids[process.pid] = _time.time()
 
-            try:
-                # Read chunk from ffmpeg - use longer timeout for network hiccups
-                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
+            # Brief check for immediate startup failures
+            await asyncio.sleep(0.1)
+            if process.returncode is not None:
+                stderr = await process.stderr.read()
+                stderr_text = stderr.decode(errors="replace")
+                logger.error("ffmpeg failed immediately (attempt %d): %s", reconnect_count + 1, stderr_text)
+                _spawned_ffmpeg_pids.pop(process.pid, None)
+                if not got_any_frames and reconnect_count == 0:
+                    # First attempt failed immediately — camera is likely unreachable
+                    yield (
+                        b"--frame\r\n"
+                        b"Content-Type: text/plain\r\n\r\n"
+                        b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
+                    )
+                    return
+                reconnect_count += 1
+                continue
 
-                if not chunk:
-                    logger.warning("Camera stream ended (no more data)")
-                    break
+            # Read JPEG frames from ffmpeg stdout
+            buffer = b""
+            stream_ended = False
+            client_gone = False
 
-                buffer += chunk
+            while True:
+                if disconnect_event and disconnect_event.is_set():
+                    client_gone = True
+                    break
 
-                # Find complete JPEG frames in buffer
-                while True:
-                    start_idx = buffer.find(jpeg_start)
-                    if start_idx == -1:
-                        # No start marker, clear buffer up to last 2 bytes
-                        buffer = buffer[-2:] if len(buffer) > 2 else buffer
+                try:
+                    chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
+
+                    if not chunk:
+                        # ffmpeg exited — log stderr and break to reconnect
+                        stderr_text = await _read_ffmpeg_stderr(process)
+                        if stderr_text:
+                            logger.warning("ffmpeg stderr (stream_id=%s): %s", stream_id, stderr_text)
+                        logger.warning("RTSP stream ended for %s (stream_id=%s), will reconnect", ip_address, stream_id)
+                        stream_ended = True
                         break
 
-                    # Trim anything before the start marker
-                    if start_idx > 0:
-                        buffer = buffer[start_idx:]
+                    buffer += chunk
 
-                    end_idx = buffer.find(jpeg_end, 2)  # Skip first 2 bytes
-                    if end_idx == -1:
-                        # No end marker yet, wait for more data
-                        break
+                    # Extract complete JPEG frames from buffer
+                    while True:
+                        start_idx = buffer.find(jpeg_start)
+                        if start_idx == -1:
+                            buffer = buffer[-2:] if len(buffer) > 2 else buffer
+                            break
 
-                    # Extract complete frame
-                    frame = buffer[: end_idx + 2]
-                    buffer = buffer[end_idx + 2 :]
+                        if start_idx > 0:
+                            buffer = buffer[start_idx:]
 
-                    # Save frame to buffer for photo capture and track timestamp
-                    if printer_id is not None:
-                        import time
+                        end_idx = buffer.find(jpeg_end, 2)
+                        if end_idx == -1:
+                            break
 
-                        _last_frames[printer_id] = frame
-                        _last_frame_times[printer_id] = time.time()
+                        frame = buffer[: end_idx + 2]
+                        buffer = buffer[end_idx + 2 :]
+                        got_any_frames = True
 
-                    # Yield frame in MJPEG format
-                    yield (
-                        b"--frame\r\n"
-                        b"Content-Type: image/jpeg\r\n"
-                        b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
-                        b"\r\n" + frame + b"\r\n"
-                    )
+                        if printer_id is not None:
+                            import time
 
-            except TimeoutError:
-                logger.warning("Camera stream read timeout")
-                break
-            except asyncio.CancelledError:
-                logger.info("Camera stream cancelled (stream_id=%s)", stream_id)
-                break
-            except GeneratorExit:
-                logger.info("Camera stream generator exit (stream_id=%s)", stream_id)
+                            _last_frames[printer_id] = frame
+                            _last_frame_times[printer_id] = time.time()
+
+                        yield (
+                            b"--frame\r\n"
+                            b"Content-Type: image/jpeg\r\n"
+                            b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
+                            b"\r\n" + frame + b"\r\n"
+                        )
+
+                except TimeoutError:
+                    stderr_text = await _read_ffmpeg_stderr(process)
+                    if stderr_text:
+                        logger.warning("ffmpeg stderr on timeout: %s", stderr_text)
+                    logger.warning("RTSP read timeout for %s (stream_id=%s)", ip_address, stream_id)
+                    stream_ended = True
+                    break
+                except asyncio.CancelledError:
+                    logger.info("Camera stream cancelled (stream_id=%s)", stream_id)
+                    client_gone = True
+                    break
+                except GeneratorExit:
+                    logger.info("Camera stream generator exit (stream_id=%s)", stream_id)
+                    client_gone = True
+                    break
+
+            # Clean up this ffmpeg process before reconnecting or exiting
+            await _terminate_ffmpeg(process, stream_id)
+            process = None
+
+            if client_gone:
                 break
 
+            if stream_ended:
+                reconnect_count += 1
+                continue
+
+            # Normal exit (shouldn't reach here, but be safe)
+            break
+
+        if reconnect_count > _RTSP_MAX_RECONNECTS:
+            logger.error(
+                "RTSP max reconnects (%d) reached for %s (stream_id=%s)",
+                _RTSP_MAX_RECONNECTS,
+                ip_address,
+                stream_id,
+            )
+
     except FileNotFoundError:
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
         yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
@@ -336,24 +443,13 @@ async def generate_rtsp_mjpeg_stream(
             _last_frame_times.pop(printer_id, None)
             _stream_start_times.pop(printer_id, None)
 
-        if process and process.returncode is None:
-            logger.info("Terminating ffmpeg process for stream %s", stream_id)
-            try:
-                process.terminate()
-                try:
-                    await asyncio.wait_for(process.wait(), timeout=2.0)
-                except TimeoutError:
-                    logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
-                    process.kill()
-                    await process.wait()
-            except ProcessLookupError:
-                pass  # Process already dead
-            except OSError as e:
-                logger.warning("Error terminating ffmpeg: %s", e)
-            logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
-        # Remove from PID tracking now that process is confirmed dead
         if process:
-            _spawned_ffmpeg_pids.pop(process.pid, None)
+            await _terminate_ffmpeg(process, stream_id)
+            logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
+
+        # Shut down the TLS proxy
+        proxy_server.close()
+        await proxy_server.wait_closed()
 
 
 @router.get("/{printer_id}/camera/stream")
@@ -401,19 +497,13 @@ async def camera_stream(
 
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
-            frame_interval = 1.0 / fps
-            last_yield_time = 0.0
             try:
                 async for frame in generate_mjpeg_stream(
                     printer.external_camera_url, printer.external_camera_type, fps
                 ):
-                    # Rate limit to prevent overwhelming browser
-                    current_time = time.time()
-                    elapsed = current_time - last_yield_time
-                    if elapsed < frame_interval:
-                        await asyncio.sleep(frame_interval - elapsed)
-                    last_yield_time = time.time()
-                    _last_frame_times[printer_id] = last_yield_time
+                    # generate_mjpeg_stream already handles rate limiting;
+                    # just track frame times for stall detection
+                    _last_frame_times[printer_id] = time.time()
                     yield frame
             finally:
                 _active_external_streams.discard(printer_id)
@@ -1148,7 +1238,7 @@ async def delete_reference(
 def _scan_bambu_ffmpeg_pids() -> list[int]:
     """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
 
-    These are definitely ours — no other software connects to rtsps://bblp:.
+    These are definitely ours — no other software connects to rtsp(s)://bblp:.
     This catches orphans that survive app restarts and are not in any tracking dict.
     """
     import os
@@ -1161,7 +1251,8 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
             try:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                     cmdline = f.read()
-                if b"ffmpeg" in cmdline and b"rtsps://bblp:" in cmdline:
+                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
+                if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
                 continue
@@ -1193,7 +1284,7 @@ async def cleanup_orphaned_streams():
     active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
 
     # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
-    #    Any ffmpeg with rtsps://bblp: that is NOT in an active stream is orphaned.
+    #    Any ffmpeg with rtsp(s)://bblp: that is NOT in an active stream is orphaned.
     for pid in _scan_bambu_ffmpeg_pids():
         if pid in active_pids:
             continue

+ 82 - 74
backend/app/api/routes/cloud.py

@@ -38,6 +38,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     get_cloud_service,
 )
+from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 logger = logging.getLogger(__name__)
 
@@ -49,15 +50,37 @@ CLOUD_TOKEN_KEY = "bambu_cloud_token"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
 
 
-async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
-    """Get stored cloud token and email from database."""
+async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None]:
+    """Get stored cloud token and email.
+
+    When a user is provided (auth enabled), returns that user's per-user credentials.
+    When user is None (auth disabled), falls back to global Settings table.
+    """
+    if user is not None:
+        return user.cloud_token, user.cloud_email
+
+    # Fallback: global storage (auth disabled)
     result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
     settings = {s.key: s.value for s in result.scalars().all()}
     return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
 
 
-async def store_token(db: AsyncSession, token: str, email: str) -> None:
-    """Store cloud token and email in database."""
+async def store_token(db: AsyncSession, token: str, email: str, user: User | None = None) -> None:
+    """Store cloud token and email.
+
+    When a user is provided (auth enabled), stores on the user record.
+    When user is None (auth disabled), stores in global Settings table.
+    """
+    if user is not None:
+        # User object is from the auth dependency's session (detached),
+        # so use a direct UPDATE via the route's db session.
+        from sqlalchemy import update
+
+        await db.execute(update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email))
+        await db.commit()
+        return
+
+    # Fallback: global storage (auth disabled)
     for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
         result = await db.execute(select(Settings).where(Settings.key == key))
         setting = result.scalar_one_or_none()
@@ -68,8 +91,20 @@ async def store_token(db: AsyncSession, token: str, email: str) -> None:
     await db.commit()
 
 
-async def clear_token(db: AsyncSession) -> None:
-    """Clear stored cloud token and email."""
+async def clear_token(db: AsyncSession, user: User | None = None) -> None:
+    """Clear stored cloud token and email.
+
+    When a user is provided (auth enabled), clears that user's credentials.
+    When user is None (auth disabled), clears from global Settings table.
+    """
+    if user is not None:
+        from sqlalchemy import update
+
+        await db.execute(update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None))
+        await db.commit()
+        return
+
+    # Fallback: global storage (auth disabled)
     result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
     for setting in result.scalars().all():
         await db.delete(setting)
@@ -79,10 +114,10 @@ async def clear_token(db: AsyncSession) -> None:
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """Get current cloud authentication status."""
-    token, email = await get_stored_token(db)
+    token, email = await get_stored_token(db, current_user)
     cloud = get_cloud_service()
 
     if token:
@@ -98,7 +133,7 @@ async def get_auth_status(
 async def login(
     request: CloudLoginRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Initiate login to Bambu Cloud.
@@ -112,15 +147,12 @@ async def login(
     """
     cloud = get_cloud_service()
 
-    # Store email temporarily for verification step
-    await store_token(db, "", request.email)
-
     try:
         result = await cloud.login_request(request.email, request.password)
 
         if result.get("success") and cloud.access_token:
             # Direct login succeeded (rare)
-            await store_token(db, cloud.access_token, request.email)
+            await store_token(db, cloud.access_token, request.email, current_user)
 
         return CloudLoginResponse(
             success=result.get("success", False),
@@ -139,7 +171,7 @@ async def login(
 async def verify_code(
     request: CloudVerifyRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Complete login with verification code (email or TOTP).
@@ -162,7 +194,7 @@ async def verify_code(
             result = await cloud.verify_code(request.email, request.code)
 
         if result.get("success") and cloud.access_token:
-            await store_token(db, cloud.access_token, request.email)
+            await store_token(db, cloud.access_token, request.email, current_user)
 
         return CloudLoginResponse(
             success=result.get("success", False),
@@ -179,7 +211,7 @@ async def verify_code(
 async def set_token(
     request: CloudTokenRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Set access token directly.
@@ -192,7 +224,7 @@ async def set_token(
     # Verify token works by trying to get profile
     try:
         await cloud.get_user_profile()
-        await store_token(db, request.access_token, "token-auth")
+        await store_token(db, request.access_token, "token-auth", current_user)
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
     except BambuCloudError:
         cloud.logout()
@@ -202,12 +234,12 @@ async def set_token(
 @router.post("/logout")
 async def logout(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """Log out of Bambu Cloud."""
     cloud = get_cloud_service()
     cloud.logout()
-    await clear_token(db)
+    await clear_token(db, current_user)
     return {"success": True}
 
 
@@ -215,14 +247,14 @@ async def logout(
 async def get_slicer_settings(
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Get all slicer settings (filament, printer, process presets).
 
     Requires authentication.
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -280,7 +312,7 @@ async def get_slicer_settings(
 
         return result
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -290,14 +322,14 @@ async def get_slicer_settings(
 async def get_setting_detail(
     setting_id: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Get detailed information for a specific setting/preset.
 
     Returns the full preset configuration.
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -311,7 +343,7 @@ async def get_setting_detail(
         data = await cloud.get_setting_detail(setting_id)
         return data
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -321,7 +353,7 @@ async def get_setting_detail(
 async def get_filament_presets(
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """
     Get just filament presets (convenience endpoint).
@@ -329,7 +361,7 @@ async def get_filament_presets(
     Returns all filament presets with custom presets first.
     Uses the same cache as get_slicer_settings.
     """
-    settings = await get_slicer_settings(version=version, db=db)
+    settings = await get_slicer_settings(version=version, db=db, current_user=current_user)
     return settings.filament
 
 
@@ -503,39 +535,15 @@ async def _enrich_from_local_presets(
     return result
 
 
-def _filament_id_to_setting_id(filament_id: str) -> str:
-    """
-    Convert filament_id to setting_id format for Bambu Cloud API.
-
-    Printers report filament_id (e.g., GFA00, GFG02) but the API expects
-    setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
-
-    User presets (starting with "P") and already-correct IDs are returned unchanged.
-    """
-    if not filament_id:
-        return filament_id
-
-    # User presets start with "P" - leave unchanged
-    if filament_id.startswith("P"):
-        return filament_id
-
-    # Official Bambu presets: GFx## -> GFSx##
-    # Check if it matches the filament_id pattern (GF followed by letter and digits)
-    if filament_id.startswith("GF") and len(filament_id) >= 4:
-        # Check if it's already a setting_id (has S after GF)
-        if filament_id[2] == "S":
-            return filament_id
-        # Insert "S" after "GF": GFA00 -> GFSA00
-        return f"GFS{filament_id[2:]}"
-
-    return filament_id
+# _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
+_filament_id_to_setting_id = filament_id_to_setting_id
 
 
 @router.post("/filament-info")
 async def get_filament_info(
     setting_ids: list[str] = Body(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """
     Get filament preset info (name and K value) for multiple setting IDs.
@@ -568,7 +576,7 @@ async def get_filament_info(
 
     # Phase 2: Try cloud for uncached IDs
     if unresolved_ids:
-        token, _ = await get_stored_token(db)
+        token, _ = await get_stored_token(db, current_user)
         if token:
             cloud = get_cloud_service()
             cloud.set_token(token)
@@ -613,14 +621,14 @@ async def get_filament_info(
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
 ):
     """
     Get list of bound printer devices.
 
     Returns printers registered to the user's Bambu account.
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -645,7 +653,7 @@ async def get_devices(
             for d in devices
         ]
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -654,7 +662,7 @@ async def get_devices(
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 async def get_firmware_updates(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware updates for all bound devices.
@@ -667,7 +675,7 @@ async def get_firmware_updates(
 
     Requires cloud authentication.
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -724,7 +732,7 @@ async def get_firmware_updates(
         return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
 
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -734,7 +742,7 @@ async def get_firmware_updates(
 async def create_setting(
     request: SlicerSettingCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Create a new slicer preset/setting.
@@ -744,7 +752,7 @@ async def create_setting(
 
     Type should be: 'filament', 'print', or 'printer'
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -764,7 +772,7 @@ async def create_setting(
         )
         return data
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -775,14 +783,14 @@ async def update_setting(
     setting_id: str,
     request: SlicerSettingUpdate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Update an existing slicer preset/setting.
 
     Updates the preset's name and/or settings on Bambu Cloud.
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -800,7 +808,7 @@ async def update_setting(
         )
         return data
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -810,14 +818,14 @@ async def update_setting(
 async def delete_setting(
     setting_id: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Delete a slicer preset/setting.
 
     Removes the preset from Bambu Cloud. This cannot be undone.
     """
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
@@ -834,7 +842,7 @@ async def delete_setting(
             message=result.get("message", "Setting deleted"),
         )
     except BambuCloudAuthError:
-        await clear_token(db)
+        await clear_token(db, current_user)
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
@@ -897,7 +905,7 @@ _filament_id_name_cache_time: float = 0
 @router.get("/filament-id-map")
 async def get_filament_id_map(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """
     Get filament_id → name mapping for user cloud presets.
@@ -914,7 +922,7 @@ async def get_filament_id_map(
     if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
         return _filament_id_name_cache
 
-    token, _ = await get_stored_token(db)
+    token, _ = await get_stored_token(db, current_user)
     if not token:
         return _filament_id_name_cache or {}
 
@@ -953,7 +961,7 @@ async def get_filament_id_map(
 @router.get("/fields/{preset_type}")
 async def get_preset_fields(
     preset_type: Literal["filament", "print", "process", "printer"],
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Get field definitions for a preset type.
@@ -974,7 +982,7 @@ async def get_preset_fields(
 
 @router.get("/fields")
 async def get_all_preset_fields(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """
     Get all field definitions for all preset types.

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

@@ -35,7 +35,7 @@ def _permission_label(perm: Permission) -> str:
     if len(parts) == 2:
         resource, action = parts
         resource = resource.replace("_", " ").title()
-        action = action.title()
+        action = action.replace("_", " ").title()
         return f"{action} {resource}"
     return perm.value
 

+ 221 - 32
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.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -30,6 +31,7 @@ from backend.app.schemas.spool import (
     SpoolUpdate,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
 
 logger = logging.getLogger(__name__)
 
@@ -77,6 +79,10 @@ class CatalogEntryUpdate(BaseModel):
     weight: int
 
 
+class BulkDeleteIdsRequest(BaseModel):
+    ids: list[int]
+
+
 # ── Color Catalog Schemas ──────────────────────────────────────────────────
 
 
@@ -174,6 +180,23 @@ async def delete_catalog_entry(
     return {"status": "deleted"}
 
 
+@router.post("/catalog/bulk-delete")
+async def bulk_delete_catalog_entries(
+    data: BulkDeleteIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete multiple spool catalog entries by ID."""
+    if not data.ids:
+        return {"deleted": 0}
+    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
+    rows = result.scalars().all()
+    for row in rows:
+        await db.delete(row)
+    await db.commit()
+    return {"deleted": len(rows)}
+
+
 @router.post("/catalog/reset")
 async def reset_spool_catalog(
     db: AsyncSession = Depends(get_db),
@@ -266,6 +289,23 @@ async def delete_color_entry(
     return {"status": "deleted"}
 
 
+@router.post("/colors/bulk-delete")
+async def bulk_delete_color_entries(
+    data: BulkDeleteIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete multiple color catalog entries by ID."""
+    if not data.ids:
+        return {"deleted": 0}
+    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
+    rows = result.scalars().all()
+    for row in rows:
+        await db.delete(row)
+    await db.commit()
+    return {"deleted": len(rows)}
+
+
 @router.post("/colors/reset")
 async def reset_color_catalog(
     db: AsyncSession = Depends(get_db),
@@ -634,9 +674,11 @@ async def replace_k_profiles(
 async def list_assignments(
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
 ):
     """List spool assignments, optionally filtered by printer."""
+    from backend.app.services.printer_manager import printer_manager
+
     query = select(SpoolAssignment).options(
         selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
         selectinload(SpoolAssignment.printer),
@@ -644,7 +686,53 @@ async def list_assignments(
     if printer_id is not None:
         query = query.where(SpoolAssignment.printer_id == printer_id)
     result = await db.execute(query)
-    return list(result.scalars().all())
+    assignments = list(result.scalars().all())
+
+    # Build (printer_id, ams_id) -> ams_serial map from live printer states.
+    # Fetch all statuses in one call rather than one get_status() call per printer.
+    serial_map: dict[tuple[int, int], str] = {}
+    seen_printer_ids: set[int] = {a.printer_id for a in assignments}
+    all_statuses = printer_manager.get_all_statuses()
+    for pid in seen_printer_ids:
+        state = all_statuses.get(pid)
+        if state and state.raw_data:
+            for ams_unit in state.raw_data.get("ams", []):
+                sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
+                if sn:
+                    try:
+                        serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
+                    except (ValueError, TypeError):
+                        continue
+
+    # Fetch all relevant AMS labels keyed by serial number
+    all_serials = set(serial_map.values())
+    # Also include synthetic fallback keys for assignments without a known serial
+    synthetic_keys: dict[str, tuple[int, int]] = {}
+    for a in assignments:
+        if (a.printer_id, a.ams_id) not in serial_map:
+            synthetic = f"p{a.printer_id}a{a.ams_id}"
+            synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
+            all_serials.add(synthetic)
+
+    label_by_serial: dict[str, str] = {}
+    if all_serials:
+        lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
+        for lbl in lbl_result.scalars().all():
+            label_by_serial[lbl.ams_serial_number] = lbl.label
+
+    # Build response objects, attaching ams_label where available
+    responses: list[SpoolAssignmentResponse] = []
+    for a in assignments:
+        resp = SpoolAssignmentResponse.model_validate(a)
+        sn = serial_map.get((a.printer_id, a.ams_id))
+        if sn and sn in label_by_serial:
+            resp.ams_label = label_by_serial[sn]
+        elif not sn:
+            synthetic = f"p{a.printer_id}a{a.ams_id}"
+            resp.ams_label = label_by_serial.get(synthetic)
+        responses.append(resp)
+
+    return responses
 
 
 @router.post("/assignments", response_model=SpoolAssignmentResponse)
@@ -731,18 +819,15 @@ async def assign_spool(
         if client:
             # Build filament setting from spool data
             tray_type = spool.material
-            tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
+            tray_sub_brands = (
+                f"{spool.brand} {spool.material} {spool.subtype}".strip()
+                if spool.brand
+                else f"{spool.material} {spool.subtype}"
+                if spool.subtype
+                else spool.material
+            )
             tray_color = spool.rgba or "FFFFFFFF"
-            tray_info_idx = spool.slicer_filament or ""
-            setting_id = ""
 
-            # Resolve tray_info_idx for the MQTT command.
-            # Priority:
-            #   1. Use the spool's own slicer_filament if set (including
-            #      cloud-synced custom presets like PFUS* / P*).
-            #   2. Reuse the slot's existing tray_info_idx if it's a specific
-            #      (non-generic) preset for the same material.
-            #   3. Fall back to a generic Bambu filament ID.
             _GENERIC_IDS = {
                 "PLA": "GFL99",
                 "PETG": "GFG99",
@@ -761,26 +846,130 @@ async def assign_spool(
             }
             _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
 
-            if tray_info_idx:
-                logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
-            elif (
-                current_tray_info_idx
-                and current_tray_info_idx not in _GENERIC_ID_VALUES
-                and fingerprint_type
-                and fingerprint_type.upper() == tray_type.upper()
-            ):
-                logger.info(
-                    "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
-                    current_tray_info_idx,
-                    tray_type,
-                )
-                tray_info_idx = current_tray_info_idx
-            elif tray_type:
-                material = tray_type.upper().strip()
-                generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
-                if generic:
-                    logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
-                    tray_info_idx = generic
+            # Resolve tray_info_idx + setting_id for the MQTT command.
+            # Three sources in priority order:
+            #   1. Cloud profile (if cloud connected) — resolve filament_id
+            #      from setting_id via cloud API
+            #   2. Local profile — use generic filament ID for material
+            #   3. Hard-coded fallback — generic Bambu filament IDs
+            tray_info_idx = ""
+            setting_id = ""
+            sf = spool.slicer_filament or ""
+
+            if sf:
+                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
+                base_sf = sf.split("_")[0] if "_" in sf else sf
+                if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+                    # Cloud setting_id — need to resolve real filament_id
+                    # Use base_sf (version suffix stripped) for cloud API + MQTT
+                    setting_id = base_sf
+                    try:
+                        from backend.app.services.bambu_cloud import get_cloud_service
+
+                        cloud = get_cloud_service()
+                        if cloud.is_authenticated:
+                            detail = await cloud.get_setting_detail(base_sf)
+                            if detail.get("filament_id"):
+                                tray_info_idx = detail["filament_id"]
+                                logger.info(
+                                    "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
+                                    tray_info_idx,
+                                    sf,
+                                )
+                                # Use cloud preset name for tray_sub_brands if available
+                                cloud_name = detail.get("name", "")
+                                if cloud_name:
+                                    tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                            elif detail.get("base_id"):
+                                # Derive from base_id (e.g. "GFSL05" → "GFL05")
+                                bid = detail["base_id"].split("_")[0]
+                                if bid.startswith("GFS") and len(bid) >= 5:
+                                    tray_info_idx = f"GF{bid[3:]}"
+                                else:
+                                    tray_info_idx = bid
+                                logger.info(
+                                    "Spool assign: derived filament_id=%r from base_id=%r",
+                                    tray_info_idx,
+                                    detail["base_id"],
+                                )
+                    except Exception as e:
+                        logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
+
+                    if not tray_info_idx:
+                        # Cloud lookup failed — use normalize as fallback
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                elif base_sf.startswith("GF"):
+                    # Official Bambu filament_id (e.g. "GFL05")
+                    tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                    logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
+
+                else:
+                    # Could be a local preset ID or material type — try local DB
+                    try:
+                        local_id = int(sf)
+                        from backend.app.models.local_preset import LocalPreset as LP
+
+                        lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+                        lp = lp_result.scalar_one_or_none()
+                        if lp:
+                            mat = (spool.material or lp.filament_type or "").upper().strip()
+                            tray_info_idx = (
+                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
+                            )
+                            # Use local preset name for tray_sub_brands
+                            if lp.name:
+                                tray_sub_brands = lp.name.split("@")[0].strip()
+                            logger.info(
+                                "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
+                                local_id,
+                                mat,
+                                tray_info_idx,
+                            )
+                    except (ValueError, TypeError):
+                        # Not a numeric ID — treat as material type string
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+            # Cross-check: the cloud API returns the base filament_id for
+            # versioned setting_ids (e.g. GFSL99 → GFL99 for all PLA variants).
+            # If the spool has a specific preset name (e.g. "Generic PLA Silk"),
+            # reverse-lookup the correct filament_id from the built-in table.
+            if tray_info_idx and spool.slicer_filament_name:
+                from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+                expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
+                if expected_name and expected_name != spool.slicer_filament_name:
+                    for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
+                        if fname == spool.slicer_filament_name:
+                            logger.info(
+                                "Spool assign: corrected filament_id %r→%r (name=%r)",
+                                tray_info_idx,
+                                fid,
+                                spool.slicer_filament_name,
+                            )
+                            tray_info_idx = fid
+                            setting_id = filament_id_to_setting_id(fid)
+                            break
+
+            if not tray_info_idx:
+                # Fallback: reuse slot's existing tray_info_idx or generic ID
+                if (
+                    current_tray_info_idx
+                    and current_tray_info_idx not in _GENERIC_ID_VALUES
+                    and fingerprint_type
+                    and fingerprint_type.upper() == tray_type.upper()
+                ):
+                    logger.info(
+                        "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
+                        current_tray_info_idx,
+                        tray_type,
+                    )
+                    tray_info_idx = current_tray_info_idx
+                elif tray_type:
+                    material = tray_type.upper().strip()
+                    generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
+                    if generic:
+                        logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
+                        tray_info_idx = generic
 
             # Temperature: use spool overrides if set, else material defaults
             temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))

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

@@ -415,7 +415,7 @@ async def create_folder(
         archive_id=data.archive_id,
     )
     db.add(folder)
-    await db.flush()
+    await db.commit()
     await db.refresh(folder)
 
     return FolderResponse(
@@ -531,7 +531,7 @@ async def update_folder(
                 raise HTTPException(status_code=404, detail="Archive not found")
             folder.archive_id = data.archive_id
 
-    await db.flush()
+    await db.commit()
     await db.refresh(folder)
 
     # Get file count and names
@@ -813,7 +813,7 @@ async def upload_file(
             created_by_id=current_user.id if current_user else None,
         )
         db.add(library_file)
-        await db.flush()
+        await db.commit()
         await db.refresh(library_file)
 
         return FileUploadResponse(
@@ -1962,7 +1962,7 @@ async def update_file(
     if data.notes is not None:
         file.notes = data.notes if data.notes else None
 
-    await db.flush()
+    await db.commit()
     await db.refresh(file)
 
     # Return full response (reuse get_file logic)

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

@@ -34,7 +34,7 @@ router = APIRouter(prefix="/maintenance", tags=["maintenance"])
 
 # Default maintenance types
 DEFAULT_MAINTENANCE_TYPES = [
-    # Carbon rod models only (X1/P1/P2S)
+    # Carbon rod models only (X1/P1)
     {
         "name": "Lubricate Carbon Rods",
         "description": "Apply lubricant to carbon rods for smooth motion",
@@ -47,6 +47,19 @@ DEFAULT_MAINTENANCE_TYPES = [
         "default_interval_hours": 100.0,
         "icon": "Sparkles",
     },
+    # Steel rod models only (P2S)
+    {
+        "name": "Lubricate Steel Rods",
+        "description": "Apply lubricant to steel rods for smooth motion",
+        "default_interval_hours": 50.0,
+        "icon": "Droplet",
+    },
+    {
+        "name": "Clean Steel Rods",
+        "description": "Wipe steel rods with a dry cloth",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
     # Linear rail models only (A1/H2)
     {
         "name": "Lubricate Linear Rails",
@@ -88,11 +101,13 @@ DEFAULT_MAINTENANCE_TYPES = [
 ]
 
 # System types that only apply to printers with a specific rod/rail type.
-# "carbon" = X1/P1/P2S series (carbon rods), "linear_rail" = A1/H2 series.
-# Types not listed here apply to all printers.
+# "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
+# "linear_rail" = A1/H2 series. Types not listed here apply to all printers.
 _ROD_TYPE_REQUIREMENTS: dict[str, str] = {
     "Lubricate Carbon Rods": "carbon",
     "Clean Carbon Rods": "carbon",
+    "Lubricate Steel Rods": "steel_rod",
+    "Clean Steel Rods": "steel_rod",
     "Lubricate Linear Rails": "linear_rail",
     "Clean Linear Rails": "linear_rail",
 }

+ 17 - 0
backend/app/api/routes/metrics.py

@@ -1,9 +1,12 @@
 """Prometheus metrics endpoint for external monitoring."""
 
+import platform
+
 from fastapi import APIRouter, Depends, Header, HTTPException, Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.config import APP_VERSION
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
@@ -77,6 +80,20 @@ async def get_metrics(
 
     lines: list[str] = []
 
+    # =========================================================================
+    # Build info
+    # =========================================================================
+
+    lines.append("# HELP bambuddy_build_info Build and version information")
+    lines.append("# TYPE bambuddy_build_info gauge")
+    build_labels = format_labels(
+        version=APP_VERSION,
+        python_version=platform.python_version(),
+        platform=platform.system(),
+        architecture=platform.machine(),
+    )
+    lines.append(f"bambuddy_build_info{build_labels} 1")
+
     # =========================================================================
     # Printer metrics
     # =========================================================================

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

@@ -58,6 +58,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_plate_not_empty": provider.on_plate_not_empty,
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
+        # First layer complete
+        "on_first_layer_complete": provider.on_first_layer_complete,
         # Print queue events
         "on_queue_job_added": provider.on_queue_job_added,
         "on_queue_job_assigned": provider.on_queue_job_assigned,
@@ -133,6 +135,18 @@ async def create_notification_provider(
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         # Build plate detection
         on_plate_not_empty=provider_data.on_plate_not_empty,
+        # Bed cooled
+        on_bed_cooled=provider_data.on_bed_cooled,
+        # First layer complete
+        on_first_layer_complete=provider_data.on_first_layer_complete,
+        # Print queue events
+        on_queue_job_added=provider_data.on_queue_job_added,
+        on_queue_job_assigned=provider_data.on_queue_job_assigned,
+        on_queue_job_started=provider_data.on_queue_job_started,
+        on_queue_job_waiting=provider_data.on_queue_job_waiting,
+        on_queue_job_skipped=provider_data.on_queue_job_skipped,
+        on_queue_job_failed=provider_data.on_queue_job_failed,
+        on_queue_completed=provider_data.on_queue_completed,
         # Quiet hours
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,

+ 196 - 5
backend/app/api/routes/printers.py

@@ -12,9 +12,11 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.schemas.printer import (
+    AmsLabelBody,
     AMSTray,
     AMSUnit,
     HMSErrorResponse,
@@ -33,7 +35,12 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     list_files_async,
 )
-from backend.app.services.printer_manager import get_derived_status_name, printer_manager, supports_chamber_temp
+from backend.app.services.printer_manager import (
+    get_derived_status_name,
+    printer_manager,
+    supports_chamber_temp,
+    supports_drying,
+)
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -120,8 +127,8 @@ async def get_available_filaments(
         return []
 
     # Collect filaments from all matching printers
-    # Dedup key includes extruder_id so same color on different nozzles appears separately
-    seen: set[tuple[str, str, int | None]] = set()  # (type_upper, color_normalized, extruder_id)
+    # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately
+    seen: set[tuple[str, str, str, int | None]] = set()  # (type_upper, color_normalized, sub_brands_upper, extruder_id)
     filaments = []
 
     for printer in printers_list:
@@ -145,8 +152,9 @@ async def get_available_filaments(
                 hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
                 color = f"#{hex_color}"
                 tray_info_idx = tray.get("tray_info_idx", "")
+                tray_sub_brands = tray.get("tray_sub_brands", "") or ""
 
-                key = (tray_type.upper(), hex_color.lower(), extruder_id)
+                key = (tray_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
                 if key not in seen:
                     seen.add(key)
                     filaments.append(
@@ -154,6 +162,7 @@ async def get_available_filaments(
                             "type": tray_type,
                             "color": color,
                             "tray_info_idx": tray_info_idx,
+                            "tray_sub_brands": tray_sub_brands,
                             "extruder_id": extruder_id,
                         }
                     )
@@ -167,10 +176,11 @@ async def get_available_filaments(
             hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
             color = f"#{hex_color}"
             tray_info_idx = vt.get("tray_info_idx", "")
+            tray_sub_brands = vt.get("tray_sub_brands", "") or ""
             vt_id = int(vt.get("id", 254))
             extruder_id = (255 - vt_id) if ams_extruder_map else None
 
-            key = (vt_type.upper(), hex_color.lower(), extruder_id)
+            key = (vt_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
             if key not in seen:
                 seen.add(key)
                 filaments.append(
@@ -178,6 +188,7 @@ async def get_available_filaments(
                         "type": vt_type,
                         "color": color,
                         "tray_info_idx": tray_info_idx,
+                        "tray_sub_brands": tray_sub_brands,
                         "extruder_id": extruder_id,
                     }
                 )
@@ -405,6 +416,8 @@ async def get_printer_status(
                         tray_uuid=tray_uuid,
                         nozzle_temp_min=tray_data.get("nozzle_temp_min"),
                         nozzle_temp_max=tray_data.get("nozzle_temp_max"),
+                        drying_temp=tray_data.get("drying_temp"),
+                        drying_time=tray_data.get("drying_time"),
                     )
                 )
             # Prefer humidity_raw (percentage) over humidity (index 1-5)
@@ -433,6 +446,13 @@ async def get_printer_status(
                     temp=ams_data.get("temp"),
                     is_ams_ht=is_ams_ht,
                     tray=trays,
+                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
+                    serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
+                    # Firmware version: populated by _handle_version_info from info.module ams/* entries
+                    sw_ver=str(ams_data.get("sw_ver") or ""),
+                    # Drying: dry_time > 0 means drying is active (minutes remaining)
+                    dry_time=int(ams_data.get("dry_time") or 0),
+                    module_type=str(ams_data.get("module_type") or ""),
                 )
             )
 
@@ -561,6 +581,7 @@ async def get_printer_status(
         timelapse=state.timelapse,
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
+        wired_network=state.wired_network,
         nozzles=nozzles,
         nozzle_rack=nozzle_rack,
         print_options=print_options,
@@ -586,6 +607,7 @@ async def get_printer_status(
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
         plate_cleared=printer_manager.is_plate_cleared(printer_id),
+        supports_drying=supports_drying(printer.model, state.firmware_version),
     )
 
 
@@ -1436,6 +1458,63 @@ async def clear_mqtt_logs(
     return {"status": "cleared"}
 
 
+# ============================================
+# AMS Drying Endpoints
+# ============================================
+
+
+@router.post("/{printer_id}/drying/start")
+async def start_drying(
+    printer_id: int,
+    ams_id: int,
+    temp: int = 45,
+    duration: int = 4,
+    filament: str = "",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Send AMS drying start command. temp=45-85, duration=hours."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Server-side guard: reject if this model/firmware doesn't support drying
+    live_state = printer_manager.get_status(printer_id)
+    firmware = live_state.firmware_version if live_state else None
+    if not supports_drying(printer.model, firmware):
+        raise HTTPException(400, "Drying not supported for this printer model or firmware version")
+
+    if temp < 45 or temp > 85:
+        raise HTTPException(400, "Temperature must be 45-85°C")
+    if duration < 1 or duration > 24:
+        raise HTTPException(400, "Duration must be 1-24 hours")
+
+    success = printer_manager.send_drying_command(printer_id, ams_id, temp, duration, mode=1, filament=filament)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+    return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
+
+
+@router.post("/{printer_id}/drying/stop")
+async def stop_drying(
+    printer_id: int,
+    ams_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Send AMS drying stop command."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+    return {"status": "drying_stopped", "ams_id": ams_id}
+
+
 # ============================================
 # Print Options (AI Detection) Endpoints
 # ============================================
@@ -1955,6 +2034,118 @@ async def reset_ams_slot(
     }
 
 
+@router.get("/{printer_id}/ams-labels")
+async def get_ams_labels(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
+
+    Labels are stored by AMS serial number.  This endpoint resolves the current
+    serial-to-ams_id mapping from the live printer state so the response is still
+    keyed by ams_id for UI compatibility.
+    """
+    # Build serial -> ams_id map from live printer state
+    serial_to_ams_id: dict[str, int] = {}
+    state = printer_manager.get_status(printer_id)
+    if state and state.raw_data:
+        for ams_unit in state.raw_data.get("ams", []):
+            sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
+            if sn:
+                serial_to_ams_id[sn] = int(ams_unit.get("id", 0))
+
+    # Collect all known serials for this printer (live + synthetic fallback keys)
+    serials_to_query = set(serial_to_ams_id.keys())
+
+    # Fetch labels for all known serials
+    labels: dict[int, str] = {}
+    if serials_to_query:
+        result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))
+        for lbl in result.scalars().all():
+            aid = serial_to_ams_id.get(lbl.ams_serial_number)
+            if aid is not None:
+                labels[aid] = lbl.label
+
+    # Also fetch labels stored under synthetic keys for this printer (backward compat)
+    # Collect all synthetic keys first, then query with a single IN clause.
+    if state and state.raw_data:
+        synthetic_key_to_aid: dict[str, int] = {
+            f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0))
+            for ams_unit in state.raw_data.get("ams", [])
+            if int(ams_unit.get("id", 0)) not in labels
+        }
+        if synthetic_key_to_aid:
+            result = await db.execute(
+                select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))
+            )
+            for lbl in result.scalars().all():
+                aid = synthetic_key_to_aid.get(lbl.ams_serial_number)
+                if aid is not None:
+                    labels[aid] = lbl.label
+
+    return labels
+
+
+@router.put("/{printer_id}/ams-labels/{ams_id}")
+async def save_ams_label(
+    printer_id: int,
+    ams_id: int,
+    body: AmsLabelBody,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create or update the friendly name for a specific AMS unit.
+
+    When ``ams_serial`` is provided the label is stored under that serial number so
+    it survives the AMS being moved to a different printer.  When it is absent (e.g.
+    older firmware that does not report a serial) a synthetic key based on the
+    printer_id and ams_id is used as a fallback.
+    """
+    # Verify printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Printer not found")
+
+    # Determine the serial key to store under
+    stripped = body.ams_serial.strip() if body.ams_serial else ""
+    serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
+
+    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
+    existing = result.scalar_one_or_none()
+
+    if existing:
+        existing.label = body.label
+        existing.ams_id = ams_id
+    else:
+        db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
+
+    await db.commit()
+    return {"ams_id": ams_id, "label": body.label}
+
+
+@router.delete("/{printer_id}/ams-labels/{ams_id}")
+async def delete_ams_label(
+    printer_id: int,
+    ams_id: int,
+    ams_serial: str = Query(default="", max_length=50),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
+    stripped = ams_serial.strip() if ams_serial else ""
+    serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
+
+    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
+    existing = result.scalar_one_or_none()
+
+    if existing:
+        await db.delete(existing)
+        await db.commit()
+
+    return {"success": True}
+
+
 @router.post("/{printer_id}/debug/simulate-print-complete")
 async def debug_simulate_print_complete(
     printer_id: int,

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

@@ -97,11 +97,11 @@ async def compute_project_stats(
     )
     in_progress_prints = in_progress_result.scalar() or 0
 
-    # Sum completed items (parts) - sum of quantities for successful prints
+    # Sum completed items (parts) - sum of quantities for actually printed jobs
     completed_items_result = await db.execute(
         select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             PrintArchive.project_id == project_id,
-            PrintArchive.status.in_(["completed", "archived"]),
+            PrintArchive.status == "completed",
         )
     )
     completed_items = int(completed_items_result.scalar() or 0)
@@ -192,11 +192,11 @@ async def list_projects(
         )
         queue_count = queue_count_result.scalar() or 0
 
-        # Sum completed parts (quantities) - includes "archived" as successful
+        # Sum completed parts (quantities) - only actually printed jobs
         completed_result = await db.execute(
             select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
-                PrintArchive.status.in_(["completed", "archived"]),
+                PrintArchive.status == "completed",
             )
         )
         completed_count = int(completed_result.scalar() or 0)

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

@@ -97,6 +97,9 @@ async def get_settings(
                 "ha_enabled",
                 "per_printer_mapping_expanded",
                 "prometheus_enabled",
+                "queue_drying_enabled",
+                "queue_drying_block",
+                "ambient_drying_enabled",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
@@ -105,6 +108,7 @@ async def get_settings(
                 "ams_temp_good",
                 "ams_temp_fair",
                 "library_disk_warning_gb",
+                "low_stock_threshold",
             ]:
                 settings_dict[setting.key] = float(setting.value)
             elif setting.key in [
@@ -410,7 +414,7 @@ async def restore_backup(
 
     from fastapi import HTTPException
 
-    from backend.app.core.database import close_all_connections
+    from backend.app.core.database import close_all_connections, init_db, reinitialize_database
     from backend.app.services.virtual_printer import virtual_printer_manager
 
     base_dir = app_settings.base_dir
@@ -497,8 +501,11 @@ async def restore_backup(
                         logger.warning("Could not restore %s directory: %s", name, e)
                         skipped_dirs.append(name)
 
-            # 7. Note: Virtual printer and database will be reinitialized on restart
-            # Do NOT try to restart services here - the database session is closed
+            # 7. Reinitialize the database engine and apply schema migrations so that
+            # tables added after the backup was created (e.g. ams_labels) exist
+            # immediately, without requiring a manual restart.
+            await reinitialize_database()
+            await init_db()
 
             logger.info("Restore complete - restart required")
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."

+ 282 - 3
backend/app/api/routes/spoolbuddy.py

@@ -17,13 +17,17 @@ from backend.app.schemas.spoolbuddy import (
     CalibrationResponse,
     DeviceRegisterRequest,
     DeviceResponse,
+    DisplaySettingsRequest,
     HeartbeatRequest,
     HeartbeatResponse,
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
+    SetTareRequest,
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
+    WriteTagRequest,
+    WriteTagResultRequest,
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
 
@@ -53,6 +57,12 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         has_scale=device.has_scale,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
+        nfc_reader_type=device.nfc_reader_type,
+        nfc_connection=device.nfc_connection,
+        display_brightness=device.display_brightness,
+        display_blank_timeout=device.display_blank_timeout,
+        has_backlight=device.has_backlight,
+        last_calibrated_at=device.last_calibrated_at,
         last_seen=device.last_seen,
         pending_command=device.pending_command,
         nfc_ok=device.nfc_ok,
@@ -84,6 +94,9 @@ async def register_device(
         device.firmware_version = req.firmware_version
         device.has_nfc = req.has_nfc
         device.has_scale = req.has_scale
+        device.nfc_reader_type = req.nfc_reader_type
+        device.nfc_connection = req.nfc_connection
+        device.has_backlight = req.has_backlight
         device.last_seen = now
         logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
     else:
@@ -96,6 +109,9 @@ async def register_device(
             has_scale=req.has_scale,
             tare_offset=req.tare_offset,
             calibration_factor=req.calibration_factor,
+            nfc_reader_type=req.nfc_reader_type,
+            nfc_connection=req.nfc_connection,
+            has_backlight=req.has_backlight,
             last_seen=now,
         )
         db.add(device)
@@ -150,10 +166,25 @@ async def device_heartbeat(
         device.firmware_version = req.firmware_version
     if req.ip_address:
         device.ip_address = req.ip_address
+    if req.nfc_reader_type:
+        device.nfc_reader_type = req.nfc_reader_type
+    if req.nfc_connection:
+        device.nfc_connection = req.nfc_connection
 
     # Return and clear pending command
     pending = device.pending_command
-    device.pending_command = None
+    pending_write = None
+    if pending == "write_tag" and device.pending_write_payload:
+        # Parse the stored JSON payload to include in response
+        import json
+
+        try:
+            pending_write = json.loads(device.pending_write_payload)
+        except (json.JSONDecodeError, TypeError):
+            pending_write = None
+        # Don't clear write_tag command — it gets cleared by write-result
+    else:
+        device.pending_command = None
 
     await db.commit()
 
@@ -168,8 +199,11 @@ async def device_heartbeat(
 
     return HeartbeatResponse(
         pending_command=pending,
+        pending_write_payload=pending_write,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
+        display_brightness=device.display_brightness,
+        display_blank_timeout=device.display_blank_timeout,
     )
 
 
@@ -236,6 +270,121 @@ async def nfc_tag_removed(
     return {"status": "ok"}
 
 
+@router.post("/nfc/write-tag")
+async def nfc_write_tag(
+    req: WriteTagRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Queue an NFC tag write command for a SpoolBuddy device."""
+    import json
+
+    from backend.app.models.spool import Spool
+    from backend.app.services.opentag3d import encode_opentag3d
+
+    # Find the spool
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(status_code=404, detail="Spool not found")
+
+    # Find the device
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    # Encode OpenTag3D NDEF data
+    ndef_data = encode_opentag3d(spool)
+
+    # Store write payload and set pending command
+    device.pending_write_payload = json.dumps(
+        {
+            "spool_id": spool.id,
+            "ndef_data_hex": ndef_data.hex(),
+        }
+    )
+    device.pending_command = "write_tag"
+    await db.commit()
+
+    logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
+    return {"status": "queued"}
+
+
+@router.post("/nfc/write-result")
+async def nfc_write_result(
+    req: WriteTagResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Handle NFC tag write result from SpoolBuddy daemon."""
+    # Find the device and clear pending state
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.pending_command = None
+    device.pending_write_payload = None
+
+    if req.success:
+        # Link the tag to the spool
+        from backend.app.models.spool import Spool
+
+        result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+        spool = result.scalar_one_or_none()
+        if spool:
+            spool.tag_uid = req.tag_uid.upper()
+            spool.tag_type = "ntag"
+            spool.data_origin = "opentag3d"
+            spool.encode_time = datetime.now(timezone.utc)
+            logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
+
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_written",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "tag_uid": req.tag_uid,
+            }
+        )
+    else:
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_write_failed",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "message": req.message,
+            }
+        )
+        logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
+
+    return {"status": "ok"}
+
+
+@router.post("/devices/{device_id}/cancel-write")
+async def cancel_write(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Cancel a pending write-tag command."""
+    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 device.pending_command == "write_tag":
+        device.pending_command = None
+        device.pending_write_payload = None
+        await db.commit()
+        logger.info("Write tag cancelled for device %s", device_id)
+
+    return {"status": "ok"}
+
+
 # --- Scale endpoints ---
 
 
@@ -274,6 +423,8 @@ async def update_spool_weight(
     # net weight = total on scale minus empty spool core
     net_filament = max(0, req.weight_grams - spool.core_weight)
     spool.weight_used = max(0, spool.label_weight - net_filament)
+    spool.last_scale_weight = req.weight_grams
+    spool.last_weighed_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info(
@@ -305,6 +456,30 @@ async def tare_scale(
     return {"status": "ok", "message": "Tare command queued"}
 
 
+@router.post("/devices/{device_id}/calibration/set-tare")
+async def set_tare_offset(
+    device_id: str,
+    req: SetTareRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Store tare offset reported by the daemon after executing a tare."""
+    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.tare_offset = req.tare_offset
+    device.last_calibrated_at = datetime.now(timezone.utc)
+    await db.commit()
+
+    logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
+    return CalibrationResponse(
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
 @router.post("/devices/{device_id}/calibration/set-factor")
 async def set_calibration_factor(
     device_id: str,
@@ -318,11 +493,15 @@ async def set_calibration_factor(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    raw_delta = req.raw_adc - device.tare_offset
+    tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
+    raw_delta = req.raw_adc - tare
     if raw_delta == 0:
         raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
 
     device.calibration_factor = req.known_weight_grams / raw_delta
+    if req.tare_raw_adc is not None:
+        device.tare_offset = tare
+    device.last_calibrated_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info(
@@ -331,7 +510,7 @@ async def set_calibration_factor(
         device.calibration_factor,
         req.known_weight_grams,
         req.raw_adc,
-        device.tare_offset,
+        tare,
     )
     return CalibrationResponse(
         tare_offset=device.tare_offset,
@@ -357,6 +536,106 @@ async def get_calibration(
     )
 
 
+# --- Display settings ---
+
+
+@router.put("/devices/{device_id}/display")
+async def update_display_settings(
+    device_id: str,
+    req: DisplaySettingsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update display brightness and screen blank timeout for a device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.display_brightness = req.brightness
+    device.display_blank_timeout = req.blank_timeout
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
+        device_id,
+        req.brightness,
+        req.blank_timeout,
+    )
+    return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
+
+
+# --- Update check ---
+
+
+@router.get("/devices/{device_id}/update-check")
+async def check_daemon_update(
+    device_id: str,
+    include_beta: bool = False,
+    db: AsyncSession = Depends(get_db),
+    _: 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
+
+    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")
+
+    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),
+        }
+
+
 # --- Background watchdog ---
 
 

+ 70 - 13
backend/app/api/routes/spoolman.py

@@ -1,5 +1,6 @@
 """Spoolman integration API routes."""
 
+import json
 import logging
 
 from fastapi import APIRouter, Depends, HTTPException
@@ -663,9 +664,14 @@ async def get_linked_spools(
 
 
 class LinkSpoolRequest(BaseModel):
-    """Request to link a Spoolman spool to an AMS tray."""
+    """Request to link a Spoolman spool to an AMS tag (tray_uuid or tag_uid)."""
 
-    tray_uuid: str
+    spool_tag: str | None = None
+    tray_uuid: str | None = None
+    tag_uid: str | None = None
+    printer_id: int | None = None
+    ams_id: int | None = None
+    tray_id: int | None = None
 
 
 @router.post("/spools/{spool_id}/link")
@@ -675,7 +681,7 @@ async def link_spool(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
-    """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
+    """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     sm = await get_spoolman_settings(db)
     enabled, url = sm["enabled"], sm["url"]
     if not enabled:
@@ -691,26 +697,77 @@ async def link_spool(
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
-    # Validate tray_uuid format (32 hex characters)
-    tray_uuid = request.tray_uuid.strip()
-    if len(tray_uuid) != 32:
-        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
+    # Resolve and validate spool tag (supports tray_uuid=32 hex and tag_uid=16 hex)
+    spool_tag = (request.spool_tag or request.tray_uuid or request.tag_uid or "").strip()
+    if not spool_tag:
+        raise HTTPException(status_code=400, detail="Missing spool tag (tray_uuid or tag_uid)")
+    if len(spool_tag) not in (16, 32):
+        raise HTTPException(status_code=400, detail="Invalid spool tag format (must be 16 or 32 hex characters)")
     try:
-        int(tray_uuid, 16)
+        int(spool_tag, 16)
     except ValueError:
-        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
+        raise HTTPException(status_code=400, detail="Invalid spool tag format (must be hex)")
+
+    if set(spool_tag) == {"0"}:
+        raise HTTPException(status_code=400, detail="Invalid spool tag format (all-zero tag is not linkable)")
+
+    spool_tag = spool_tag.upper()
+
+    # Build location like: "{Printer Name} - {AMS Name} {Slot Number}"
+    location: str | None = None
+    if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
+        printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
+        printer = printer_result.scalar_one_or_none()
+        if not printer:
+            raise HTTPException(status_code=404, detail="Printer not found")
+
+        location = f"{printer.name} - {client.convert_ams_slot_to_location(request.ams_id, request.tray_id)}"
 
     # Update spool with tag
     # Note: Spoolman extra field values must be valid JSON, so we encode the string
-    import json
+    result = await client.update_spool(
+        spool_id=spool_id,
+        location=location,
+        extra={"tag": json.dumps(spool_tag)},
+    )
+
+    if result:
+        logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
+        return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
+    else:
+        raise HTTPException(status_code=500, detail="Failed to update spool")
+
+
+@router.post("/spools/{spool_id}/unlink")
+async def unlink_spool(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+):
+    """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
+    sm = await get_spoolman_settings(db)
+    enabled, url = sm["enabled"], sm["url"]
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
     result = await client.update_spool(
         spool_id=spool_id,
-        extra={"tag": json.dumps(tray_uuid)},
+        clear_location=True,
+        extra={"tag": json.dumps("")},
     )
 
     if result:
-        logger.info("Linked Spoolman spool %s to tray_uuid %s", spool_id, tray_uuid)
-        return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
+        logger.info("Unlinked Spoolman spool %s", spool_id)
+        return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
     else:
         raise HTTPException(status_code=500, detail="Failed to update spool")

+ 116 - 13
backend/app/api/routes/support.py

@@ -10,7 +10,7 @@ import os
 import platform
 import re
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 from fastapi import APIRouter, HTTPException, Query
@@ -63,6 +63,8 @@ async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
     if enabled_at_setting and enabled_at_setting.value:
         try:
             enabled_at = datetime.fromisoformat(enabled_at_setting.value)
+            if enabled_at.tzinfo is None:
+                enabled_at = enabled_at.replace(tzinfo=timezone.utc)
         except ValueError:
             pass  # Ignore malformed timestamp; enabled_at stays None
 
@@ -80,7 +82,7 @@ async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None
         db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
 
     # Update enabled_at timestamp
-    enabled_at = datetime.now() if enabled else None
+    enabled_at = datetime.now(tz=timezone.utc) if enabled else None
     result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
     at_setting = result.scalar_one_or_none()
     if at_setting:
@@ -127,7 +129,7 @@ async def get_debug_logging_state(
 
     duration = None
     if enabled and enabled_at:
-        duration = int((datetime.now() - enabled_at).total_seconds())
+        duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
 
     return DebugLoggingState(
         enabled=enabled,
@@ -149,7 +151,7 @@ async def toggle_debug_logging(
 
     duration = None
     if toggle.enabled and enabled_at:
-        duration = int((datetime.now() - enabled_at).total_seconds())
+        duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
 
     return DebugLoggingState(
         enabled=toggle.enabled,
@@ -325,6 +327,37 @@ def _sanitize_path(path: str) -> str:
     return path
 
 
+def _detect_docker_network_mode() -> str:
+    """Detect Docker network mode by checking for host-level interfaces.
+
+    In host mode the container shares the host network namespace, so Docker
+    infrastructure interfaces (docker0, br-*, veth*) are visible.  In bridge
+    mode the container is isolated and only sees its own veth (named eth0).
+    """
+    try:
+        import socket
+
+        for _idx, name in socket.if_nameindex():
+            if name.startswith(("docker", "br-", "veth", "virbr")):
+                return "host"
+    except Exception:
+        pass
+    return "bridge"
+
+
+def _mask_subnet(subnet: str) -> str:
+    """Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'."""
+    try:
+        parts = subnet.split(".")
+        if len(parts) >= 4:
+            parts[0] = "x"
+            parts[1] = "x"
+            return ".".join(parts)
+    except Exception:
+        pass
+    return subnet
+
+
 def _anonymize_mqtt_broker(broker: str) -> str:
     """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
     if not broker:
@@ -418,11 +451,10 @@ async def _collect_support_info() -> dict:
     if in_docker:
         try:
             mem_limit = _get_container_memory_limit()
-            interfaces = get_network_interfaces()
             info["docker"] = {
                 "container_memory_limit_bytes": mem_limit,
                 "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
-                "network_mode_hint": "host" if len(interfaces) > 2 else "bridge",
+                "network_mode_hint": _detect_docker_network_mode(),
             }
         except Exception:
             logger.debug("Failed to collect Docker info", exc_info=True)
@@ -500,6 +532,34 @@ async def _collect_support_info() -> dict:
                 }
             )
 
+        # Virtual printers
+        try:
+            from backend.app.models.virtual_printer import VirtualPrinter
+            from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+
+            result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))
+            vps = result.scalars().all()
+            info["virtual_printers"] = []
+            for vp in vps:
+                instance = virtual_printer_manager.get_instance(vp.id)
+                status = instance.get_status() if instance else None
+                model_code = vp.model or "C12"
+                info["virtual_printers"].append(
+                    {
+                        "index": vp.id,
+                        "enabled": vp.enabled,
+                        "mode": vp.mode,
+                        "model": model_code,
+                        "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
+                        "has_target_printer": vp.target_printer_id is not None,
+                        "has_bind_ip": bool(vp.bind_ip),
+                        "running": status.get("running", False) if status else False,
+                        "pending_files": status.get("pending_files", 0) if status else 0,
+                    }
+                )
+        except Exception:
+            logger.debug("Failed to collect virtual printer info", exc_info=True)
+
         # Non-sensitive settings
         result = await db.execute(select(Settings))
         all_settings = result.scalars().all()
@@ -642,12 +702,12 @@ async def _collect_support_info() -> dict:
     except Exception:
         logger.debug("Failed to collect log file info", exc_info=True)
 
-    # Network interfaces (subnets only — already anonymized)
+    # Network interfaces (subnets with first two octets masked)
     try:
         interfaces = get_network_interfaces()
         info["network"] = {
             "interface_count": len(interfaces),
-            "interfaces": [{"name": iface["name"], "subnet": iface["subnet"]} for iface in interfaces],
+            "interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces],
         }
     except Exception:
         logger.debug("Failed to collect network info", exc_info=True)
@@ -675,8 +735,8 @@ def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
                 continue  # Skip very short strings to prevent over-redaction
             content = re.sub(re.escape(value), label, content)
 
-    # Replace credentials in URLs (e.g. http://user:pass@host)
-    content = re.sub(r"(https?://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
+    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
+    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
 
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
@@ -721,6 +781,47 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
     return content.encode("utf-8")
 
 
+async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
+    """Get recent log lines, sanitized for inclusion in bug reports."""
+    # Collect sensitive strings from DB for redaction
+    sensitive_strings: dict[str, str] = {}
+    async with async_session() as db:
+        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
+        for name, serial, ip_address, access_code in result.all():
+            if name:
+                sensitive_strings[name] = "[PRINTER]"
+            if serial:
+                sensitive_strings[serial] = "[SERIAL]"
+            if ip_address:
+                sensitive_strings[ip_address] = "[IP]"
+            if access_code:
+                sensitive_strings[access_code] = "[ACCESS_CODE]"
+
+        result = await db.execute(select(User.username))
+        for (username,) in result.all():
+            if username:
+                sensitive_strings[username] = "[USER]"
+
+        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
+        cloud_email = result.scalar_one_or_none()
+        if cloud_email:
+            sensitive_strings[cloud_email] = "[EMAIL]"
+
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return ""
+
+    # Read last portion of log file
+    try:
+        content = log_file.read_text(encoding="utf-8", errors="replace")
+        lines = content.splitlines()
+        recent = "\n".join(lines[-max_lines:])
+        return _sanitize_log_content(recent, sensitive_strings)
+    except Exception:
+        logger.debug("Failed to read logs for bug report", exc_info=True)
+        return ""
+
+
 @router.get("/bundle")
 async def generate_support_bundle(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -740,15 +841,17 @@ async def generate_support_bundle(
         # Collect known sensitive values for log redaction
         sensitive_strings: dict[str, str] = {}
 
-        # Printer names, serial numbers, and IP addresses
-        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address))
-        for name, serial, ip_address in result.all():
+        # Printer names, serial numbers, IP addresses, and access codes
+        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
+        for name, serial, ip_address, access_code in result.all():
             if name:
                 sensitive_strings[name] = "[PRINTER]"
             if serial:
                 sensitive_strings[serial] = "[SERIAL]"
             if ip_address:
                 sensitive_strings[ip_address] = "[IP]"
+            if access_code:
+                sensitive_strings[access_code] = "[ACCESS_CODE]"
 
         # Auth usernames
         result = await db.execute(select(User.username))

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

@@ -86,6 +86,9 @@ def parse_version(version: str) -> tuple:
     # Remove 'v' prefix if present
     version = version.lstrip("v")
 
+    # Strip daily build suffix (e.g., "0.2.2b4-daily.20260313" -> "0.2.2b4")
+    version = re.sub(r"-daily\.\d+$", "", version)
+
     # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N
     match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:b|beta|alpha|rc)?(\d+)?", version)
 

+ 26 - 3
backend/app/api/routes/virtual_printers.py

@@ -23,6 +23,7 @@ class VirtualPrinterCreate(BaseModel):
     model: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
+    auto_dispatch: bool = True
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
 
@@ -34,6 +35,7 @@ class VirtualPrinterUpdate(BaseModel):
     model: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
+    auto_dispatch: bool | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
 
@@ -56,6 +58,7 @@ def _vp_to_dict(vp, status: dict | None = None) -> dict:
         "access_code_set": bool(vp.access_code),
         "serial": serial,
         "target_printer_id": vp.target_printer_id,
+        "auto_dispatch": vp.auto_dispatch,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "position": vp.position,
@@ -125,11 +128,13 @@ async def create_virtual_printer(
                 return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
 
     # Validate proxy target printer exists
+    target_printer = None
     if body.target_printer_id:
         from backend.app.models.printer import Printer
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
-        if not result.scalar_one_or_none():
+        target_printer = result.scalar_one_or_none()
+        if not target_printer:
             return JSONResponse(
                 status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
             )
@@ -166,9 +171,12 @@ async def create_virtual_printer(
         name=body.name,
         enabled=body.enabled,
         mode=body.mode,
-        model=body.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+        model=body.model
+        or (target_printer.model if target_printer and target_printer.model and body.mode == "proxy" else None)
+        or DEFAULT_VIRTUAL_PRINTER_MODEL,
         access_code=body.access_code,
         target_printer_id=body.target_printer_id,
+        auto_dispatch=body.auto_dispatch,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
         serial_suffix=new_suffix,
@@ -260,16 +268,31 @@ async def update_virtual_printer(
         from backend.app.models.printer import Printer
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
-        if not result.scalar_one_or_none():
+        target_printer = result.scalar_one_or_none()
+        if not target_printer:
             return JSONResponse(
                 status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
             )
         vp.target_printer_id = body.target_printer_id
+        # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
+        if body.model is None and vp.mode == "proxy" and target_printer.model:
+            vp.model = target_printer.model
+    if body.auto_dispatch is not None:
+        vp.auto_dispatch = body.auto_dispatch
     if body.bind_ip is not None:
         vp.bind_ip = body.bind_ip
     if body.remote_interface_ip is not None:
         vp.remote_interface_ip = body.remote_interface_ip
 
+    # Auto-inherit model when switching to proxy mode with existing target printer
+    if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
+        from backend.app.models.printer import Printer as PrinterModel
+
+        result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
+        existing_target = result.scalar_one_or_none()
+        if existing_target and existing_target.model:
+            vp.model = existing_target.model
+
     # Determine final enabled state
     explicitly_enabling = body.enabled is True
     new_enabled = body.enabled if body.enabled is not None else vp.enabled

+ 11 - 0
backend/app/core/compat.py

@@ -0,0 +1,11 @@
+"""Compatibility shims for older Python versions."""
+
+import sys
+
+if sys.version_info >= (3, 11):
+    from enum import StrEnum
+else:
+    from enum import Enum
+
+    class StrEnum(str, Enum):
+        """Drop-in replacement for enum.StrEnum on Python < 3.11."""

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

@@ -5,8 +5,9 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.1.1"
+APP_VERSION = "0.2.2"
 GITHUB_REPO = "maziggy/bambuddy"
+BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 # App directory - where the application is installed (for static files)
 _app_dir = Path(__file__).resolve().parent.parent.parent.parent

+ 139 - 2
backend/app/core/database.py

@@ -20,6 +20,8 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
 engine = create_async_engine(
     settings.database_url,
     echo=settings.debug,
+    pool_size=10,
+    max_overflow=20,
 )
 
 # Register the pragma listener on the underlying sync engine
@@ -44,6 +46,8 @@ async def reinitialize_database():
     engine = create_async_engine(
         settings.database_url,
         echo=settings.debug,
+        pool_size=10,
+        max_overflow=20,
     )
     event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
     async_session = async_sessionmaker(
@@ -74,8 +78,10 @@ async def init_db():
     from backend.app.models import (  # noqa: F401
         active_print_spoolman,
         ams_history,
+        ams_label,
         api_key,
         archive,
+        bug_report,
         color_catalog,
         external_link,
         filament,
@@ -838,10 +844,31 @@ async def run_migrations(conn):
     # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
     # SQLite requires table recreation to drop constraints
     try:
-        # Check if we need to migrate (if UNIQUE constraint exists)
+        # Check if we need to migrate — look for UNIQUE on printer_id in the
+        # CREATE TABLE statement OR as a separate UNIQUE index.
+        needs_migration = False
         result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
         row = result.fetchone()
-        if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
+        table_sql = (row[0] or "").upper() if row else ""
+        if "PRINTER_ID" in table_sql and "UNIQUE" in table_sql:
+            # Check if UNIQUE appears near printer_id — inline or table-level constraint.
+            # Handle quoted ("PRINTER_ID") and unquoted column names.
+            import re
+
+            if re.search(r'"?PRINTER_ID"?\s+\w+\s+UNIQUE', table_sql) or re.search(
+                r'UNIQUE\s*\([^)]*"?PRINTER_ID"?', table_sql
+            ):
+                needs_migration = True
+        # Also check for separate UNIQUE indexes on printer_id
+        idx_result = await conn.execute(
+            text("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='smart_plugs' AND sql IS NOT NULL")
+        )
+        for idx_row in idx_result.fetchall():
+            idx_sql = (idx_row[0] or "").upper()
+            if "UNIQUE" in idx_sql and "PRINTER_ID" in idx_sql:
+                needs_migration = True
+                break
+        if needs_migration:
             # Create new table without UNIQUE constraint on printer_id
             await conn.execute(
                 text("""
@@ -1224,12 +1251,30 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add first layer complete notification column to notification_providers
+    try:
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_first_layer_complete BOOLEAN DEFAULT 0")
+        )
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
     try:
         await conn.execute(text("ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0"))
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add SpoolBuddy scale weight tracking columns to spool table
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add cost tracking fields to spool table
     try:
         await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL"))
@@ -1318,6 +1363,98 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add NFC reader and display control columns to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add NFC tag write payload column to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
+    # 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.
+    try:
+        await conn.execute(text("DROP TABLE IF EXISTS ams_labels_new"))
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'"))
+        row = result.fetchone()
+        if row and "printer_id" in (row[0] or ""):
+            # Old schema: rebuild the table with ams_serial_number as the unique key.
+            # Existing rows get a synthetic serial "p{printer_id}a{ams_id}" so data is preserved.
+            await conn.execute(
+                text("""
+                CREATE TABLE ams_labels_new (
+                    id INTEGER PRIMARY KEY,
+                    ams_serial_number VARCHAR(50) NOT NULL,
+                    ams_id INTEGER,
+                    label VARCHAR(100) NOT NULL,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                    CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number)
+                )
+            """)
+            )
+            await conn.execute(
+                text("""
+                INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at)
+                SELECT id,
+                       'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT),
+                       ams_id,
+                       label,
+                       created_at,
+                       updated_at
+                FROM ams_labels
+            """)
+            )
+            await conn.execute(text("DROP TABLE ams_labels"))
+            await conn.execute(text("ALTER TABLE ams_labels_new RENAME TO ams_labels"))
+    except OperationalError:
+        pass  # Already migrated or table does not exist yet
+
+    # Migration: Add auto_dispatch column to virtual_printers
+    try:
+        await conn.execute(text("ALTER TABLE virtual_printers ADD COLUMN auto_dispatch BOOLEAN DEFAULT 1"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add per-user Bambu Cloud credential columns
+    try:
+        await conn.execute(text("ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Cleanup: Remove obsolete settings keys that are no longer used
+    obsolete_keys = ["slicer_binary_path"]
+    for key in obsolete_keys:
+        await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 5 - 1
backend/app/core/permissions.py

@@ -4,7 +4,7 @@ This module defines all permissions using a string enum with `resource:action` n
 Permissions are additive across groups - a user has all permissions from all their groups.
 """
 
-from enum import StrEnum
+from backend.app.core.compat import StrEnum
 
 
 class Permission(StrEnum):
@@ -68,6 +68,7 @@ class Permission(StrEnum):
     INVENTORY_CREATE = "inventory:create"
     INVENTORY_UPDATE = "inventory:update"
     INVENTORY_DELETE = "inventory:delete"
+    INVENTORY_VIEW_ASSIGNMENTS = "inventory:view_assignments"  # View spool-to-AMS assignments on printer cards
 
     # Smart Plugs
     SMART_PLUGS_READ = "smart_plugs:read"
@@ -214,6 +215,7 @@ PERMISSION_CATEGORIES = {
         Permission.INVENTORY_CREATE,
         Permission.INVENTORY_UPDATE,
         Permission.INVENTORY_DELETE,
+        Permission.INVENTORY_VIEW_ASSIGNMENTS,
     ],
     "Smart Plugs": [
         Permission.SMART_PLUGS_READ,
@@ -355,6 +357,7 @@ DEFAULT_GROUPS = {
             Permission.INVENTORY_CREATE.value,
             Permission.INVENTORY_UPDATE.value,
             Permission.INVENTORY_DELETE.value,
+            Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
             # Smart Plugs - full access
             Permission.SMART_PLUGS_READ.value,
             Permission.SMART_PLUGS_CREATE.value,
@@ -411,6 +414,7 @@ DEFAULT_GROUPS = {
             Permission.PROJECTS_READ.value,
             Permission.FILAMENTS_READ.value,
             Permission.INVENTORY_READ.value,
+            Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
             Permission.SMART_PLUGS_READ.value,
             Permission.CAMERA_VIEW.value,
             Permission.MAINTENANCE_READ.value,

+ 127 - 20
backend/app/main.py

@@ -16,6 +16,7 @@ from backend.app.api.routes import (
     archives,
     auth,
     background_dispatch as background_dispatch_routes,
+    bug_report,
     camera,
     cloud,
     discovery,
@@ -261,6 +262,9 @@ _print_ams_mappings: dict[int, list[int]] = {}
 # Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
 _last_progress_milestone: dict[int, int] = {}
 
+# Track whether first layer complete notification has been sent for current print
+_first_layer_notified: dict[int, bool] = {}
+
 # Track HMS errors that have been notified: {printer_id: set of error codes}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
@@ -321,6 +325,14 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
     )
 
 
+def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
+    """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
+    stored_ams_mapping = data.get("ams_mapping")
+    if not stored_ams_mapping and archive_id:
+        stored_ams_mapping = _print_ams_mappings.get(archive_id)
+    return stored_ams_mapping
+
+
 _last_status_broadcast: dict[int, str] = {}
 # Track printers where we've updated nozzle_count
 _nozzle_count_updated: set[int] = set()
@@ -358,12 +370,15 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
     # 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
+    # Include AMS dry_time values so drying status 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 ()
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
-        f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}"
+        f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
+        f"{ams_dry_key}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -428,12 +443,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     elif progress < 5:
         # Reset milestone tracking when print restarts or new print begins
         _last_progress_milestone[printer_id] = 0
+        _first_layer_notified[printer_id] = False
 
-    # HMS error codes that should not trigger notifications.
-    # These are infrastructure/auth issues, not actionable print errors.
+    # HMS error codes that should not trigger notifications even though they
+    # have known descriptions (e.g. user-initiated actions, not real errors).
     _HMS_NOTIFICATION_SUPPRESS = {
-        "0500_0007",  # MQTT command verification failed (auth/bind issue, not a print error)
-        "0500_4001",  # Failed to connect to Bambu Cloud (network issue)
         "0500_400E",  # Printing was cancelled (user action, not an error)
     }
 
@@ -481,6 +495,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                         printer_id, printer, logging.getLogger(__name__)
                     )
 
+                    sent_count = 0
                     for error in new_errors:
                         module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         # Build short code like "0700_8010"
@@ -489,21 +504,24 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                         error_code_masked = error_code_int & 0xFFFF
                         short_code = f"{(error.attr >> 16) & 0xFFFF:04X}_{error_code_masked:04X}"
 
-                        if short_code in _HMS_NOTIFICATION_SUPPRESS:
+                        # Only notify for errors with known descriptions — printers
+                        # send many undocumented/phantom codes that aren't real errors.
+                        description = get_error_description(short_code)
+                        if not description or short_code in _HMS_NOTIFICATION_SUPPRESS:
                             continue
 
                         error_type = f"{module_name} Error"
-                        # Look up human-readable description
-                        description = get_error_description(short_code)
-                        error_detail = description if description else f"Error code: {short_code}"
+                        error_detail = description
 
                         await notification_service.on_printer_error(
                             printer_id, printer_name, error_type, db, error_detail, image_data=error_image_data
                         )
+                        sent_count += 1
 
-                    logging.getLogger(__name__).info(
-                        f"[HMS] Sent notification for {len(new_errors)} new error(s) on printer {printer_id}"
-                    )
+                    if sent_count:
+                        logging.getLogger(__name__).info(
+                            f"[HMS] Sent notification for {sent_count} error(s) on printer {printer_id}"
+                        )
 
                     # Also publish to MQTT relay
                     printer_info = printer_manager.get_printer(printer_id)
@@ -692,7 +710,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
             # Commit any changes (stale deletions and/or fingerprint updates)
             await db.commit()
     except Exception as e:
-        logger.warning("Spool assignment cleanup failed: %s", e)
+        logger.warning("Spool assignment cleanup failed: %s", e, exc_info=True)
 
     # Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS)
     try:
@@ -815,7 +833,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 }
                             )
     except Exception as e:
-        logger.warning("RFID spool auto-assign failed: %s", e)
+        logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
 
     try:
         async with async_session() as db:
@@ -1310,7 +1328,14 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Store Spoolman tracking data for per-filament usage reporting
                 try:
-                    await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager)
+                    await _store_spoolman_print_data(
+                        printer_id,
+                        archive.id,
+                        archive.file_path,
+                        db,
+                        printer_manager,
+                        ams_mapping=_get_start_ams_mapping(data, archive.id),
+                    )
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
@@ -1627,7 +1652,12 @@ async def on_print_start(printer_id: int, data: dict):
                 # Store Spoolman tracking data (may not work for fallback since no 3MF)
                 try:
                     await _store_spoolman_print_data(
-                        printer_id, fallback_archive.id, fallback_archive.file_path, db, printer_manager
+                        printer_id,
+                        fallback_archive.id,
+                        fallback_archive.file_path,
+                        db,
+                        printer_manager,
+                        ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),
                     )
                 except Exception as e:
                     logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
@@ -1747,7 +1777,14 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Store Spoolman tracking data for per-filament usage reporting
                 try:
-                    await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager)
+                    await _store_spoolman_print_data(
+                        printer_id,
+                        archive.id,
+                        archive.file_path,
+                        db,
+                        printer_manager,
+                        ams_mapping=_get_start_ams_mapping(data, archive.id),
+                    )
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
@@ -2208,6 +2245,10 @@ async def on_print_complete(printer_id: int, data: dict):
             queue_item = printing_items[0] if printing_items else None
             if queue_item:
                 queue_status = data.get("status", "completed")
+                # MQTT sends "aborted" for cancelled prints; normalise to
+                # "cancelled" so it matches the queue schema Literal.
+                if queue_status == "aborted":
+                    queue_status = "cancelled"
                 queue_item.status = queue_status
                 queue_item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
@@ -2301,10 +2342,19 @@ async def on_print_complete(printer_id: int, data: dict):
 
             logger.info("[BED-COOL] Monitoring bed temp for printer %s (threshold: %.0f°C)", printer_id, threshold)
 
+            # Request a fresh full status so we get current bed_temper
+            printer_manager.request_status_update(printer_id)
+
             max_polls = 120  # 120 * 15s = 30 min timeout
-            for _ in range(max_polls):
+            for poll_num in range(max_polls):
                 await asyncio.sleep(15)
 
+                # Request fresh temperature data every 60s — after print completion,
+                # the printer may send partial MQTT updates without bed_temper,
+                # leaving the cached value stale at the end-of-print temperature.
+                if poll_num % 4 == 0:
+                    printer_manager.request_status_update(printer_id)
+
                 # Check if printer is still connected
                 status = printer_manager.get_status(printer_id)
                 if status is None:
@@ -2322,8 +2372,16 @@ async def on_print_complete(printer_id: int, data: dict):
                     bed_temp = status.temperatures.get("bed")
 
                 if bed_temp is None:
+                    logger.debug(
+                        "[BED-COOL] Printer %s: bed temp is None (keys: %s, state: %s)",
+                        printer_id,
+                        list(status.temperatures.keys()) if isinstance(status.temperatures, dict) else "N/A",
+                        status.state if hasattr(status, "state") else "N/A",
+                    )
                     continue
 
+                logger.debug("[BED-COOL] Printer %s: bed=%.1f°C, threshold=%.0f°C", printer_id, bed_temp, threshold)
+
                 if bed_temp <= threshold:
                     logger.info(
                         "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)",
@@ -2513,7 +2571,13 @@ async def on_print_complete(printer_id: int, data: dict):
         # Report partial usage if tracking data exists (only stored when weight sync is disabled)
         try:
             async with async_session() as db:
-                await _cleanup_spoolman_tracking(printer_id, archive_id, db)
+                await _cleanup_spoolman_tracking(
+                    printer_id,
+                    archive_id,
+                    db,
+                    last_layer_num=data.get("last_layer_num"),
+                    last_progress=data.get("last_progress"),
+                )
         except Exception as e:
             logger.debug("[SPOOLMAN] Cleanup failed: %s", e)
 
@@ -3256,6 +3320,22 @@ async def lifespan(app: FastAPI):
     # Startup
     await init_db()
 
+    # Fix queue items stuck with invalid "aborted" status (should be "cancelled").
+    # This can happen when a print was cancelled mid-print on versions before this fix.
+    try:
+        async with async_session() as db:
+            from backend.app.models.print_queue import PrintQueueItem
+
+            result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
+            aborted_items = result.scalars().all()
+            if aborted_items:
+                for item in aborted_items:
+                    item.status = "cancelled"
+                await db.commit()
+                logging.info("Fixed %d queue item(s) with invalid 'aborted' status → 'cancelled'", len(aborted_items))
+    except Exception as e:
+        logging.warning("Failed to fix aborted queue items: %s", e)
+
     # Restore debug logging state from previous session
     await init_debug_logging()
 
@@ -3269,11 +3349,37 @@ async def lifespan(app: FastAPI):
 
     # Layer change callback for external camera timelapse
     async def on_layer_change(printer_id: int, layer_num: int):
-        """Capture timelapse frame on layer change."""
+        """Capture timelapse frame on layer change + first layer notification."""
         from backend.app.services.layer_timelapse import on_layer_change as tl_layer_change
 
         await tl_layer_change(printer_id, layer_num)
 
+        # First layer complete notification (layer_num >= 2 means layer 1 is done)
+        if 2 <= layer_num <= 5 and not _first_layer_notified.get(printer_id, False):
+            _first_layer_notified[printer_id] = True
+            try:
+                async with async_session() as db:
+                    from backend.app.models.printer import Printer
+
+                    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                    printer = result.scalar_one_or_none()
+                    if not printer:
+                        return
+                    printer_name = printer.name
+                    client = printer_manager.get_client(printer_id)
+                    state = client.state if client else None
+                    filename = (state.subtask_name or state.gcode_file or "Unknown") if state else "Unknown"
+                    total_layers = state.total_layers if state else 0
+
+                    image_data = await _capture_snapshot_for_notification(
+                        printer_id, printer, logging.getLogger(__name__)
+                    )
+                    await notification_service.on_first_layer_complete(
+                        printer_id, printer_name, filename, total_layers, db, image_data=image_data
+                    )
+            except Exception as e:
+                logging.getLogger(__name__).warning("First layer notification failed: %s", e)
+
     printer_manager.set_layer_change_callback(on_layer_change)
 
     # Initialize MQTT relay from settings
@@ -3563,6 +3669,7 @@ async def auth_middleware(request, call_next):
 
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
+app.include_router(bug_report.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)

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

@@ -1,4 +1,5 @@
 from backend.app.models.ams_history import AMSSensorHistory
+from backend.app.models.ams_label import AmsLabel
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.color_catalog import ColorCatalogEntry
@@ -40,6 +41,7 @@ __all__ = [
     "Project",
     "APIKey",
     "AMSSensorHistory",
+    "AmsLabel",
     "PendingUpload",
     "LibraryFolder",
     "LibraryFile",

+ 30 - 0
backend/app/models/ams_label.py

@@ -0,0 +1,30 @@
+"""Model for storing user-defined friendly names for AMS units.
+
+Users can assign a custom label to each AMS (e.g. "Workshop AMS", "Silk Colours")
+that is displayed in place of or alongside the auto-generated label (AMS-A, HT-A, …).
+
+Labels are keyed by AMS serial number so they persist when the AMS is moved to a
+different printer.  A fallback (printer_id + ams_id) is retained for units whose
+serial number is not yet known.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class AmsLabel(Base):
+    """Maps an AMS unit serial number to a user-defined friendly name."""
+
+    __tablename__ = "ams_labels"
+    __table_args__ = (UniqueConstraint("ams_serial_number", name="uq_ams_label_serial"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    ams_serial_number: Mapped[str] = mapped_column(String(50))  # AMS unit serial number (sn from MQTT)
+    ams_id: Mapped[int | None] = mapped_column(Integer, nullable=True)  # AMS unit ID hint (0, 1, 2, 3, 128…)
+    label: Mapped[str] = mapped_column(String(100))  # User-defined friendly name
+    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())

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

@@ -0,0 +1,20 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class BugReport(Base):
+    __tablename__ = "bug_reports"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    description: Mapped[str] = mapped_column(Text)
+    reporter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    github_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    github_issue_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    status: Mapped[str] = mapped_column(String(20), default="submitted")
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

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

@@ -85,6 +85,7 @@ class NotificationProvider(Base):
 
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
+    on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing
 
     # Event triggers - Print queue
     on_queue_job_added = Column(Boolean, default=False)  # Job added to queue

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

@@ -103,6 +103,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Bed Cooled",
         "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
     },
+    {
+        "event_type": "first_layer_complete",
+        "name": "First Layer Complete",
+        "title_template": "First Layer Complete",
+        "body_template": "{printer}: {filename}\nLayer 1/{total_layers} done",
+    },
     {
         "event_type": "test",
         "name": "Test Notification",

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

@@ -24,6 +24,8 @@ class Spool(Base):
     )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
+    last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
+    last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp

+ 8 - 1
backend/app/models/spoolbuddy_device.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -20,8 +20,15 @@ class SpoolBuddyDevice(Base):
     has_scale: Mapped[bool] = mapped_column(Boolean, default=True)
     tare_offset: Mapped[int] = mapped_column(Integer, default=0)
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
+    nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
+    nfc_connection: Mapped[str | None] = mapped_column(String(20))
+    display_brightness: Mapped[int] = mapped_column(Integer, default=100)
+    display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
+    has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)
+    last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     pending_command: Mapped[str | None] = mapped_column(String(50))
+    pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)

+ 4 - 0
backend/app/models/user.py

@@ -33,6 +33,10 @@ class User(Base):
     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())
 
+    # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
+    cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
+    cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
+
     # Relationship to groups through association table
     groups: Mapped[list[Group]] = relationship(
         "Group",

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

@@ -15,6 +15,7 @@ class VirtualPrinter(Base):
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     mode: Mapped[str] = mapped_column(String(20), default="immediate")  # immediate|review|print_queue|proxy
+    auto_dispatch: Mapped[bool] = mapped_column(Boolean, default=True)  # print_queue mode: auto-start or manual
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(

+ 21 - 0
backend/app/schemas/archive.py

@@ -110,6 +110,27 @@ class ArchiveResponse(BaseModel):
         from_attributes = True
 
 
+class ArchiveSlim(BaseModel):
+    """Lightweight archive response for stats/dashboard widgets."""
+
+    printer_id: int | None
+    print_name: str | None
+    print_time_seconds: int | None
+    actual_time_seconds: int | None = None
+    filament_used_grams: float | None
+    filament_type: str | None
+    filament_color: str | None
+    status: str
+    started_at: datetime | None
+    completed_at: datetime | None
+    cost: float | None
+    quantity: int = 1
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
 class ArchiveStats(BaseModel):
     total_prints: int
     successful_prints: int

+ 0 - 9
backend/app/schemas/auth.py

@@ -100,16 +100,7 @@ class SMTPSettings(BaseModel):
 
 
 class TestSMTPRequest(BaseModel):
-    smtp_host: str
-    smtp_port: int
-    smtp_username: str | None = None  # Optional when auth is disabled
-    smtp_password: str | None = None  # Optional when auth is disabled
-    smtp_security: str = "starttls"  # 'starttls', 'ssl', 'none'
-    smtp_auth_enabled: bool = True
-    smtp_from_email: str
     test_recipient: str
-    # Deprecated field for backward compatibility
-    smtp_use_tls: bool | None = None
 
 
 class TestSMTPResponse(BaseModel):

+ 2 - 1
backend/app/schemas/github_backup.py

@@ -2,10 +2,11 @@
 
 import re
 from datetime import datetime
-from enum import StrEnum
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.core.compat import StrEnum
+
 
 class ScheduleType(StrEnum):
     """Backup schedule types."""

+ 9 - 1
backend/app/schemas/notification.py

@@ -1,11 +1,12 @@
 """Pydantic schemas for notification providers."""
 
 from datetime import datetime
-from enum import StrEnum
 from typing import Any
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.core.compat import StrEnum
+
 
 class ProviderType(StrEnum):
     """Supported notification provider types."""
@@ -17,6 +18,7 @@ class ProviderType(StrEnum):
     EMAIL = "email"
     DISCORD = "discord"
     WEBHOOK = "webhook"
+    HOMEASSISTANT = "homeassistant"
 
 
 class NotificationProviderBase(BaseModel):
@@ -56,6 +58,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
 
+    # Event triggers - First layer complete
+    on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
+
     # Event triggers - Print queue
     on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
     on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
@@ -135,6 +140,9 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None
 
+    # Event triggers - First layer complete
+    on_first_layer_complete: bool | None = None
+
     # Event triggers - Print queue
     on_queue_job_added: bool | None = None
     on_queue_job_assigned: bool | None = None

+ 8 - 4
backend/app/schemas/notification_template.py

@@ -1,10 +1,11 @@
 """Pydantic schemas for notification templates."""
 
 from datetime import datetime
-from enum import StrEnum
 
 from pydantic import BaseModel, Field
 
+from backend.app.core.compat import StrEnum
+
 
 class EventType(StrEnum):
     """Supported notification event types."""
@@ -26,7 +27,7 @@ class EventType(StrEnum):
 
 # Available variables for each event type
 EVENT_VARIABLES: dict[str, list[str]] = {
-    "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
+    "print_start": ["printer", "filename", "estimated_time", "eta", "timestamp", "app_name"],
     "print_complete": [
         "printer",
         "filename",
@@ -60,7 +61,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "app_name",
     ],
-    "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
+    "print_progress": ["printer", "filename", "progress", "remaining_time", "eta", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -72,7 +73,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
     "queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"],
-    "queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"],
+    "queue_job_started": ["printer", "job_name", "estimated_time", "eta", "timestamp", "app_name"],
     "queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"],
     "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
     "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
@@ -88,6 +89,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "estimated_time": "1h 23m",
+        "eta": "15:53",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
@@ -129,6 +131,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "progress": "50",
         "remaining_time": "0h 41m",
+        "eta": "15:41",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
     },
@@ -204,6 +207,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "job_name": "Benchy.3mf",
         "estimated_time": "1h 23m",
+        "eta": "15:53",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },

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

@@ -130,6 +130,8 @@ class AMSTray(BaseModel):
     tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
     nozzle_temp_min: int | None = None  # Min nozzle temperature
     nozzle_temp_max: int | None = None  # Max nozzle temperature
+    drying_temp: int | None = None  # RFID-recommended drying temp
+    drying_time: int | None = None  # RFID-recommended drying time (hours)
 
 
 class AMSUnit(BaseModel):
@@ -138,6 +140,13 @@ class AMSUnit(BaseModel):
     temp: float | None = None
     is_ams_ht: bool = False  # True for AMS-HT (single spool), False for regular AMS (4 spools)
     tray: list[AMSTray] = []
+    serial_number: str = ""  # AMS unit serial number (sn from MQTT)
+    sw_ver: str = ""  # AMS firmware version (from get_version info.module)
+    dry_time: int = 0  # Minutes remaining (0 = not drying, >0 = drying active)
+    dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
+    dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify
+    dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
+    module_type: str = ""  # "ams", "n3f", "n3s"
 
 
 class NozzleInfoResponse(BaseModel):
@@ -160,6 +169,11 @@ class NozzleRackSlot(BaseModel):
     filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
 
 
+class AmsLabelBody(BaseModel):
+    label: str = Field(..., min_length=1, max_length=100)
+    ams_serial: str = Field(default="", max_length=50)
+
+
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
 
@@ -205,6 +219,7 @@ class PrinterStatus(BaseModel):
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
+    wired_network: bool = False  # Ethernet connection detected
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     nozzle_rack: list[NozzleRackSlot] = []  # H2C 6-nozzle tool-changer rack
     print_options: PrintOptionsResponse | None = None  # AI detection and print options
@@ -249,3 +264,5 @@ class PrinterStatus(BaseModel):
     developer_mode: bool | None = None
     # Queue: user has acknowledged plate is cleared for next queued print
     plate_cleared: bool = False
+    # AMS drying support
+    supports_drying: bool = False

+ 33 - 1
backend/app/schemas/settings.py

@@ -38,6 +38,7 @@ class AppSettings(BaseModel):
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
 
     # Language
+    language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
     # Bed cooled notification threshold
@@ -56,6 +57,23 @@ class AppSettings(BaseModel):
     )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
 
+    # Queue auto-drying settings
+    queue_drying_enabled: bool = Field(
+        default=False, description="Automatically dry AMS filament between queued prints"
+    )
+    queue_drying_block: bool = Field(
+        default=False,
+        description="Block queue until drying completes (when disabled, prints take priority over drying)",
+    )
+    ambient_drying_enabled: bool = Field(
+        default=False,
+        description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
+    )
+    drying_presets: str = Field(
+        default="",
+        description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
+    )
+
     # Print modal settings
     per_printer_mapping_expanded: bool = Field(
         default=False, description="Expand custom filament mapping by default in print modal"
@@ -92,7 +110,7 @@ class AppSettings(BaseModel):
     ftp_retry_enabled: bool = Field(default=True, description="Enable automatic retry for FTP operations")
     ftp_retry_count: int = Field(default=3, description="Number of retry attempts for FTP operations (1-10)")
     ftp_retry_delay: int = Field(default=2, description="Seconds to wait between FTP retry attempts (1-30)")
-    ftp_timeout: int = Field(default=30, description="FTP connection timeout in seconds (10-120)")
+    ftp_timeout: int = Field(default=30, description="FTP connection timeout in seconds (10-300)")
 
     # MQTT Relay settings for publishing events to external broker
     mqtt_enabled: bool = Field(default=False, description="Enable MQTT event publishing to external broker")
@@ -148,6 +166,14 @@ class AppSettings(BaseModel):
         default="", description="Bearer token for Prometheus metrics authentication (optional)"
     )
 
+    # Inventory low stock threshold
+    low_stock_threshold: float = Field(
+        default=20.0,
+        ge=0.1,
+        le=99.9,
+        description="Low stock threshold percentage (%) for inventory filtering and display",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -167,6 +193,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
     ams_humidity_good: int | None = None
@@ -174,6 +201,10 @@ class AppSettingsUpdate(BaseModel):
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_history_retention_days: int | None = None
+    queue_drying_enabled: bool | None = None
+    queue_drying_block: bool | None = None
+    ambient_drying_enabled: bool | None = None
+    drying_presets: str | None = None
     per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     time_format: str | None = None
@@ -208,3 +239,4 @@ class AppSettingsUpdate(BaseModel):
     preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
+    low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

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

@@ -24,6 +24,8 @@ class SpoolBase(BaseModel):
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool = False
+    last_scale_weight: int | None = None
+    last_weighed_at: datetime | None = None
 
 
 class SpoolCreate(SpoolBase):
@@ -115,6 +117,7 @@ class SpoolAssignmentResponse(BaseModel):
     created_at: datetime
     spool: SpoolResponse | None = None
     configured: bool = False
+    ams_label: str | None = None  # User-defined friendly name for the AMS unit
 
     class Config:
         from_attributes = True

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

@@ -14,6 +14,9 @@ class DeviceRegisterRequest(BaseModel):
     has_scale: bool = True
     tare_offset: int = 0
     calibration_factor: float = 1.0
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    has_backlight: bool = False
 
 
 class DeviceResponse(BaseModel):
@@ -26,6 +29,12 @@ class DeviceResponse(BaseModel):
     has_scale: bool
     tare_offset: int
     calibration_factor: float
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    display_brightness: int = 100
+    display_blank_timeout: int = 0
+    has_backlight: bool = False
+    last_calibrated_at: datetime | None = None
     last_seen: datetime | None = None
     pending_command: str | None = None
     nfc_ok: bool
@@ -45,12 +54,17 @@ class HeartbeatRequest(BaseModel):
     uptime_s: int = 0
     firmware_version: str | None = None
     ip_address: str | None = None
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
 
 
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
+    pending_write_payload: dict | None = None
     tare_offset: int
     calibration_factor: float
+    display_brightness: int = 100
+    display_blank_timeout: int = 0
 
 
 # --- NFC schemas ---
@@ -92,11 +106,37 @@ class TareRequest(BaseModel):
     pass
 
 
+class SetTareRequest(BaseModel):
+    tare_offset: int
+
+
 class SetCalibrationFactorRequest(BaseModel):
     known_weight_grams: float = Field(..., gt=0)
     raw_adc: int
+    tare_raw_adc: int | None = None
 
 
 class CalibrationResponse(BaseModel):
     tare_offset: int
     calibration_factor: float
+
+
+# --- Display schemas ---
+
+
+class WriteTagRequest(BaseModel):
+    device_id: str
+    spool_id: int
+
+
+class WriteTagResultRequest(BaseModel):
+    device_id: str
+    spool_id: int
+    tag_uid: str
+    success: bool
+    message: str | None = None
+
+
+class DisplaySettingsRequest(BaseModel):
+    brightness: int = Field(ge=0, le=100)
+    blank_timeout: int = Field(ge=0)

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

@@ -4,7 +4,7 @@ import logging
 import re
 import shutil
 import zipfile
-from datetime import datetime, timezone
+from datetime import date, datetime, time, timezone
 from pathlib import Path
 
 from defusedxml import ElementTree as ET
@@ -996,6 +996,8 @@ class ArchiveService:
         self,
         printer_id: int | None = None,
         project_id: int | None = None,
+        date_from: date | None = None,
+        date_to: date | None = None,
         limit: int = 50,
         offset: int = 0,
     ) -> list[PrintArchive]:
@@ -1014,6 +1016,14 @@ class ArchiveService:
         if project_id:
             query = query.where(PrintArchive.project_id == project_id)
 
+        if date_from:
+            dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+            query = query.where(PrintArchive.created_at >= dt_from)
+
+        if date_to:
+            dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+            query = query.where(PrintArchive.created_at <= dt_to)
+
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         return list(result.scalars().all())

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

@@ -715,6 +715,7 @@ class BackgroundDispatchService:
             archive = await archive_service.archive_print(
                 printer_id=job.printer_id,
                 source_file=file_path,
+                original_filename=lib_file.filename,
             )
             if not archive:
                 raise RuntimeError("Failed to create archive")

+ 54 - 15
backend/app/services/bambu_ftp.py

@@ -4,6 +4,7 @@ import logging
 import os
 import socket
 import ssl
+import time
 from collections.abc import Awaitable, Callable
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
@@ -81,11 +82,10 @@ class BambuFTPClient:
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # These models have varying FTP SSL behavior depending on firmware version
     A1_MODELS = ("A1", "A1 Mini")
-    # Chunk size for manual upload transfer (1MB)
-    # Larger chunks reduce overhead and work better with A1 printers
-    CHUNK_SIZE = 1024 * 1024
-    # Per-chunk data socket timeout during upload.
-    UPLOAD_CHUNK_TIMEOUT = 120
+    # Chunk size for manual upload transfer (64KB)
+    # Smaller chunks provide smoother progress reporting — at typical printer FTP
+    # speeds (~50-100KB/s) this gives a progress update roughly every second.
+    CHUNK_SIZE = 64 * 1024
 
     # Cache for working FTP modes per printer IP
     # Maps IP -> "prot_p" or "prot_c"
@@ -368,11 +368,16 @@ class BambuFTPClient:
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             with open(local_path, "rb") as f:
                 logger.debug("FTP STOR command starting for %s", remote_path)
+                t0 = time.monotonic()
                 conn = self._ftp.transfercmd(f"STOR {remote_path}")
+                logger.info(
+                    "FTP data channel ready in %.1fs (PASV + TLS handshake)",
+                    time.monotonic() - t0,
+                )
 
                 # Set explicit socket options for reliable transfer
                 conn.setblocking(True)
-                conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
+                conn.settimeout(self.timeout)
 
                 try:
                     while True:
@@ -408,14 +413,30 @@ class BambuFTPClient:
                     except OSError:
                         pass
 
-            # Skip voidresp() for A1 models — they hang after transfercmd uploads
-            if self.printer_model not in self.A1_MODELS:
+            # Wait for the server's 226 "Transfer complete" response to confirm
+            # the file has been flushed to the SD card. Without this, the printer
+            # may try to read an incomplete file when the print command is sent,
+            # causing 0500-C010 "MicroSD Card read/write exception" errors.
+            # See: https://bugs.python.org/issue25458 (ftplib response desync)
+            try:
+                old_timeout = self._ftp.sock.gettimeout()
+                # Use a generous timeout — H2D printers can take 30+ seconds
+                # to send the 226 after the data channel closes.
+                self._ftp.sock.settimeout(max(self.timeout, 60))
                 try:
-                    self._ftp.voidresp()
-                except (OSError, ftplib.Error) as e:
-                    # Data transfer already completed — voidresp() failure is just a noisy
-                    # 226 acknowledgment issue, not an actual upload failure. Log and continue.
-                    logger.warning("FTP upload response for %s was not clean (data already sent): %s", remote_path, e)
+                    resp = self._ftp.voidresp()
+                    logger.info("FTP STOR confirmed for %s: %s", remote_path, resp.strip())
+                finally:
+                    self._ftp.sock.settimeout(old_timeout)
+            except Exception as e:
+                # Timeout or error reading 226 — log but proceed, the data
+                # was fully sent so the file is likely on the SD card.
+                logger.warning(
+                    "FTP STOR confirmation not received for %s (proceeding): %s (%s)",
+                    remote_path,
+                    e,
+                    type(e).__name__,
+                )
 
             if callback_exception is not None:
                 cleanup_ok = False
@@ -432,7 +453,15 @@ class BambuFTPClient:
                     f"Upload cancelled but failed to remove partial file {remote_path} from printer"
                 ) from callback_exception
 
-            logger.info("FTP upload complete: %s", remote_path)
+            elapsed = time.monotonic() - t0
+            speed_kbs = (file_size / 1024) / elapsed if elapsed > 0 else 0
+            logger.info(
+                "FTP upload complete: %s (%s bytes in %.1fs, %.0f KB/s)",
+                remote_path,
+                file_size,
+                elapsed,
+                speed_kbs,
+            )
             return True
         except ftplib.error_perm as e:
             # Permanent FTP error (4xx/5xx response)
@@ -462,7 +491,7 @@ class BambuFTPClient:
             # Use manual transfer instead of storbinary() for A1 compatibility
             conn = self._ftp.transfercmd(f"STOR {remote_path}")
             conn.setblocking(True)
-            conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
+            conn.settimeout(self.timeout)
 
             try:
                 # Send data in chunks
@@ -479,6 +508,16 @@ class BambuFTPClient:
                     conn.close()
                 except OSError:
                     pass
+            # Wait for 226 confirmation (see upload_file for rationale)
+            try:
+                old_timeout = self._ftp.sock.gettimeout()
+                self._ftp.sock.settimeout(max(self.timeout, 60))
+                try:
+                    self._ftp.voidresp()
+                finally:
+                    self._ftp.sock.settimeout(old_timeout)
+            except Exception:
+                pass  # Best-effort — data was sent, proceed
             return True
         except (OSError, ftplib.Error):
             return False

+ 296 - 41
backend/app/services/bambu_mqtt.py

@@ -22,6 +22,13 @@ import paho.mqtt.client as mqtt
 
 logger = logging.getLogger(__name__)
 
+# AMS module name prefixes used in get_version responses.
+# The numeric suffix after '/' is the AMS unit ID as reported in push_status.
+#   "ams/<id>"  – original AMS (X1C, X1E, P1S, …)
+#   "n3f/<id>"  – AMS 2 Pro (H2D Pro and similar)
+#   "n3s/<id>"  – AMS HT (H2D Pro and similar; IDs typically start at 128)
+_AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
+
 
 @dataclass
 class MQTTLogEntry:
@@ -113,6 +120,7 @@ class PrinterState:
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
+    wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
@@ -145,7 +153,7 @@ class PrinterState:
     mc_print_sub_stage: int = 0
     # AMS mapping for dual nozzle: which slot is active (from ams.ams_exist_bits/tray_exist_bits)
     ams_mapping: list = field(default_factory=list)
-    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
+    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
     ams_extruder_map: dict = field(default_factory=dict)
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
@@ -296,6 +304,14 @@ class BambuMQTTClient:
         self._disconnection_event: threading.Event | None = None
         self._previous_ams_hash: str | None = None  # Track AMS changes
 
+        # Cache AMS firmware/SN from get_version in case it arrives before AMS status
+        # Key: ams_id (int). Value: {'sw_ver': str, 'sn': str}
+        self._ams_version_cache: dict[int, dict[str, str]] = {}
+
+        # Track which (ams_id, field) warnings have already been emitted this connection
+        # so that missing-serial / missing-firmware warnings fire only once per connection.
+        self._ams_version_warned: set[tuple[int | str, str]] = set()
+
         # K-profile command tracking
         self._sequence_id: int = 0
         self._pending_kprofile_response: asyncio.Event | None = None
@@ -355,6 +371,8 @@ class BambuMQTTClient:
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
             self.state.connected = True
+            # Reset per-connection warning state so warnings fire once per (re)connection
+            self._ams_version_warned = set()
             client.subscribe(self.topic_subscribe)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             if self._request_topic_supported:
@@ -538,6 +556,22 @@ class BambuMQTTClient:
                 except ValueError:
                     pass  # Ignore unparseable wifi_signal strings; field is non-critical
 
+            # Detect ethernet: wifi_signal == -90 is a sentinel for "WiFi disabled/ethernet"
+            from backend.app.utils.printer_models import has_ethernet
+
+            if has_ethernet(self.model):
+                self.state.wired_network = self.state.wifi_signal == -90
+
+        # Parse developer LAN mode from top-level "fun" field
+        # Some firmware versions send "fun" at the top level, others inside "print"
+        if "fun" in payload and self.state.developer_mode is None:
+            try:
+                fun_val = payload["fun"]
+                fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)
+                self.state.developer_mode = (fun_int & 0x20000000) == 0
+            except (ValueError, TypeError):
+                pass
+
         if "print" in payload:
             print_data = payload["print"]
 
@@ -653,12 +687,24 @@ class BambuMQTTClient:
         """Handle version info response from get_version command.
 
         Parses firmware version from the 'ota' module in the module list.
+        Also extracts AMS unit firmware versions from AMS modules and stores
+        them on the corresponding AMS unit in raw_data so the status route can
+        expose them to the frontend.
+
+        AMS module naming conventions (numeric suffix is the AMS unit ID):
+        - ``ams/<id>``  – original AMS
+        - ``n3f/<id>``  – AMS 2 Pro (H2D Pro and similar)
+        - ``n3s/<id>``  – AMS HT (H2D Pro and similar)
+
         Message format:
         {
             "command": "get_version",
             "module": [
                 {"name": "ota", "sw_ver": "01.08.05.00"},
                 {"name": "rv1126", "sw_ver": "00.00.14.74"},
+                {"name": "ams/0", "sw_ver": "00.00.06.96", "sn": "ABC123"},
+                {"name": "n3f/0", "sw_ver": "03.00.21.29", "sn": "19C06A552504488"},
+                {"name": "n3s/128", "sw_ver": "03.00.21.29", "sn": "19F06A561801096"},
                 ...
             ]
         }
@@ -667,6 +713,7 @@ class BambuMQTTClient:
         if not isinstance(modules, list):
             return
 
+        state_changed = False
         for module in modules:
             if not isinstance(module, dict):
                 continue
@@ -677,11 +724,123 @@ class BambuMQTTClient:
                     self.state.firmware_version = version
                     if old_version != version:
                         logger.info("[%s] Firmware version: %s", self.serial_number, version)
-                    # Trigger state change callback
-                    if self.on_state_change:
-                        self.on_state_change(self.state)
+                    state_changed = True
                 break
 
+        # Extract AMS unit firmware versions from AMS modules.
+        # See module-level _AMS_MODULE_PREFIXES for supported naming conventions.
+        # Always cache regardless of whether AMS data has arrived yet — get_version
+        # often arrives before the first push_status, so caching must be unconditional.
+        ams_raw = self.state.raw_data.get("ams")
+        for module in modules:
+            if not isinstance(module, dict):
+                continue
+            name = module.get("name", "")
+            if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):
+                continue
+            try:
+                ams_id = int(name.split("/", 1)[1])
+            except (ValueError, IndexError):
+                continue
+            sw_ver = module.get("sw_ver", "")
+            sn = module.get("sn", "")
+
+            # Extract module type from prefix (e.g. "ams/0" → "ams", "n3f/0" → "n3f")
+            module_type = name.split("/", 1)[0]
+
+            # Always cache so _apply_ams_version_cache can apply it when AMS data arrives
+            if sw_ver or sn or module_type:
+                self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn, "module_type": module_type}
+                state_changed = True
+
+            # Also directly update any AMS unit already present in raw_data
+            if ams_raw and isinstance(ams_raw, list):
+                for ams_unit in ams_raw:
+                    if not isinstance(ams_unit, dict):
+                        continue
+                    try:
+                        unit_id = int(ams_unit.get("id")) if ams_unit.get("id") is not None else None
+                    except (ValueError, TypeError):
+                        unit_id = None
+                    if unit_id == ams_id:
+                        if sw_ver:
+                            ams_unit["sw_ver"] = sw_ver
+                            logger.debug("[%s] AMS %s firmware: %s", self.serial_number, ams_id, sw_ver)
+                        # Only set sn from version info if not already present in AMS data
+                        if sn and not ams_unit.get("sn"):
+                            ams_unit["sn"] = sn
+                        if module_type:
+                            ams_unit["module_type"] = module_type
+                        break
+
+        # Trigger state change callback AFTER both loops so AMS sn/sw_ver are
+        # included in the broadcast (not just the printer firmware version).
+        if state_changed and self.on_state_change:
+            self.on_state_change(self.state)
+
+        # Warn if any AMS unit is still missing serial number or firmware version
+        # after processing the version info response. Warn only once per connection
+        # to avoid repeated noise on older firmware that doesn't report these fields.
+        if ams_raw and isinstance(ams_raw, list):
+            for ams_unit in ams_raw:
+                if not isinstance(ams_unit, dict):
+                    continue
+                ams_id = ams_unit.get("id", "?")
+                if not ams_unit.get("sn") and not ams_unit.get("serial_number"):
+                    key = (ams_id, "sn")
+                    if key not in self._ams_version_warned:
+                        self._ams_version_warned.add(key)
+                        logger.warning(
+                            "[%s] AMS unit %s: serial number not available in version info",
+                            self.serial_number,
+                            ams_id,
+                        )
+                if not ams_unit.get("sw_ver"):
+                    key = (ams_id, "sw_ver")
+                    if key not in self._ams_version_warned:
+                        self._ams_version_warned.add(key)
+                        logger.warning(
+                            "[%s] AMS unit %s: firmware version not available in version info",
+                            self.serial_number,
+                            ams_id,
+                        )
+
+    def _apply_ams_version_cache(self, ams_list: list) -> None:
+        """Apply cached AMS firmware/SN (from get_version) onto an AMS list in-place.
+
+        get_version may arrive before pushall/AMS status, and AMS unit IDs may be
+        strings in MQTT payloads. This helper normalizes IDs and fills missing
+        sw_ver/sn fields without overwriting values already present.
+        """
+        if not ams_list or not isinstance(ams_list, list):
+            return
+        cache = self._ams_version_cache
+        if not cache:
+            return
+        for unit in ams_list:
+            if not isinstance(unit, dict):
+                continue
+            raw_id = unit.get("id")
+            try:
+                unit_id = int(raw_id) if raw_id is not None else None
+            except (ValueError, TypeError):
+                unit_id = None
+            if unit_id is None:
+                continue
+            cached = cache.get(unit_id)
+            if not cached:
+                continue
+            sw_ver = cached.get("sw_ver") or ""
+            sn = cached.get("sn") or ""
+            if sw_ver and not unit.get("sw_ver"):
+                unit["sw_ver"] = sw_ver
+            # Only set sn if not already present in AMS data
+            if sn and not unit.get("sn") and not unit.get("serial_number"):
+                unit["sn"] = sn
+            module_type = cached.get("module_type") or ""
+            if module_type and not unit.get("module_type"):
+                unit["module_type"] = module_type
+
     def _parse_xcam_data(self, xcam_data):
         """Parse xcam data for camera settings and AI detection options."""
         if not isinstance(xcam_data, dict):
@@ -1228,6 +1387,10 @@ class BambuMQTTClient:
                             merged_trays.append(new_tray)
                     # Update ams_unit with merged trays
                     ams_unit = {**ams_unit, "tray": merged_trays}
+                elif existing_unit:
+                    # Partial update without tray data: merge new fields into existing
+                    # unit to preserve tray, sn, sw_ver, and other accumulated data.
+                    ams_unit = {**existing_unit, **ams_unit}
                 existing_by_id[ams_id] = ams_unit
 
         # Convert back to list, sorted by ID for consistent ordering
@@ -1276,36 +1439,70 @@ class BambuMQTTClient:
 
         self.state.raw_data["ams"] = merged_ams
 
+        # Apply cached AMS firmware/SN from get_version (handles ordering and id type mismatches)
+        self._apply_ams_version_cache(merged_ams)
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
         self.state.last_ams_update = time.time()
         logger.debug("[%s] Merged AMS data: %s new units, %s total", self.serial_number, len(ams_list), len(merged_ams))
 
         # Extract ams_extruder_map from each AMS unit's info field
-        # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
-
-        ams_extruder_map = {}
-        for ams_unit in ams_list:
+        # BambuStudio DevFilaSystem.cpp parses info as hex string:
+        #   type_id    = get_flag_bits(info, 0, 4)   // bits 0-3: AMS type
+        #   extruder_id = get_flag_bits(info, 8, 4)  // bits 8-11: extruder assignment
+        # where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
+        # extruder_id: 0=right/main, 1=left/deputy, 0xE=uninitialized (skip)
+        #
+        # Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
+        # the full map. Merge into existing map to preserve entries from prior updates.
+
+        ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
+        for ams_unit in merged_ams:
             ams_id = ams_unit.get("id")
             info = ams_unit.get("info")
             if ams_id is not None and info is not None:
                 try:
-                    info_val = int(info) if isinstance(info, str) else info
-                    # Extract bit 8 for extruder assignment
-                    # Bit 8 = 0 means LEFT extruder (id 1), bit 8 = 1 means RIGHT extruder (id 0)
-                    # So we invert: extruder_id = 1 - bit8
-                    bit8 = (info_val >> 8) & 0x1
-                    extruder_id = 1 - bit8  # 0=right, 1=left
+                    # info is a hex-encoded string in MQTT JSON (e.g. "10001003")
+                    info_val = int(str(info), 16)
+                    # Extract 4 bits starting at bit 8 for extruder assignment
+                    extruder_id = (info_val >> 8) & 0xF
+                    if extruder_id == 0xE:
+                        # 0xE = uninitialized AMS, skip
+                        continue
                     ams_extruder_map[str(ams_id)] = extruder_id
-                    logger.debug(
-                        f"[{self.serial_number}] AMS {ams_id} info={info_val} (bit8={bit8}) -> extruder {extruder_id}"
-                    )
+                    logger.debug(f"[{self.serial_number}] AMS {ams_id} info=0x{info} -> extruder {extruder_id}")
                 except (ValueError, TypeError):
                     pass  # Skip AMS units with unparseable info bitmask values
         if ams_extruder_map:
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map
-            self.state.ams_extruder_map = ams_extruder_map  # Also set on state for inference logic
+            self.state.ams_extruder_map = ams_extruder_map
             logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
 
+        # Extract drying status from info hex string and dry_sf_reason per AMS unit
+        # BambuStudio DevFilaSystem.cpp parses info bits:
+        #   dry_status     = get_flag_bits(info, 4, 4)   // bits 4-7
+        #   dry_sub_status = get_flag_bits(info, 22, 4)  // bits 22-25
+        for ams_unit in merged_ams:
+            info = ams_unit.get("info")
+            if info is not None:
+                try:
+                    info_val = int(str(info), 16)
+                    ams_unit["dry_status"] = (info_val >> 4) & 0xF
+                    ams_unit["dry_sub_status"] = (info_val >> 22) & 0xF
+                except (ValueError, TypeError):
+                    pass  # Skip unparseable info values
+            # dry_sf_reason is a per-unit array of cannot-dry reason codes
+            if "dry_sf_reason" in ams_unit:
+                sf_reason = ams_unit["dry_sf_reason"]
+                if isinstance(sf_reason, list):
+                    ams_unit["dry_sf_reason"] = [
+                        int(r) for r in sf_reason if isinstance(r, int) or (isinstance(r, str) and r.isdigit())
+                    ]
+                else:
+                    ams_unit["dry_sf_reason"] = []
+
+        # Persist updated drying fields back to raw_data
+        self.state.raw_data["ams"] = merged_ams
+
         # Create a hash of relevant AMS data to detect changes
         ams_hash_data = []
         for ams_unit in ams_list:
@@ -1844,6 +2041,11 @@ class BambuMQTTClient:
                         severity = (attr >> 8) & 0xF
                         # Module is in attr byte 3 (bits 24-31)
                         module = (attr >> 24) & 0xFF
+                        # Skip non-error status codes — all real HMS errors
+                        # have code >= 0x4000. Lower values are status/phase
+                        # indicators that some firmware sends during normal printing.
+                        if code < 0x4000:
+                            continue
                         self.state.hms_errors.append(
                             HMSError(
                                 code=f"0x{code:x}" if code else "0x0",
@@ -1865,32 +2067,38 @@ class BambuMQTTClient:
                 module = (print_error >> 16) & 0xFFFF  # High 16 bits (e.g., 0x0500)
                 error = print_error & 0xFFFF  # Low 16 bits (e.g., 0x8061)
 
-                # Store in a format that matches the community error database
-                # attr stores the full 32-bit value for reconstruction
-                # code stores the short format string for lookup
-                short_code = f"{module:04X}_{error:04X}"
+                # Values below 0x4000 are status/phase indicators, not real errors.
+                # All known HMS errors use 0x4xxx (fatal), 0x8xxx (warning), 0xCxxx (prompt).
+                # Some firmware sends low values like 0x0002 during normal printing.
+                if error < 0x4000:
+                    pass  # Skip — not a real error
+                else:
+                    # Store in a format that matches the community error database
+                    # attr stores the full 32-bit value for reconstruction
+                    # code stores the short format string for lookup
+                    short_code = f"{module:04X}_{error:04X}"
 
-                logger.debug(
-                    f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
-                )
+                    logger.debug(
+                        f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
+                    )
 
-                # Only add if not already in HMS errors (avoid duplicates)
-                existing_short_codes = set()
-                for e in self.state.hms_errors:
-                    # Extract short code from existing errors
-                    e_module = (e.attr >> 16) & 0xFFFF
-                    e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
-                    existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
-
-                if short_code not in existing_short_codes:
-                    self.state.hms_errors.append(
-                        HMSError(
-                            code=f"0x{error:x}",
-                            attr=print_error,  # Store full value for display
-                            module=module >> 8,  # High byte of module (e.g., 0x05)
-                            severity=3,  # Warning level for print_error
+                    # Only add if not already in HMS errors (avoid duplicates)
+                    existing_short_codes = set()
+                    for e in self.state.hms_errors:
+                        # Extract short code from existing errors
+                        e_module = (e.attr >> 16) & 0xFFFF
+                        e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
+                        existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
+
+                    if short_code not in existing_short_codes:
+                        self.state.hms_errors.append(
+                            HMSError(
+                                code=f"0x{error:x}",
+                                attr=print_error,  # Store full value for display
+                                module=module >> 8,  # High byte of module (e.g., 0x05)
+                                severity=3,  # Warning level for print_error
+                            )
                         )
-                    )
 
         # Parse SD card status
         if "sdcard" in data:
@@ -1953,6 +2161,14 @@ class BambuMQTTClient:
                 except ValueError:
                     pass  # Ignore unparseable wifi_signal strings; field is non-critical
 
+            # Detect ethernet connection: printers on ethernet with WiFi disabled
+            # report a hardcoded wifi_signal of -90 dBm. Real WiFi signals vary
+            # (typically -30 to -80 dBm). Only check models with an ethernet port.
+            from backend.app.utils.printer_models import has_ethernet
+
+            if has_ethernet(self.model):
+                self.state.wired_network = self.state.wifi_signal == -90
+
         # Parse print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
         if "spd_lvl" in data:
             new_speed = data["spd_lvl"]
@@ -2716,6 +2932,45 @@ class BambuMQTTClient:
         """Check if logging is enabled."""
         return self._logging_enabled
 
+    def send_drying_command(self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = ""):
+        """Send AMS drying start/stop command.
+
+        Args:
+            ams_id: AMS unit ID (0-3 for AMS 2 Pro, 128-135 for AMS-HT)
+            temp: Target drying temperature (45-65 for AMS 2 Pro, 45-85 for AMS-HT)
+            duration: Drying duration in hours
+            mode: 1=start, 0=stop
+            filament: Filament type string (e.g. "PLA", "PETG")
+        """
+        if not self._client:
+            return False
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "sequence_id": str(self._sequence_id),
+                "command": "ams_filament_drying",
+                "ams_id": ams_id,
+                "temp": temp,
+                "cooling_temp": 20 if mode == 1 else 0,
+                "duration": duration,
+                "humidity": 0,
+                "mode": mode,
+                "rotate_tray": False,
+                "filament": filament,
+                "close_power_conflict": False,
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(
+            "[%s] Sent drying command: ams_id=%d, temp=%d, duration=%d, mode=%d",
+            self.serial_number,
+            ams_id,
+            temp,
+            duration,
+            mode,
+        )
+        return True
+
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")

+ 142 - 0
backend/app/services/bug_report.py

@@ -0,0 +1,142 @@
+"""Bug report service — posts to the bambuddy.cool relay which holds the GitHub PAT."""
+
+import logging
+import time
+
+import httpx
+
+from backend.app.core.config import BUG_REPORT_RELAY_URL
+from backend.app.core.database import async_session
+from backend.app.models.bug_report import BugReport
+
+logger = logging.getLogger(__name__)
+
+# Rate limiting: max 5 reports per hour
+_rate_limit_window = 3600
+_rate_limit_max = 5
+_rate_limit_timestamps: list[float] = []
+
+
+def _check_rate_limit() -> bool:
+    """Check if rate limit allows a new report. Returns True if allowed."""
+    now = time.time()
+    _rate_limit_timestamps[:] = [t for t in _rate_limit_timestamps if now - t < _rate_limit_window]
+    if len(_rate_limit_timestamps) >= _rate_limit_max:
+        return False
+    _rate_limit_timestamps.append(now)
+    return True
+
+
+async def submit_report(
+    description: str,
+    reporter_email: str | None,
+    screenshot_base64: str | None,
+    support_info: dict | None,
+) -> dict:
+    """Submit a bug report via the bambuddy.cool relay."""
+    if not _check_rate_limit():
+        return {
+            "success": False,
+            "message": "Rate limit exceeded. Please try again later.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    if not BUG_REPORT_RELAY_URL:
+        return {
+            "success": False,
+            "message": "Bug reporting is not configured. BUG_REPORT_RELAY_URL is not set.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    # Build relay payload — email is sent to relay for maintainer notification + issue body
+    payload: dict = {"description": description}
+    if reporter_email:
+        payload["reporter_email"] = reporter_email
+    if screenshot_base64:
+        payload["screenshot_base64"] = screenshot_base64
+    if support_info:
+        payload["support_info"] = support_info
+
+    try:
+        async with httpx.AsyncClient(timeout=60.0) as client:
+            resp = await client.post(BUG_REPORT_RELAY_URL, json=payload)
+            if resp.status_code != 200:
+                error_msg = f"Relay returned HTTP {resp.status_code}"
+                logger.error("%s at %s", error_msg, BUG_REPORT_RELAY_URL)
+                async with async_session() as db:
+                    report = BugReport(
+                        description=description,
+                        reporter_email=reporter_email,
+                        status="failed",
+                        error_message=error_msg,
+                    )
+                    db.add(report)
+                    await db.commit()
+                return {
+                    "success": False,
+                    "message": "Bug report relay is not available. Please try again later.",
+                    "issue_url": None,
+                    "issue_number": None,
+                }
+            relay_data = resp.json()
+    except Exception:
+        logger.exception("Failed to reach bug report relay at %s", BUG_REPORT_RELAY_URL)
+        async with async_session() as db:
+            report = BugReport(
+                description=description,
+                reporter_email=reporter_email,
+                status="failed",
+                error_message="Failed to reach bug report relay",
+            )
+            db.add(report)
+            await db.commit()
+
+        return {
+            "success": False,
+            "message": "Failed to submit bug report. Please try again later.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    if not relay_data.get("success"):
+        async with async_session() as db:
+            report = BugReport(
+                description=description,
+                reporter_email=reporter_email,
+                status="failed",
+                error_message=relay_data.get("message", "Relay returned failure"),
+            )
+            db.add(report)
+            await db.commit()
+
+        return {
+            "success": False,
+            "message": relay_data.get("message", "Failed to create bug report."),
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    issue_number = relay_data["issue_number"]
+    issue_url = relay_data["issue_url"]
+
+    # Save to DB
+    async with async_session() as db:
+        report = BugReport(
+            description=description,
+            reporter_email=reporter_email,
+            github_issue_number=issue_number,
+            github_issue_url=issue_url,
+            status="submitted",
+            email_sent=True,
+        )
+        db.add(report)
+        await db.commit()
+
+    return {
+        "success": True,
+        "message": "Bug report submitted successfully!",
+        "issue_url": issue_url,
+        "issue_number": issue_number,
+    }

+ 122 - 1
backend/app/services/camera.py

@@ -100,6 +100,119 @@ def get_camera_port(model: str | None) -> int:
     return 6000
 
 
+def rewrite_rtsp_request_url(data: bytes, proxy_url: bytes, real_url: bytes) -> bytes:
+    """Rewrite RTSP request-line URLs, leaving other lines (e.g. Authorization) intact.
+
+    RTSP request lines have the form ``METHOD <url> RTSP/1.0\\r\\n``.
+    Only those lines are modified so that Digest auth headers (which embed
+    the original URL and a cryptographic hash) are not broken.
+    """
+    rtsp_marker = b" RTSP/1.0"
+    if rtsp_marker not in data:
+        return data
+    lines = data.split(b"\r\n")
+    for i, line in enumerate(lines):
+        if line.endswith(rtsp_marker):
+            lines[i] = line.replace(proxy_url, real_url)
+            break
+    return b"\r\n".join(lines)
+
+
+async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "asyncio.Server"]:
+    """Create a local TCP→TLS proxy for RTSP streams.
+
+    Bambu printers use RTSPS (RTSP over TLS) with self-signed certificates.
+    The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
+    certain TLS behaviors (renegotiation, legacy ciphers) that some printer
+    firmwares (notably P2S) rely on.  This causes streams to drop after a
+    few seconds.
+
+    This proxy terminates TLS using Python's ssl module (OpenSSL), which is
+    more permissive, and exposes a plain TCP port that ffmpeg connects to
+    with ``rtsp://`` instead of ``rtsps://``.
+
+    RTSP embeds URLs in protocol messages (DESCRIBE, SETUP, PLAY).  The proxy
+    rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in
+    client→server data so the printer recognises the stream path.
+
+    Returns ``(local_port, server)``.  Caller must close the server when done.
+    """
+    ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    ssl_ctx.check_hostname = False
+    ssl_ctx.verify_mode = ssl.CERT_NONE
+
+    # Filled in after the server socket is created (handler only runs after).
+    _local_port: list[int] = [0]
+
+    async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
+        tls_writer = None
+        try:
+            tls_reader, tls_writer = await asyncio.wait_for(
+                asyncio.open_connection(target_host, target_port, ssl=ssl_ctx),
+                timeout=10.0,
+            )
+
+            # URL patterns for RTSP request-line rewriting.
+            proxy_url = f"rtsp://127.0.0.1:{_local_port[0]}".encode()
+            real_url = f"rtsps://{target_host}:{target_port}".encode()
+
+            async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
+                """Forward client→server, rewriting RTSP request-line URLs only."""
+                try:
+                    while True:
+                        data = await src.read(65536)
+                        if not data:
+                            break
+                        data = rewrite_rtsp_request_url(data, proxy_url, real_url)
+                        dst.write(data)
+                        await dst.drain()
+                except (ConnectionError, OSError, asyncio.CancelledError):
+                    pass
+                finally:
+                    if not dst.is_closing():
+                        try:
+                            dst.close()
+                        except OSError:
+                            pass
+
+            async def _fwd_to_client(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
+                """Forward server→client unchanged."""
+                try:
+                    while True:
+                        data = await src.read(65536)
+                        if not data:
+                            break
+                        dst.write(data)
+                        await dst.drain()
+                except (ConnectionError, OSError, asyncio.CancelledError):
+                    pass
+                finally:
+                    if not dst.is_closing():
+                        try:
+                            dst.close()
+                        except OSError:
+                            pass
+
+            await asyncio.gather(
+                _fwd_to_server(client_reader, tls_writer),
+                _fwd_to_client(tls_reader, client_writer),
+            )
+        except (ConnectionError, OSError, TimeoutError) as e:
+            logger.debug("TLS proxy connection to %s:%s failed: %s", target_host, target_port, e)
+        finally:
+            for w in (client_writer, tls_writer):
+                if w and not w.is_closing():
+                    try:
+                        w.close()
+                    except OSError:
+                        pass
+
+    server = await asyncio.start_server(_handle, "127.0.0.1", 0)
+    _local_port[0] = server.sockets[0].getsockname()[1]
+    logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
+    return _local_port[0], server
+
+
 def is_chamber_image_model(model: str | None) -> bool:
     """Check if printer uses chamber image protocol instead of RTSP.
 
@@ -357,10 +470,15 @@ async def capture_camera_frame_bytes(
         return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
 
     # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
-    camera_url = build_camera_url(ip_address, access_code, model)
+    # TLS proxy avoids GnuTLS compatibility issues with some printer firmwares
+    port = get_camera_port(model)
+    proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
+    camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
 
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
+        proxy_server.close()
+        await proxy_server.wait_closed()
         logger.error("ffmpeg not found for camera frame capture")
         return None
 
@@ -415,6 +533,9 @@ async def capture_camera_frame_bytes(
     except Exception as e:
         logger.exception("Camera frame bytes capture failed: %s", e)
         return None
+    finally:
+        proxy_server.close()
+        await proxy_server.wait_closed()
 
 
 async def capture_finish_photo(

+ 85 - 11
backend/app/services/external_camera.py

@@ -336,20 +336,46 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
 
 
 async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
-    """Capture frame from RTSP using ffmpeg."""
+    """Capture frame from RTSP using ffmpeg.
+
+    For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
+    """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         return None
 
-    # Use ffmpeg to grab a single frame from RTSP stream
-    # ffmpeg handles both rtsp:// and rtsps:// URLs automatically
+    # If rtsps://, use TLS proxy
+    proxy_server = None
+    effective_url = url
+    if url.lower().startswith("rtsps://"):
+        try:
+            from urllib.parse import urlparse
+
+            from backend.app.services.camera import create_tls_proxy
+
+            parsed = urlparse(url)
+            target_port = parsed.port or 322
+            proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
+            userinfo = ""
+            if parsed.username:
+                userinfo = parsed.username
+                if parsed.password:
+                    userinfo += f":{parsed.password}"
+                userinfo += "@"
+            effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
+            if parsed.query:
+                effective_url += f"?{parsed.query}"
+        except Exception as e:
+            logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
+            effective_url = url
+
     cmd = [
         ffmpeg,
         "-rtsp_transport",
         "tcp",
         "-i",
-        url,
+        effective_url,
         "-frames:v",
         "1",
         "-f",
@@ -362,7 +388,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     ]
 
     try:
-        logger.debug(f"Running ffmpeg command: {' '.join(cmd[:6])}...")
+        logger.debug("Running ffmpeg RTSP capture...")
         process = await asyncio.create_subprocess_exec(
             *cmd,
             stdout=asyncio.subprocess.PIPE,
@@ -371,7 +397,10 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
 
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         logger.debug(
-            f"ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
+            "ffmpeg returned: code=%s, stdout=%s bytes, stderr=%s bytes",
+            process.returncode,
+            len(stdout),
+            len(stderr),
         )
 
         if process.returncode != 0:
@@ -392,6 +421,10 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     except OSError as e:
         logger.error("RTSP frame capture failed: %s", e)
         return None
+    finally:
+        if proxy_server:
+            proxy_server.close()
+            await proxy_server.wait_closed()
 
 
 async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
@@ -605,13 +638,43 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
 
 
 async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
-    """Stream frames from RTSP URL via ffmpeg."""
+    """Stream frames from RTSP URL via ffmpeg.
+
+    For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
+    of relying on ffmpeg's GnuTLS backend, which has compatibility issues
+    with some printer firmwares.
+    """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP streaming")
         return
 
-    # ffmpeg handles both rtsp:// and rtsps:// URLs automatically
+    # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
+    proxy_server = None
+    effective_url = url
+    if url.lower().startswith("rtsps://"):
+        try:
+            from urllib.parse import urlparse
+
+            from backend.app.services.camera import create_tls_proxy
+
+            parsed = urlparse(url)
+            target_port = parsed.port or 322
+            proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
+            # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
+            userinfo = ""
+            if parsed.username:
+                userinfo = parsed.username
+                if parsed.password:
+                    userinfo += f":{parsed.password}"
+                userinfo += "@"
+            effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
+            if parsed.query:
+                effective_url += f"?{parsed.query}"
+        except Exception as e:
+            logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
+            effective_url = url
+
     cmd = [
         ffmpeg,
         "-rtsp_transport",
@@ -624,8 +687,16 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         "1024000",
         "-max_delay",
         "500000",
+        "-probesize",
+        "32",
+        "-analyzeduration",
+        "0",
+        "-fflags",
+        "nobuffer",
+        "-flags",
+        "low_delay",
         "-i",
-        url,
+        effective_url,
         "-f",
         "mjpeg",
         "-q:v",
@@ -644,8 +715,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             stderr=asyncio.subprocess.PIPE,
         )
 
-        # Give ffmpeg a moment to start and check for immediate failures
-        await asyncio.sleep(0.5)
+        # Brief check for immediate startup failures
+        await asyncio.sleep(0.1)
         if process.returncode is not None:
             stderr = await process.stderr.read()
             logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
@@ -698,6 +769,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             except TimeoutError:
                 process.kill()
                 await process.wait()
+        if proxy_server:
+            proxy_server.close()
+            await proxy_server.wait_closed()
 
 
 async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:

+ 34 - 14
backend/app/services/failure_analysis.py

@@ -1,5 +1,5 @@
 from collections import defaultdict
-from datetime import datetime, timedelta, timezone
+from datetime import date, datetime, time, timedelta, timezone
 
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,28 +16,47 @@ class FailureAnalysisService:
 
     async def analyze_failures(
         self,
-        days: int = 30,
+        days: int | None = None,
+        date_from: date | None = None,
+        date_to: date | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
     ) -> dict:
         """Analyze failure patterns across archives.
 
         Args:
-            days: Number of days to analyze
+            days: Number of days to analyze (fallback when no date range)
+            date_from: Start date filter (inclusive)
+            date_to: End date filter (inclusive)
             printer_id: Optional filter by printer
             project_id: Optional filter by project
 
         Returns:
             Dictionary with failure analysis results
         """
-        cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
-
-        # Build base query
-        base_filter = [PrintArchive.created_at >= cutoff_date]
+        # Build base query — separate date vs non-date filters for trend reuse
+        base_filter = []
+        non_date_filter = []
+        if date_from or date_to:
+            if date_from:
+                dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+                base_filter.append(PrintArchive.created_at >= dt_from)
+            if date_to:
+                dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+                base_filter.append(PrintArchive.created_at <= dt_to)
+            # Compute effective span for trend
+            range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
+            range_end = dt_to if date_to else datetime.now(timezone.utc)
+            effective_days = max((range_end - range_start).days, 1)
+        else:
+            effective_days = days if days is not None else 30
+            cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
+            base_filter.append(PrintArchive.created_at >= cutoff_date)
         if printer_id:
-            base_filter.append(PrintArchive.printer_id == printer_id)
+            non_date_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:
-            base_filter.append(PrintArchive.project_id == project_id)
+            non_date_filter.append(PrintArchive.project_id == project_id)
+        base_filter.extend(non_date_filter)
 
         # Total counts
         total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
@@ -141,15 +160,16 @@ class FailureAnalysisService:
 
         # Failure rate trend (by week)
         trend_data = []
-        for i in range(min(days // 7, 12)):  # Up to 12 weeks
+        num_weeks = max(effective_days // 7, 1)
+        for i in range(num_weeks):
             week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_start = week_end - timedelta(weeks=1)
 
-            week_filter = base_filter.copy()
-            week_filter[0] = and_(
+            week_filter = [
                 PrintArchive.created_at >= week_start,
                 PrintArchive.created_at < week_end,
-            )
+                *non_date_filter,
+            ]
 
             week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
@@ -174,7 +194,7 @@ class FailureAnalysisService:
         trend_data.reverse()  # Oldest first
 
         return {
-            "period_days": days,
+            "period_days": effective_days,
             "total_prints": total_prints,
             "failed_prints": failed_prints,
             "failure_rate": round(failure_rate, 1),

+ 16 - 0
backend/app/services/firmware_check.py

@@ -49,6 +49,22 @@ MODEL_TO_API_KEY = {
     "H2D Pro": "h2d-pro",
     "H2D-Pro": "h2d-pro",
     "H2DPRO": "h2d-pro",
+    # SSDP model codes (DevModel header) — in case raw codes are stored
+    "O1D": "h2d",
+    "O1E": "h2d-pro",
+    "O2D": "h2d-pro",
+    "O1C": "h2c",
+    "O1C2": "h2c",
+    "O1S": "h2s",
+    "BL-P001": "x1",
+    "BL-P002": "x1",
+    "BL-P003": "x1e",
+    "C11": "p1",
+    "C12": "p1",
+    "C13": "p2s",
+    "N2S": "a1",
+    "N1": "a1-mini",
+    "N7": "p2s",
 }
 
 # Reverse mapping: API key to model codes

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

@@ -11,11 +11,11 @@ Orchestrates firmware updates for Bambu Lab printers:
 import asyncio
 import logging
 from dataclasses import dataclass
-from enum import StrEnum
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.compat import StrEnum
 from backend.app.core.websocket import ws_manager
 from backend.app.models.printer import Printer
 from backend.app.services.bambu_ftp import (

+ 106 - 1
backend/app/services/notification_service.py

@@ -5,7 +5,7 @@ import json
 import logging
 import re
 import smtplib
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -93,6 +93,21 @@ class NotificationService:
         result = re.sub(r"\{[a-z_]+\}", "", result)
         return result
 
+    async def _format_eta(self, seconds: int | None, db: AsyncSession) -> str:
+        """Format ETA as wall-clock time, respecting user's time_format setting."""
+        if not seconds or seconds <= 0:
+            return "Unknown"
+
+        from backend.app.api.routes.settings import get_setting
+
+        eta_time = datetime.now() + timedelta(seconds=seconds)
+        time_format = await get_setting(db, "time_format")
+
+        if time_format == "12h":
+            return eta_time.strftime("%I:%M %p").lstrip("0")
+        # Default to 24h for "24h", "system", or unset
+        return eta_time.strftime("%H:%M")
+
     def _format_duration(self, seconds: int | None) -> str:
         """Format duration in seconds to human-readable string."""
         if seconds is None:
@@ -162,6 +177,8 @@ class NotificationService:
                 return await self._send_discord(config, title, message)
             elif provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
+            elif provider_type == "homeassistant":
+                return await self._send_homeassistant(config, title, message, db=db)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
@@ -456,6 +473,59 @@ class NotificationService:
         except Exception as e:
             return False, f"Webhook error: {str(e)}"
 
+    async def _send_homeassistant(
+        self, config: dict, title: str, message: str, db: AsyncSession | None = None
+    ) -> tuple[bool, str]:
+        """Send notification via Home Assistant persistent notifications.
+
+        Uses the globally configured HA URL/token from settings,
+        and calls POST /api/services/persistent_notification/create.
+        """
+        # Get HA connection settings from global config
+        ha_url = ""
+        ha_token = ""
+
+        if db:
+            from backend.app.api.routes.settings import get_homeassistant_settings
+
+            try:
+                ha_settings = await get_homeassistant_settings(db)
+                ha_url = ha_settings.get("ha_url", "")
+                ha_token = ha_settings.get("ha_token", "")
+            except Exception as e:
+                logger.warning("Failed to read HA settings from database: %s", e)
+        else:
+            # Fallback: read directly from environment if no DB session
+            import os
+
+            ha_url = os.environ.get("HA_URL", "")
+            ha_token = os.environ.get("HA_TOKEN", "")
+
+        if not ha_url or not ha_token:
+            return False, (
+                "Home Assistant is not configured. Please set HA URL and token in Settings → Network → Home Assistant."
+            )
+
+        url = f"{ha_url.rstrip('/')}/api/services/persistent_notification/create"
+        headers = {
+            "Authorization": f"Bearer {ha_token}",
+            "Content-Type": "application/json",
+        }
+        payload = {
+            "title": title,
+            "message": message,
+        }
+
+        client = await self._get_client()
+        response = await client.post(url, json=payload, headers=headers)
+
+        if response.status_code in (200, 201):
+            return True, "Notification sent via Home Assistant"
+        elif response.status_code == 401:
+            return False, "Home Assistant authentication failed - check your token"
+        else:
+            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
     async def _send_to_provider(
         self,
         provider: NotificationProvider,
@@ -487,6 +557,8 @@ class NotificationService:
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
+            elif provider.provider_type == "homeassistant":
+                return await self._send_homeassistant(config, title, message, db=db)
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
@@ -678,11 +750,13 @@ class NotificationService:
                 logger.debug("Using mc_remaining_time from raw_data: %s", estimated_time)
 
         time_str = self._format_duration(estimated_time)
+        eta_str = await self._format_eta(estimated_time, db)
 
         variables = {
             "printer": printer_name,
             "filename": filename,
             "estimated_time": time_str,
+            "eta": eta_str,
         }
 
         # Extract image data for providers that support attachments (e.g. Pushover)
@@ -805,11 +879,14 @@ class NotificationService:
         if not providers:
             return
 
+        eta_str = await self._format_eta(remaining_time, db)
+
         variables = {
             "printer": printer_name,
             "filename": self._clean_filename(filename),
             "progress": str(progress),
             "remaining_time": self._format_duration(remaining_time) if remaining_time else "Unknown",
+            "eta": eta_str,
         }
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
@@ -1065,6 +1142,31 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "bed_cooled", variables)
         await self._send_to_providers(providers, title, message, db, "bed_cooled", printer_id, printer_name)
 
+    async def on_first_layer_complete(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        total_layers: int,
+        db: AsyncSession,
+        image_data: bytes | None = None,
+    ):
+        """Handle first layer complete event."""
+        providers = await self._get_providers_for_event(db, "on_first_layer_complete", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "total_layers": str(total_layers),
+        }
+
+        title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
+        await self._send_to_providers(
+            providers, title, message, db, "first_layer_complete", printer_id, printer_name, image_data=image_data
+        )
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
@@ -1128,10 +1230,13 @@ class NotificationService:
         if not providers:
             return
 
+        eta_str = await self._format_eta(estimated_time, db)
+
         variables = {
             "job_name": job_name,
             "printer": printer_name,
             "estimated_time": self._format_duration(estimated_time),
+            "eta": eta_str,
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_started", variables)

+ 103 - 0
backend/app/services/opentag3d.py

@@ -0,0 +1,103 @@
+"""OpenTag3D NDEF encoder for NTAG tags.
+
+Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
+starting at page 4 (after the manufacturer pages).
+
+NDEF structure:
+  [CC: E1 10 12 00]              - Capability Container (4 bytes, page 4)
+  [TLV: 03 len]                  - NDEF Message TLV (2 bytes)
+  [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
+  [Type: "application/opentag3d"] - 21 bytes
+  [Payload: OpenTag3D fields]    - 102 bytes
+  [Terminator: FE]               - 1 byte
+"""
+
+import struct
+
+from backend.app.models.spool import Spool
+
+OPENTAG3D_MIME_TYPE = b"application/opentag3d"
+PAYLOAD_SIZE = 102
+TAG_VERSION = 1000  # v1.000
+
+
+def _build_payload(spool: Spool) -> bytes:
+    """Build 102-byte OpenTag3D core payload from spool fields."""
+    buf = bytearray(PAYLOAD_SIZE)
+
+    # 0x00: Tag Version (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x00, TAG_VERSION)
+
+    # 0x02: Base Material (5 bytes, UTF-8, space-padded)
+    material = (spool.material or "")[:5].ljust(5)
+    buf[0x02:0x07] = material.encode("utf-8")[:5]
+
+    # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
+    modifiers = (spool.subtype or "")[:5].ljust(5)
+    buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
+
+    # 0x0C: Reserved (15 bytes, zero-fill) — already zero
+
+    # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
+    brand = (spool.brand or "")[:16].ljust(16)
+    buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
+
+    # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
+    color_name = (spool.color_name or "")[:32].ljust(32)
+    buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
+
+    # 0x4B: Color 1 RGBA (4 bytes)
+    rgba_hex = spool.rgba or "00000000"
+    try:
+        rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
+    except ValueError:
+        rgba_bytes = b"\x00\x00\x00\x00"
+    buf[0x4B:0x4F] = rgba_bytes[:4]
+
+    # 0x4F: Colors 2-4 (12 bytes, zero-fill) — already zero
+
+    # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
+    struct.pack_into(">H", buf, 0x5C, 1750)
+
+    # 0x5E: Target Weight (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0)
+
+    # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5
+    buf[0x60] = (spool.nozzle_temp_min or 0) // 5
+
+    # 0x61: Bed Temp (1 byte) — not tracked
+    # 0x62: Density (2 bytes) — not tracked
+    # 0x64: Transmission Distance (2 bytes) — not tracked
+    # All zero — already zero
+
+    return bytes(buf)
+
+
+def encode_opentag3d(spool: Spool) -> bytes:
+    """Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator).
+
+    Returns raw bytes ready to write to NTAG starting at page 4.
+    """
+    payload = _build_payload(spool)
+    mime_type = OPENTAG3D_MIME_TYPE
+
+    # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
+    # Type length = 21
+    # Payload length = 102 (fits in SR single byte)
+    record_header = bytes([0xD2, len(mime_type), len(payload)])
+    ndef_record = record_header + mime_type + payload
+
+    # TLV: type=0x03 (NDEF Message), length
+    ndef_len = len(ndef_record)
+    if ndef_len < 0xFF:
+        tlv = bytes([0x03, ndef_len])
+    else:
+        tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
+
+    # Capability Container (page 4)
+    cc = bytes([0xE1, 0x10, 0x12, 0x00])
+
+    # Terminator TLV
+    terminator = bytes([0xFE])
+
+    return cc + tlv + ndef_record + terminator

+ 14 - 1
backend/app/services/orca_profiles.py

@@ -230,10 +230,23 @@ MATERIAL_TYPES = [
 
 
 def _parse_material_from_name(name: str) -> str | None:
-    """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'."""
+    """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'.
+
+    Handles 'X Support for Y' patterns where the filament type is Y, not X.
+    e.g. 'PLA Support for PETG PETG Basic @Bambu Lab H2D' -> 'PETG'.
+    """
     import re
 
     upper = name.upper()
+
+    # Handle "X Support for Y" pattern: the filament type is Y, not X.
+    support_match = re.search(r"\bSUPPORT\s+FOR\s+", upper)
+    if support_match:
+        after_support = upper[support_match.end() :]
+        for mat in MATERIAL_TYPES:
+            if re.search(rf"\b{mat}\b", after_support):
+                return mat
+
     for mat in MATERIAL_TYPES:
         if re.search(rf"\b{mat}\b", upper):
             return mat

+ 513 - 20
backend/app/services/print_scheduler.py

@@ -3,6 +3,7 @@
 import asyncio
 import json
 import logging
+import time
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
@@ -17,25 +18,59 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.notification_service import notification_service
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
+# Filament type equivalence groups — types within the same group are
+# interchangeable on the printer side (Bambu Lab firmware treats them as compatible).
+_FILAMENT_TYPE_GROUPS: list[list[str]] = [
+    ["PA-CF", "PA12-CF", "PAHT-CF"],
+]
+_FILAMENT_EQUIV_MAP: dict[str, str] = {}
+for _group in _FILAMENT_TYPE_GROUPS:
+    _canonical = _group[0].upper()
+    for _t in _group:
+        _FILAMENT_EQUIV_MAP[_t.upper()] = _canonical
+
+
+def _canonical_filament_type(ftype: str) -> str:
+    """Return canonical type for equivalence matching."""
+    upper = ftype.upper()
+    return _FILAMENT_EQUIV_MAP.get(upper, upper)
+
 
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
 
+    # Built-in drying presets per filament type (from BambuStudio filament profiles)
+    # Format: { n3f_temp, n3s_temp, n3f_hours, n3s_hours }
+    DEFAULT_DRYING_PRESETS: dict[str, dict[str, int]] = {
+        "PLA": {"n3f": 45, "n3s": 45, "n3f_hours": 12, "n3s_hours": 12},
+        "PETG": {"n3f": 65, "n3s": 65, "n3f_hours": 12, "n3s_hours": 12},
+        "TPU": {"n3f": 65, "n3s": 75, "n3f_hours": 12, "n3s_hours": 18},
+        "ABS": {"n3f": 65, "n3s": 80, "n3f_hours": 12, "n3s_hours": 8},
+        "ASA": {"n3f": 65, "n3s": 80, "n3f_hours": 12, "n3s_hours": 8},
+        "PA": {"n3f": 65, "n3s": 85, "n3f_hours": 12, "n3s_hours": 12},
+        "PC": {"n3f": 65, "n3s": 80, "n3f_hours": 12, "n3s_hours": 8},
+        "PVA": {"n3f": 65, "n3s": 85, "n3f_hours": 12, "n3s_hours": 18},
+    }
+
     def __init__(self):
         self._running = False
         self._check_interval = 30  # seconds
         self._power_on_wait_time = 180  # seconds to wait for printer after power on (3 min)
         self._power_on_check_interval = 10  # seconds between connection checks
+        self._min_drying_seconds = 1800  # 30 minutes minimum before humidity re-check can stop drying
+        # Track which printers are currently auto-drying (printer_id -> start timestamp)
+        self._drying_in_progress: dict[int, float] = {}
 
     async def run(self):
         """Main loop - check queue every interval."""
@@ -67,6 +102,8 @@ class PrintScheduler:
             items = list(result.scalars().all())
 
             if not items:
+                # No pending items — still check auto-drying on idle printers
+                await self._check_auto_drying(db, [], set())
                 return
 
             logger.info(
@@ -78,6 +115,9 @@ class PrintScheduler:
             # Track busy printers to avoid assigning multiple items to same printer
             busy_printers: set[int] = set()
 
+            # Log skip reasons once per queue check (not per item)
+            skip_reasons: dict[str, int] = {}
+
             for item in items:
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time:
@@ -85,10 +125,12 @@ class PrintScheduler:
                     if sched.tzinfo is None:
                         sched = sched.replace(tzinfo=timezone.utc)
                     if sched > datetime.now(timezone.utc):
+                        skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
                         continue
 
                 # Skip items that require manual start
                 if item.manual_start:
+                    skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
                     continue
 
                 if item.printer_id:
@@ -120,8 +162,24 @@ class PrintScheduler:
 
                     # Check if printer is idle (busy with another print)
                     if not printer_idle:
-                        busy_printers.add(item.printer_id)
-                        continue
+                        # If printer is drying (not truly busy), handle based on queue_drying_block
+                        if self._drying_in_progress.get(item.printer_id):
+                            block_for_drying = await self._get_bool_setting(db, "queue_drying_block")
+                            if block_for_drying:
+                                # Drying blocks queue — skip this printer
+                                busy_printers.add(item.printer_id)
+                                continue
+                            else:
+                                # Print takes priority — stop drying
+                                await self._stop_drying(item.printer_id)
+                                # Re-check idle after stopping drying
+                                printer_idle = self._is_printer_idle(item.printer_id)
+                                if not printer_idle:
+                                    busy_printers.add(item.printer_id)
+                                    continue
+                        else:
+                            busy_printers.add(item.printer_id)
+                            continue
 
                     # Check condition (previous print success)
                     if item.require_previous_success:
@@ -261,6 +319,27 @@ class PrintScheduler:
                         await self._start_print(db, item)
                         busy_printers.add(printer_id)
 
+            # Log summary of skip reasons (helps diagnose why queue items aren't starting)
+            if skip_reasons:
+                logger.info("Queue skip summary: %s", skip_reasons)
+            if busy_printers:
+                # Log why each printer was busy (first time it was checked)
+                for pid in busy_printers:
+                    state = printer_manager.get_status(pid)
+                    connected = printer_manager.is_connected(pid)
+                    plate_cleared = printer_manager.is_plate_cleared(pid)
+                    state_name = state.state if state else "NO_STATUS"
+                    logger.info(
+                        "Queue: printer %d not available — connected=%s, state=%s, plate_cleared=%s",
+                        pid,
+                        connected,
+                        state_name,
+                        plate_cleared,
+                    )
+
+            # Auto-drying: start drying on idle printers that have no pending queue items
+            await self._check_auto_drying(db, items, busy_printers)
+
     async def _find_idle_printer_for_model(
         self,
         db: AsyncSession,
@@ -279,6 +358,10 @@ class PrintScheduler:
             required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
                                      If provided, only printers with all required types loaded will match.
             target_location: Optional location filter. If provided, only printers in this location are considered.
+            filament_overrides: Optional list of override dicts. Each entry may include
+                                 ``force_color_match: true`` to require an exact type+color match
+                                 on the printer for that slot. Without the flag the existing
+                                 colour-preference logic applies.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
@@ -304,14 +387,27 @@ class PrintScheduler:
         if not printers:
             return None, f"No active {normalized_model} printers{location_suffix} configured"
 
+        # Separate force-matched overrides from preference-only overrides
+        force_overrides = [o for o in (filament_overrides or []) if o.get("force_color_match")]
+        pref_overrides = [o for o in (filament_overrides or []) if not o.get("force_color_match")]
+
         # Track reasons for skipping printers
         printers_busy = []
         printers_offline = []
-        printers_missing_filament = []
+        printers_missing_filament: list[tuple[str, list[str]]] = []
         candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)
 
         for printer in printers:
             if printer.id in exclude_ids:
+                # Printer is already claimed by another job in this scheduling run.
+                # For force-color jobs, still check if the color would match — if not,
+                # report it as a color mismatch rather than plain "Busy" so the user
+                # knows the job needs a filament change, not just to wait for availability.
+                if force_overrides and not pref_overrides:
+                    missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)
+                    if missing_colors:
+                        printers_missing_filament.append((printer.name, missing_colors))
+                        continue
                 printers_busy.append(printer.name)
                 continue
 
@@ -323,6 +419,21 @@ class PrintScheduler:
                 continue
 
             if not is_idle:
+                # Printer is currently printing.  For force-color jobs, check whether the
+                # loaded color would satisfy the requirement — if not, surface it as a
+                # color-mismatch reason rather than plain "Busy" so the user understands
+                # that the job is waiting for a filament change, not just printer availability.
+                if force_overrides and not pref_overrides:
+                    missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)
+                    if missing_colors:
+                        printers_missing_filament.append((printer.name, missing_colors))
+                        logger.debug(
+                            "Printer %s (%s) is busy but also has wrong force-color: %s",
+                            printer.id,
+                            printer.name,
+                            missing_colors,
+                        )
+                        continue
                 printers_busy.append(printer.name)
                 continue
 
@@ -330,25 +441,54 @@ class PrintScheduler:
             if required_filament_types:
                 missing = self._get_missing_filament_types(printer.id, required_filament_types)
                 if missing:
-                    printers_missing_filament.append((printer.name, missing))
+                    # When force_overrides are present, enrich missing entries with color info
+                    # so the "Waiting on" message includes "TYPE (color)" instead of just "TYPE"
+                    if force_overrides:
+                        force_color_map = {
+                            (o.get("type") or "").upper(): o.get("color_name") or o.get("color", "?")
+                            for o in force_overrides
+                        }
+                        missing_enriched = [
+                            f"{t} ({force_color_map[t_upper]})" if (t_upper := t.upper()) in force_color_map else t
+                            for t in missing
+                        ]
+                        printers_missing_filament.append((printer.name, missing_enriched))
+                    else:
+                        printers_missing_filament.append((printer.name, missing))
                     logger.debug("Skipping printer %s (%s) - missing filaments: %s", printer.id, printer.name, missing)
                     continue
 
-            # If filament overrides with colors, only consider printers that have at least one color match
-            if filament_overrides:
-                color_matches = self._count_override_color_matches(printer.id, filament_overrides)
+            # Force color match: ALL flagged slots must have an exact type+color match
+            if force_overrides:
+                missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)
+                if missing_colors:
+                    printers_missing_filament.append((printer.name, missing_colors))
+                    logger.debug(
+                        "Skipping printer %s (%s) - missing force-matched colors: %s",
+                        printer.id,
+                        printer.name,
+                        missing_colors,
+                    )
+                    continue
+
+            # If preference-only overrides exist, rank by color matches (existing behaviour)
+            if pref_overrides:
+                color_matches = self._count_override_color_matches(printer.id, pref_overrides)
                 if color_matches > 0:
                     candidates.append((printer.id, color_matches))
                 else:
-                    override_colors = [f"{o.get('type', '?')} ({o.get('color', '?')})" for o in filament_overrides]
+                    override_colors = [f"{o.get('type', '?')} ({o.get('color', '?')})" for o in pref_overrides]
                     printers_missing_filament.append((printer.name, override_colors))
                     logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
                     continue
+            elif force_overrides:
+                # Passed all force checks — immediately eligible (no preference ordering needed)
+                return printer.id, None
             else:
-                # No overrides - take first available (existing behavior)
+                # No overrides at all - take first available (existing behavior)
                 return printer.id, None
 
-        # If we have candidates from override matching, pick the one with most color matches
+        # If we have candidates from preference override matching, pick the one with most color matches
         if candidates:
             candidates.sort(key=lambda c: c[1], reverse=True)
             return candidates[0][0], None
@@ -356,9 +496,21 @@ class PrintScheduler:
         # Build waiting reason from what we found
         reasons = []
         if printers_missing_filament:
-            # Filament mismatch is most actionable - show first
-            names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
-            reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
+            # Filament/color mismatch is most actionable - show first
+            if force_overrides and not pref_overrides:
+                # All mismatches are force-color failures — use descriptive message only;
+                # but only if there are no busy printers that DO have the matching color.
+                # If a printer has the right color but is busy, surface "Busy" instead so
+                # the user knows the job will start automatically once that printer is free.
+                if not printers_busy:
+                    all_missing = sorted({c for _, cols in printers_missing_filament for c in cols})
+                    return None, f"No matching material/color. Waiting on {', '.join(all_missing)}"
+                # else: fall through — printers_busy will be appended below
+            else:
+                names_and_missing = [
+                    f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament
+                ]
+                reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
         if printers_busy:
             reasons.append(f"Busy: {', '.join(printers_busy)}")
         if printers_offline:
@@ -366,6 +518,44 @@ class PrintScheduler:
 
         return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 
+    def _get_missing_force_color_slots(self, printer_id: int, force_overrides: list[dict]) -> list[str]:
+        """Return descriptive strings for force_color_match slots not satisfied by the printer.
+
+        Each entry in ``force_overrides`` must have ``type`` and ``color`` fields and is expected
+        to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
+        with an exact type+color match.
+
+        Returns:
+            List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
+        """
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
+
+        # Build set of loaded type+colour pairs from AMS and external spool
+        loaded: set[tuple[str, str]] = set()
+        for ams_unit in status.raw_data.get("ams", []):
+            for tray in ams_unit.get("tray", []):
+                tray_type = tray.get("tray_type")
+                tray_color = tray.get("tray_color", "")
+                if tray_type:
+                    color_norm = tray_color.replace("#", "").lower()[:6]
+                    loaded.add((_canonical_filament_type(tray_type), color_norm))
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
+            if vt_type:
+                color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                loaded.add((_canonical_filament_type(vt_type), color_norm))
+
+        missing = []
+        for o in force_overrides:
+            o_type = _canonical_filament_type(o.get("type") or "")
+            o_color = (o.get("color") or "").replace("#", "").lower()[:6]
+            if (o_type, o_color) not in loaded:
+                color_label = o.get("color_name") or o.get("color", "?")
+                missing.append(f"{o_type} ({color_label})")
+        return missing
+
     def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
         """Get the list of required filament types that are not loaded on the printer.
 
@@ -381,6 +571,7 @@ class PrintScheduler:
             return required_types  # Can't determine, assume all missing
 
         # Collect all filament types loaded on this printer (AMS units + external spool)
+        # Use canonical types so equivalence groups (e.g. PA-CF/PA12-CF/PAHT-CF) match.
         loaded_types: set[str] = set()
 
         # Check AMS units (stored in raw_data["ams"])
@@ -390,18 +581,18 @@ class PrintScheduler:
                 for tray in ams_unit.get("tray", []):
                     tray_type = tray.get("tray_type")
                     if tray_type:
-                        loaded_types.add(tray_type.upper())
+                        loaded_types.add(_canonical_filament_type(tray_type))
 
         # Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
         for vt in status.raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
-                loaded_types.add(vt_type.upper())
+                loaded_types.add(_canonical_filament_type(vt_type))
 
-        # Find which required types are missing (case-insensitive comparison)
+        # Find which required types are missing (using canonical type for equivalence)
         missing = []
         for req_type in required_types:
-            if req_type.upper() not in loaded_types:
+            if _canonical_filament_type(req_type) not in loaded_types:
                 missing.append(req_type)
 
         return missing
@@ -781,7 +972,7 @@ class PrintScheduler:
             if not idx_match and not exact_match and not similar_match and not type_only_match:
                 for f in available:
                     f_type = (f.get("type") or "").upper()
-                    if f_type != req_type:
+                    if _canonical_filament_type(f_type) != _canonical_filament_type(req_type):
                         continue
 
                     # Type matches - check color
@@ -821,17 +1012,319 @@ class PrintScheduler:
     def _is_printer_idle(self, printer_id: int) -> bool:
         """Check if a printer is connected and idle."""
         if not printer_manager.is_connected(printer_id):
+            logger.debug("Printer %d: not connected", printer_id)
             return False
 
         state = printer_manager.get_status(printer_id)
         if not state:
+            logger.debug("Printer %d: no status available", printer_id)
             return False
 
         # IDLE = ready for next print
         # FINISH/FAILED = ready only if user confirmed plate is cleared
-        return state.state == "IDLE" or (
+        idle = state.state == "IDLE" or (
             state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
         )
+        if not idle:
+            logger.debug(
+                "Printer %d: not idle — state=%s, plate_cleared=%s",
+                printer_id,
+                state.state,
+                printer_manager.is_plate_cleared(printer_id),
+            )
+        return idle
+
+    async def _get_bool_setting(self, db: AsyncSession, key: str, default: bool = False) -> bool:
+        """Read a boolean setting from the database."""
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        if setting:
+            return setting.value.lower() == "true"
+        return default
+
+    async def _get_drying_presets(self, db: AsyncSession) -> dict[str, dict[str, int]]:
+        """Get drying presets (user-configured or built-in defaults)."""
+        result = await db.execute(select(Settings).where(Settings.key == "drying_presets"))
+        setting = result.scalar_one_or_none()
+        if setting and setting.value:
+            try:
+                presets = json.loads(setting.value)
+                if isinstance(presets, dict) and presets:
+                    return presets
+            except json.JSONDecodeError:
+                pass
+        return self.DEFAULT_DRYING_PRESETS
+
+    def _get_conservative_drying_params(
+        self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]]
+    ) -> tuple[int, int, str] | None:
+        """Get the most conservative drying params for mixed filament types in an AMS unit.
+
+        Returns (temp, duration_hours, filament_type) or None if no drying-eligible filaments.
+        """
+        temp_key = module_type if module_type in ("n3f", "n3s") else "n3f"
+        hours_key = f"{temp_key}_hours"
+
+        min_temp = None
+        max_hours = None
+        filament_type = ""
+
+        for tray in trays:
+            tray_type = tray.get("tray_type", "")
+            if not tray_type:
+                continue
+            # Normalize filament type for preset lookup (e.g., "PLA Basic" -> "PLA")
+            base_type = tray_type.split()[0].upper()
+            preset = presets.get(base_type)
+            if not preset:
+                continue
+
+            temp = preset.get(temp_key, 55)
+            hours = preset.get(hours_key, 12)
+
+            # Conservative: lowest temp, longest duration
+            if min_temp is None or temp < min_temp:
+                min_temp = temp
+            if max_hours is None or hours > max_hours:
+                max_hours = hours
+            if not filament_type:
+                filament_type = base_type
+
+        if min_temp is None:
+            return None
+        return (min_temp, max_hours or 12, filament_type)
+
+    async def _check_auto_drying(self, db: AsyncSession, queue_items: list[PrintQueueItem], busy_printers: set[int]):
+        """Start drying on idle printers based on humidity.
+
+        Two modes (can both be enabled):
+        - queue_drying_enabled: Dry between scheduled queue prints
+        - ambient_drying_enabled: Dry any idle printer when humidity is high, regardless of queue
+        """
+        queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
+        ambient_drying_enabled = await self._get_bool_setting(db, "ambient_drying_enabled")
+        if not queue_drying_enabled and not ambient_drying_enabled:
+            # Stop active drying on all printers if both features disabled
+            if self._drying_in_progress:
+                for pid in list(self._drying_in_progress):
+                    logger.info("Auto-drying: printer %d — stopping, auto-drying disabled", pid)
+                    await self._stop_drying(pid)
+            return
+
+        # Update drying state from printer status (handles backend restart)
+        self._sync_drying_state()
+
+        # Find printers with scheduled items (for queue drying mode)
+        printers_with_scheduled: set[int] = set()
+        printers_with_items: set[int] = set()
+        for item in queue_items:
+            if item.printer_id:
+                printers_with_items.add(item.printer_id)
+                if item.scheduled_time and not item.manual_start:
+                    printers_with_scheduled.add(item.printer_id)
+
+        # If only queue mode is on and no printers have scheduled items, stop drying
+        if not ambient_drying_enabled and not printers_with_scheduled:
+            for pid in list(self._drying_in_progress):
+                logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
+                await self._stop_drying(pid)
+            return
+
+        # Get humidity threshold
+        result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
+        setting = result.scalar_one_or_none()
+        humidity_threshold = int(setting.value) if setting else 60
+
+        # Get drying presets
+        presets = await self._get_drying_presets(db)
+
+        # Determine if drying should be skipped for printers with pending items
+        block_for_drying = await self._get_bool_setting(db, "queue_drying_block")
+
+        # Get all active printers
+        all_printers = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
+        for printer in all_printers.scalars():
+            pid = printer.id
+            if pid in busy_printers:
+                logger.debug("Auto-drying: printer %d skipped — busy", pid)
+                continue
+            # In queue-only mode, only dry printers that have scheduled prints
+            if not ambient_drying_enabled and pid not in printers_with_scheduled:
+                if self._drying_in_progress.get(pid):
+                    logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
+                    await self._stop_drying(pid)
+                logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+                continue
+            # When block mode is on, don't START new drying on printers with pending items.
+            # But allow already-drying printers through so humidity auto-stop logic still runs.
+            if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
+                logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+                continue
+            if not printer_manager.is_connected(pid):
+                logger.debug("Auto-drying: printer %d skipped — not connected", pid)
+                continue
+            if not self._is_printer_idle(pid):
+                logger.debug("Auto-drying: printer %d skipped — not idle", pid)
+                continue
+
+            # Check if this printer supports drying
+            state = printer_manager.get_status(pid)
+            if not state:
+                logger.debug("Auto-drying: printer %d skipped — no state", pid)
+                continue
+            model = printer_manager.get_model(pid)
+            firmware = state.firmware_version
+            if not supports_drying(model, firmware):
+                logger.debug("Auto-drying: printer %d skipped — model %s does not support drying", pid, model)
+                continue
+
+            # Check each AMS unit from raw_data
+            ams_list = state.raw_data.get("ams", [])
+            logger.debug("Auto-drying: printer %d — checking %d AMS units", pid, len(ams_list))
+            for ams_data in ams_list:
+                module_type = str(ams_data.get("module_type") or "")
+                ams_id = int(ams_data.get("id", 0))
+                # Only n3f/n3s support drying
+                if module_type not in ("n3f", "n3s"):
+                    logger.debug("Auto-drying: printer %d AMS %d skipped — module_type=%s", pid, ams_id, module_type)
+                    continue
+
+                dry_time = int(ams_data.get("dry_time") or 0)
+
+                # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
+                humidity = None
+                h_raw = ams_data.get("humidity_raw")
+                if h_raw is not None:
+                    try:
+                        humidity = int(h_raw)
+                    except (ValueError, TypeError):
+                        pass
+                if humidity is None:
+                    h_idx = ams_data.get("humidity")
+                    if h_idx is not None:
+                        try:
+                            humidity = int(h_idx)
+                        except (ValueError, TypeError):
+                            pass
+                # Already drying — check if humidity dropped below threshold (with minimum drying time)
+                if dry_time > 0:
+                    if pid not in self._drying_in_progress:
+                        # Drying we didn't start (manual or from before restart) — track but don't stop
+                        self._drying_in_progress[pid] = time.monotonic()
+                    started_at = self._drying_in_progress[pid]
+                    elapsed = time.monotonic() - started_at
+                    if humidity is not None and humidity <= humidity_threshold and elapsed >= self._min_drying_seconds:
+                        logger.info(
+                            "Auto-drying: printer %d AMS %d — humidity %d%% <= threshold %d%% after %dm, stopping drying",
+                            pid,
+                            ams_id,
+                            humidity,
+                            humidity_threshold,
+                            int(elapsed / 60),
+                        )
+                        printer_manager.send_drying_command(pid, ams_id, temp=0, duration=0, mode=0)
+                    else:
+                        logger.debug(
+                            "Auto-drying: printer %d AMS %d — drying (%dm left, humidity %s%%, elapsed %dm/%dm min)",
+                            pid,
+                            ams_id,
+                            dry_time,
+                            humidity,
+                            int(elapsed / 60),
+                            self._min_drying_seconds // 60,
+                        )
+                    continue
+
+                # Humidity below threshold — no need to start drying
+                if humidity is None or humidity <= humidity_threshold:
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d",
+                        pid,
+                        ams_id,
+                        humidity,
+                        humidity_threshold,
+                    )
+                    continue
+
+                # Check cannot-dry reasons (power constraints etc.)
+                sf_reasons = ams_data.get("dry_sf_reason", [])
+                if sf_reasons:
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d skipped — cannot dry reasons: %s",
+                        pid,
+                        ams_id,
+                        sf_reasons,
+                    )
+                    continue
+
+                # Get conservative drying params for mixed filaments
+                trays = ams_data.get("tray", [])
+                params = self._get_conservative_drying_params(trays, module_type, presets)
+                if not params:
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d skipped — no drying-eligible filaments in trays", pid, ams_id
+                    )
+                    continue
+
+                temp, duration_hours, filament_type = params
+
+                # Start drying
+                logger.info(
+                    "Auto-drying: printer %d AMS %d — humidity %d%% > threshold %d%%, "
+                    "starting %s drying at %d°C for %dh",
+                    pid,
+                    ams_id,
+                    humidity,
+                    humidity_threshold,
+                    filament_type,
+                    temp,
+                    duration_hours,
+                )
+                success = printer_manager.send_drying_command(
+                    pid, ams_id, temp, duration_hours, mode=1, filament=filament_type
+                )
+                if success:
+                    self._drying_in_progress[pid] = time.monotonic()
+
+    def _sync_drying_state(self):
+        """Sync in-memory drying state with actual printer status.
+
+        Handles backend restart — if a printer is drying but we don't know about it,
+        update our state. If we think it's drying but it's not, clear it.
+        """
+        to_remove = []
+        for pid in self._drying_in_progress:
+            state = printer_manager.get_status(pid)
+            if not state:
+                to_remove.append(pid)
+                continue
+            # Check if any AMS unit is still drying
+            ams_list = state.raw_data.get("ams", [])
+            any_drying = any(int(a.get("dry_time") or 0) > 0 for a in ams_list)
+            if not any_drying:
+                to_remove.append(pid)
+        for pid in to_remove:
+            self._drying_in_progress.pop(pid, None)
+
+    async def _stop_drying(self, printer_id: int):
+        """Stop all active drying on a printer (print takes priority)."""
+        state = printer_manager.get_status(printer_id)
+        if not state:
+            self._drying_in_progress.pop(printer_id, None)
+            return
+
+        ams_list = state.raw_data.get("ams", [])
+        for ams_data in ams_list:
+            dry_time = int(ams_data.get("dry_time") or 0)
+            if dry_time > 0:
+                ams_id = int(ams_data.get("id", 0))
+                logger.info(
+                    "Auto-drying: stopping drying on printer %d AMS %d — print takes priority",
+                    printer_id,
+                    ams_id,
+                )
+                printer_manager.send_drying_command(printer_id, ams_id, 0, 0, mode=0)
+        self._drying_in_progress.pop(printer_id, None)
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""

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

@@ -82,6 +82,41 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
     return model_upper in A1_MODELS
 
 
+# Minimum firmware versions for AMS drying support (confirmed via capture testing)
+# Keys are exact model names (upper-cased). Do NOT use substring matching — it would
+# incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
+_DRYING_MIN_FIRMWARE: dict[str, str] = {
+    "H2D": "01.02.30.00",
+    "X1": "01.09.00.00",
+    "X1C": "01.09.00.00",
+    "P1P": "01.08.00.00",
+    "P1S": "01.08.00.00",
+}
+# Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
+_DRYING_UNSUPPORTED_MODELS = frozenset(
+    {"P2S", "A1", "A1MINI", "A1-MINI", "A1 MINI", "H2S", "H2C", "N7", "O1C", "O1C2", "O1S", "N1", "N2S"}
+)
+
+
+def supports_drying(model: str | None, firmware: str | None) -> bool:
+    """Check if a printer model supports AMS drying commands.
+
+    Known models with confirmed min firmware get version-gated.
+    Known unsupported models are blocked.
+    All other models (H2D Pro, X1E, future models) are allowed —
+    the command fails gracefully with result: "fail" if unsupported.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    if model_upper in _DRYING_UNSUPPORTED_MODELS:
+        return False
+    if model_upper in _DRYING_MIN_FIRMWARE:
+        return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
+    # For all other models: allow
+    return True
+
+
 class PrinterInfo:
     """Basic printer info for callbacks."""
 
@@ -409,6 +444,20 @@ class PrinterManager:
             return self._clients[printer_id].logging_enabled
         return False
 
+    def send_drying_command(
+        self,
+        printer_id: int,
+        ams_id: int,
+        temp: int,
+        duration: int,
+        mode: int = 1,
+        filament: str = "",
+    ) -> bool:
+        """Send AMS drying command to printer."""
+        if printer_id not in self._clients:
+            return False
+        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament)
+
     def request_status_update(self, printer_id: int) -> bool:
         """Request a full status update from the printer.
 
@@ -560,6 +609,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                         "tray_uuid": tray_uuid,
                         "nozzle_temp_min": tray.get("nozzle_temp_min"),
                         "nozzle_temp_max": tray.get("nozzle_temp_max"),
+                        "drying_temp": tray.get("drying_temp"),
+                        "drying_time": tray.get("drying_time"),
                     }
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
@@ -589,6 +640,19 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
                     "tray": trays,
+                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
+                    "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
+                    # Firmware version: populated by _handle_version_info from get_version
+                    "sw_ver": str(ams_data.get("sw_ver") or ""),
+                    # Drying: dry_time > 0 means drying is active (minutes remaining)
+                    "dry_time": int(ams_data.get("dry_time") or 0),
+                    # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)
+                    "dry_status": int(ams_data.get("dry_status") or 0),
+                    "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),
+                    # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)
+                    "dry_sf_reason": list(ams_data.get("dry_sf_reason") or []),
+                    # Module type: "ams", "n3f", "n3s" (from get_version)
+                    "module_type": str(ams_data.get("module_type") or ""),
                 }
             )
 
@@ -664,6 +728,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
+        "wired_network": state.wired_network,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state, model),
@@ -694,6 +759,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
             }
             for n in (state.nozzle_rack or [])
         ],
+        # AMS drying support
+        "supports_drying": supports_drying(model, state.firmware_version),
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE state so skip objects modal can show cover

+ 20 - 2
backend/app/services/smart_plug_manager.py

@@ -132,11 +132,29 @@ class SmartPlugManager:
             await db.commit()
 
     async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None":
-        """Get the smart plug linked to a printer."""
+        """Get the main (non-script) smart plug linked to a printer.
+
+        When multiple plugs are assigned (e.g., a power plug + secondary HA switch),
+        returns the main power plug for automation control.
+        """
         from backend.app.models.smart_plug import SmartPlug
 
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        return result.scalar_one_or_none()
+        plugs = result.scalars().all()
+
+        if not plugs:
+            return None
+
+        # Prefer non-script, non-secondary plugs (main power plug)
+        for plug in plugs:
+            is_script = (
+                plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+            )
+            if not is_script:
+                return plug
+
+        # All are scripts, return the first one
+        return plugs[0]
 
     async def on_print_start(self, printer_id: int, db: AsyncSession):
         """Called when a print starts - turn on plug if configured."""

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

@@ -142,6 +142,14 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         data_origin="rfid_auto",
         tag_type="bambulab",
     )
+    # Initialize relationships BEFORE db.add() to prevent lazy loads.
+    # Setting them after flush() would trigger a lazy load because SQLAlchemy
+    # loads the current collection before replacing it on a persistent object.
+    # They must also be set before add() because cascade processing during
+    # add/flush accesses these collections, and back_populates resolution
+    # when creating SpoolAssignment runs synchronously outside the greenlet.
+    spool.k_profiles = []
+    spool.assignments = []
     db.add(spool)
     await db.flush()
 
@@ -166,7 +174,7 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp
     if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid):
         result = await db.execute(
             select(Spool)
-            .options(selectinload(Spool.k_profiles))
+            .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
             .where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None))
             .limit(1)
         )
@@ -178,7 +186,7 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp
     if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid):
         result = await db.execute(
             select(Spool)
-            .options(selectinload(Spool.k_profiles))
+            .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
             .where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None))
             .limit(1)
         )

+ 10 - 5
backend/app/services/spoolman.py

@@ -42,7 +42,7 @@ class SpoolmanFilament:
 class AMSTray:
     """Represents an AMS tray with filament data from Bambu printer."""
 
-    ams_id: int  # 0-3 for regular AMS, 128-135 for external spool
+    ams_id: int  # 0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool
     tray_id: int  # 0-3
     tray_type: str  # PLA, PETG, ABS, etc.
     tray_sub_brands: str  # Full name like "PLA Basic", "PETG HF"
@@ -620,7 +620,7 @@ class SpoolmanClient:
         """Parse AMS tray data into AMSTray object.
 
         Args:
-            ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
+            ams_id: The AMS unit ID (0-3 for regular, 128-135 for AMS-HT, 254+ for external)
             tray_data: Raw tray data from MQTT
 
         Returns:
@@ -678,15 +678,20 @@ class SpoolmanClient:
         """Convert AMS ID and tray ID to human-readable location.
 
         Args:
-            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool)
             tray_id: Tray ID within the AMS (0-3)
 
         Returns:
-            Location string like "AMS A1", "AMS B2", "External"
+            Location string like "AMS A1", "AMS-HT A1", "External Spool", etc.
         """
-        if ams_id >= 128:
+        if ams_id >= 254:
             return "External Spool"
 
+        if 128 <= ams_id <= 135:
+            # AMS-HT units use IDs 128-135
+            ht_letter = chr(ord("A") + (ams_id - 128))
+            return f"AMS-HT {ht_letter}{tray_id + 1}"
+
         ams_letter = chr(ord("A") + ams_id)
         return f"AMS {ams_letter}{tray_id + 1}"
 

+ 187 - 46
backend/app/services/spoolman_tracking.py

@@ -18,33 +18,103 @@ logger = logging.getLogger(__name__)
 
 # Zero UUID used by Bambu printers for empty/unset tray_uuid
 _ZERO_UUID = "00000000000000000000000000000000"
+_ZERO_TAG_UID = "0000000000000000"
 
 
-def _resolve_spool_tag(tray_info: dict) -> str:
+def _is_non_zero_identifier(value: str) -> bool:
+    """Return True when identifier is non-empty and not all zeros."""
+    if not value:
+        return False
+    return set(value) != {"0"}
+
+
+def _to_fixed_hex(value: int, width: int) -> str:
+    """Mirror frontend toFixedHex(): uppercase, zero-padded, fixed width."""
+    safe = max(0, int(value))
+    return format(safe, "X").zfill(width)[-width:]
+
+
+def _hash_serial_to_hex32(serial: str) -> str:
+    """Mirror frontend hashSerialToHex32() exactly (32-bit FNV-1a)."""
+    input_str = (serial or "").strip().upper()
+    hash_value = 0x811C9DC5
+    for char in input_str:
+        hash_value ^= ord(char)
+        hash_value = (hash_value * 0x01000193) & 0xFFFFFFFF
+    return format(hash_value, "X").zfill(8)
+
+
+def _global_tray_id_to_ams_slot(global_tray_id: int) -> tuple[int, int]:
+    """Convert global tray id to (ams_id, tray_id) tuple for fallback tag generation."""
+    # External spool slots use IDs 254/255 and map to ams_id=255 tray_id=0/1.
+    if global_tray_id >= 254:
+        return 255, max(0, global_tray_id - 254)
+    # AMS-HT units are addressed by ams_id directly and have a single tray.
+    if global_tray_id >= 128:
+        return global_tray_id, 0
+    # Standard AMS units: four trays each.
+    return global_tray_id // 4, global_tray_id % 4
+
+
+def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:
+    """Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly."""
+    if not printer_serial:
+        return ""
+    ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
+    return f"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}"
+
+
+def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None) -> str:
     """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
 
     Returns empty string if no usable identifier is found.
     """
-    tray_uuid = tray_info.get("tray_uuid", "")
-    tag_uid = tray_info.get("tag_uid", "")
-    if tray_uuid and tray_uuid != _ZERO_UUID:
+    tray_uuid = str(tray_info.get("tray_uuid", "") or "")
+    tag_uid = str(tray_info.get("tag_uid", "") or "")
+    if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
         return tray_uuid
-    return tag_uid
+    if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
+        return tag_uid
+    if global_tray_id is not None:
+        return _get_fallback_spool_tag(printer_serial, global_tray_id)
+    return ""
+
+
+async def _get_printer_serial(printer_id: int) -> str:
+    """Get printer serial for deterministic fallback tag generation."""
+    from backend.app.models.printer import Printer
+    from backend.app.services.printer_manager import printer_manager
+
+    printer_info = printer_manager.get_printer(printer_id)
+    if printer_info and printer_info.serial_number:
+        return printer_info.serial_number
+
+    async with async_session() as db:
+        result = await db.execute(select(Printer.serial_number).where(Printer.id == printer_id))
+        serial_number = result.scalar_one_or_none()
+        return serial_number or ""
 
 
-def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None) -> int:
+def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays: dict | None = None) -> int:
     """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
 
-    Default mapping: slot 1 -> tray 0, slot 2 -> tray 1, etc.
-    Custom mapping (from print queue): slot_to_tray[slot_id - 1] overrides default.
-    A value of -1 in custom mapping means unmapped (uses default).
+    Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
+    Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
+    naturally follow standard AMS trays, matching the slicer's slot numbering.
+    A value of -1 in the custom mapping means unmapped (uses position-based default).
+    Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
     """
-    global_tray_id = slot_id - 1
     if slot_to_tray and slot_id <= len(slot_to_tray):
         mapped_tray = slot_to_tray[slot_id - 1]
         if mapped_tray >= 0:
-            global_tray_id = mapped_tray
-    return global_tray_id
+            return mapped_tray
+    # Position-based default: sort available tray IDs so external spools (254/255)
+    # come after standard AMS trays, matching the slicer's slot assignment order.
+    if ams_trays:
+        sorted_tray_ids = sorted(ams_trays.keys())
+        if slot_id <= len(sorted_tray_ids):
+            return sorted_tray_ids[slot_id - 1]
+    return slot_id - 1
 
 
 def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
@@ -55,9 +125,9 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     lookup = {}
     ams_data = raw_data.get("ams", [])
     for ams_unit in ams_data:
-        ams_id = ams_unit.get("id", 0)
+        ams_id = int(ams_unit.get("id", 0))
         for tray in ams_unit.get("tray", []):
-            tray_id = tray.get("id", 0)
+            tray_id = int(tray.get("id", 0))
             # AMS-HT units have IDs starting at 128 with a single tray
             global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
             lookup[global_tray_id] = {
@@ -79,7 +149,14 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     return lookup
 
 
-async def store_print_data(printer_id: int, archive_id: int, file_path: str, db, printer_manager):
+async def store_print_data(
+    printer_id: int,
+    archive_id: int,
+    file_path: str,
+    db,
+    printer_manager,
+    ams_mapping: list[int] | None = None,
+):
     """Store Spoolman tracking data at print start (persisted to database).
 
     Only stores data when Spoolman is enabled and AMS weight sync is disabled
@@ -124,17 +201,21 @@ async def store_print_data(printer_id: int, archive_id: int, file_path: str, db,
     if state and state.raw_data:
         ams_trays = build_ams_tray_lookup(state.raw_data)
 
-    # Get custom slot-to-tray mapping from queue item (if this is a queued print)
-    slot_to_tray = None
-    queue_result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
-    )
-    queue_item = queue_result.scalar_one_or_none()
-    if queue_item and queue_item.ams_mapping:
-        try:
-            slot_to_tray = json.loads(queue_item.ams_mapping)
-        except json.JSONDecodeError:
-            pass  # Ignore malformed AMS mapping; fall back to default slot assignment
+    # Prefer the explicit mapping captured from the print command, then fall back
+    # to any queue mapping stored for scheduled/reprint jobs.
+    slot_to_tray = ams_mapping if ams_mapping is not None else None
+    if not slot_to_tray:
+        queue_result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.archive_id == archive_id)
+            .where(PrintQueueItem.status == "printing")
+        )
+        queue_item = queue_result.scalar_one_or_none()
+        if queue_item and queue_item.ams_mapping:
+            try:
+                slot_to_tray = json.loads(queue_item.ams_mapping)
+            except json.JSONDecodeError:
+                pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
     # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
     layer_usage = extract_layer_filament_usage_from_3mf(full_path)
@@ -176,7 +257,13 @@ async def store_print_data(printer_id: int, archive_id: int, file_path: str, db,
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
 
 
-async def cleanup_tracking(printer_id: int, archive_id: int, db):
+async def cleanup_tracking(
+    printer_id: int,
+    archive_id: int,
+    db,
+    last_layer_num: int | None = None,
+    last_progress: int | None = None,
+):
     """Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
     from backend.app.models.active_print_spoolman import ActivePrintSpoolman
 
@@ -194,7 +281,12 @@ async def cleanup_tracking(printer_id: int, archive_id: int, db):
 
     # Try to report partial usage before cleanup
     try:
-        await _report_partial_usage(printer_id, tracking)
+        await _report_partial_usage(
+            printer_id,
+            tracking,
+            last_layer_num=last_layer_num,
+            last_progress=last_progress,
+        )
     except Exception as e:
         logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
 
@@ -234,6 +326,7 @@ async def _report_spool_usage_for_slots(
     ams_trays: dict[int, dict],
     slot_to_tray: list | None,
     method_label: str,
+    printer_serial: str = "",
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
 
@@ -244,13 +337,23 @@ async def _report_spool_usage_for_slots(
         if grams_used <= 0:
             continue
 
-        global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray)
+        global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
         tray_info = ams_trays.get(global_tray_id)
         if not tray_info:
             logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
             continue
 
-        spool_tag = _resolve_spool_tag(tray_info)
+        is_external = global_tray_id >= 254
+        tray_type = tray_info.get("tray_type", "")
+        logger.debug(
+            "[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
+            slot_id,
+            global_tray_id,
+            tray_type or "unknown",
+            is_external,
+        )
+
+        spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
         if not spool_tag:
             logger.debug("[SPOOLMAN] Slot %s: no identifier for tray %s", slot_id, global_tray_id)
             continue
@@ -268,7 +371,12 @@ async def _report_spool_usage_for_slots(
     return spools_updated
 
 
-async def _report_partial_usage(printer_id: int, tracking):
+async def _report_partial_usage(
+    printer_id: int,
+    tracking,
+    last_layer_num: int | None = None,
+    last_progress: int | None = None,
+):
     """Report partial filament usage based on actual G-code layer data.
 
     Uses per-layer cumulative extrusion from G-code parsing for accurate
@@ -292,17 +400,39 @@ async def _report_partial_usage(printer_id: int, tracking):
         if not spoolman_enabled or spoolman_enabled.lower() != "true":
             return
 
-    # Get current printer state for layer progress
+    # Get current printer state for layer progress.
+    # On failed/aborted prints the firmware may already reset to IDLE with layer=0,
+    # so we fall back to completion-time hints captured from MQTT.
     state = printer_manager.get_status(printer_id)
-    if not state:
-        logger.debug("[SPOOLMAN] No printer state available for partial usage")
-        return
+    current_layer = state.layer_num if state else None
+    total_layers = state.total_layers if state else None
 
-    current_layer = state.layer_num
-    total_layers = state.total_layers
+    if (not current_layer or current_layer <= 0) and last_layer_num and last_layer_num > 0:
+        current_layer = last_layer_num
+        logger.debug("[SPOOLMAN] Using captured last_layer_num=%s for partial usage", current_layer)
+
+    progress_ratio_from_event = None
+    if last_progress is not None:
+        try:
+            progress_ratio_from_event = min(max(float(last_progress), 0.0), 100.0) / 100.0
+        except (TypeError, ValueError):
+            progress_ratio_from_event = None
+
+    if (not current_layer or current_layer <= 0) and progress_ratio_from_event and total_layers and total_layers > 0:
+        current_layer = max(1, int(round(total_layers * progress_ratio_from_event)))
+        logger.debug(
+            "[SPOOLMAN] Estimated layer from last_progress=%s%% and total_layers=%s -> %s",
+            last_progress,
+            total_layers,
+            current_layer,
+        )
 
     if not current_layer or current_layer <= 0:
-        logger.debug("[SPOOLMAN] No progress to report (layer 0 or unknown)")
+        logger.debug(
+            "[SPOOLMAN] No progress to report (layer 0/unknown, last_layer_num=%s, last_progress=%s)",
+            last_layer_num,
+            last_progress,
+        )
         return
 
     logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", current_layer, total_layers or "?")
@@ -313,6 +443,7 @@ async def _report_partial_usage(printer_id: int, tracking):
     filament_usage = tracking.filament_usage or []
     ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
     slot_to_tray = tracking.slot_to_tray
+    printer_serial = await _get_printer_serial(printer_id)
 
     client = await _get_spoolman_client_with_fallback()
     if not client:
@@ -335,13 +466,13 @@ async def _report_partial_usage(printer_id: int, tracking):
                 slot_id = filament_id + 1  # filament_id is 0-based, slot_id is 1-based
 
                 # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
-                global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray)
+                global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
                 tray_info = ams_trays.get(global_tray_id)
                 density = None
                 diameter = 1.75
 
                 if tray_info:
-                    spool_tag = _resolve_spool_tag(tray_info)
+                    spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
                     if spool_tag:
                         spool = await client.find_spool_by_tag(spool_tag)
                         if spool:
@@ -358,18 +489,27 @@ async def _report_partial_usage(printer_id: int, tracking):
                 usage_items.append((slot_id, grams_used))
 
             spools_updated = await _report_spool_usage_for_slots(
-                client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)"
+                client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)", printer_serial
             )
             if spools_updated > 0:
                 logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
             return
 
     # Fallback: linear interpolation (if no G-code data available)
-    if not total_layers or total_layers <= 0:
-        logger.debug("[SPOOLMAN] Cannot use linear fallback: total_layers=%s", total_layers)
+    progress_ratio = None
+    if total_layers and total_layers > 0:
+        progress_ratio = min(current_layer / total_layers, 1.0)
+    elif progress_ratio_from_event is not None:
+        progress_ratio = progress_ratio_from_event
+
+    if progress_ratio is None:
+        logger.debug(
+            "[SPOOLMAN] Cannot use linear fallback: total_layers=%s, last_progress=%s",
+            total_layers,
+            last_progress,
+        )
         return
 
-    progress_ratio = min(current_layer / total_layers, 1.0)
     logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
 
     usage_items = []
@@ -381,7 +521,7 @@ async def _report_partial_usage(printer_id: int, tracking):
             usage_items.append((slot_id, partial_used_g))
 
     spools_updated = await _report_spool_usage_for_slots(
-        client, usage_items, ams_trays, slot_to_tray, "Partial (linear)"
+        client, usage_items, ams_trays, slot_to_tray, "Partial (linear)", printer_serial
     )
     if spools_updated > 0:
         logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
@@ -412,6 +552,7 @@ async def report_usage(printer_id: int, archive_id: int):
         filament_usage = tracking.filament_usage or []
         ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
         slot_to_tray = tracking.slot_to_tray
+        printer_serial = await _get_printer_serial(printer_id)
 
         # Delete tracking row (we're done with it)
         await db.delete(tracking)
@@ -435,7 +576,7 @@ async def report_usage(printer_id: int, archive_id: int):
 
         usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
         spools_updated = await _report_spool_usage_for_slots(
-            client, usage_items, ams_trays, slot_to_tray, f"Archive {archive_id}"
+            client, usage_items, ams_trays, slot_to_tray, f"Archive {archive_id}", printer_serial
         )
 
         if spools_updated == 0:

+ 18 - 3
backend/app/services/usage_tracker.py

@@ -475,7 +475,8 @@ async def _track_from_3mf(
     2. MQTT mapping field from printer state (universal, all print sources)
     3. Queue item ams_mapping (for queue-initiated prints)
     4. tray_now from printer state (for single-filament non-queue prints)
-    5. Default mapping: slot_id - 1 = global_tray_id (last resort)
+    5. Position-based default using sorted available tray IDs (handles external spools)
+    6. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
@@ -804,12 +805,26 @@ async def _track_from_3mf(
             # Single-filament non-queue print: use actual tray from printer state
             global_tray_id = tray_now_override
         else:
-            # Queue mapping or default: slot_id - 1, overridden by ams_mapping
-            global_tray_id = slot_id - 1
+            # Explicit mapping (print command, MQTT, queue, color match)
+            global_tray_id = None
             if slot_to_tray and slot_id <= len(slot_to_tray):
                 mapped = slot_to_tray[slot_id - 1]
                 if isinstance(mapped, int) and mapped >= 0:
                     global_tray_id = mapped
+            # Position-based default: sort available tray IDs so external spools (254/255)
+            # naturally follow standard AMS trays, matching slicer slot numbering
+            if global_tray_id is None:
+                _state = printer_manager.get_status(printer_id)
+                _raw = getattr(_state, "raw_data", None) if _state else None
+                if _raw:
+                    from backend.app.services.spoolman_tracking import build_ams_tray_lookup
+
+                    available_trays = sorted(build_ams_tray_lookup(_raw).keys())
+                    if slot_id <= len(available_trays):
+                        global_tray_id = available_trays[slot_id - 1]
+            # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)
+            if global_tray_id is None:
+                global_tray_id = slot_id - 1
 
         if global_tray_id >= 254:
             # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)

+ 42 - 8
backend/app/services/virtual_printer/bind_server.py

@@ -2,9 +2,12 @@
 
 Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
 or 3002 to perform the "bind with access code" handshake before using
-MQTT/FTP. The port varies by slicer version, so we listen on both.
+MQTT/FTP.
 
-Protocol:
+Port 3000: plain TCP (legacy / some printer models).
+Port 3002: TLS (newer firmware, e.g. A1 Mini 01.07.x).
+
+Protocol (same on both ports, only transport differs):
   - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
   - Slicer sends: {"login":{"command":"detect","sequence_id":"20000"}}
   - Printer replies: {"login":{"bind":"free","command":"detect","connect":"lan",
@@ -16,11 +19,15 @@ Protocol:
 import asyncio
 import json
 import logging
+import ssl
 import struct
+from pathlib import Path
 
 logger = logging.getLogger(__name__)
 
-BIND_PORTS = [3000, 3002]
+BIND_PORT_PLAIN = 3000
+BIND_PORT_TLS = 3002
+BIND_PORTS = [BIND_PORT_PLAIN, BIND_PORT_TLS]
 FRAME_HEADER = b"\xa5\xa5"
 FRAME_TRAILER = b"\xa7\xa7"
 HEADER_SIZE = 4  # 2 bytes magic + 2 bytes length
@@ -33,8 +40,8 @@ class BindServer:
     In server mode, Bambuddy IS the printer — it responds with its own
     identity so the slicer can discover and bind to it.
 
-    Different BambuStudio versions connect on different ports (3000 or 3002),
-    so we listen on both to ensure compatibility.
+    Port 3000 is plain TCP, port 3002 is TLS.  BambuStudio chooses which
+    port to use based on the printer model discovered via SSDP.
     """
 
     def __init__(
@@ -44,39 +51,66 @@ class BindServer:
         name: str,
         version: str = "01.00.00.00",
         bind_address: str = "0.0.0.0",  # nosec B104
+        cert_path: Path | None = None,
+        key_path: Path | None = None,
     ):
         self.serial = serial
         self.model = model
         self.name = name
         self.version = version
         self.bind_address = bind_address
+        self.cert_path = cert_path
+        self.key_path = key_path
 
         self._servers: list[asyncio.Server] = []
         self._running = False
 
+    def _create_tls_context(self) -> ssl.SSLContext | None:
+        """Create SSL context for the TLS bind port (3002)."""
+        if not self.cert_path or not self.key_path:
+            return None
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        ctx.verify_mode = ssl.CERT_NONE
+        return ctx
+
     async def start(self) -> None:
-        """Start the bind server on ports 3000 and 3002."""
+        """Start the bind server on ports 3000 (plain) and 3002 (TLS)."""
         if self._running:
             return
 
         self._running = True
+
+        tls_ctx = self._create_tls_context()
+        if not tls_ctx:
+            logger.warning("Bind server: no TLS cert provided, port %s will be plain TCP", BIND_PORT_TLS)
+
         logger.info(
-            "Starting bind server on ports %s (serial=%s, model=%s)",
+            "Starting bind server on ports %s (serial=%s, model=%s, tls=%s)",
             BIND_PORTS,
             self.serial,
             self.model,
+            tls_ctx is not None,
         )
 
         try:
             for port in BIND_PORTS:
+                use_tls = port == BIND_PORT_TLS and tls_ctx is not None
                 try:
                     server = await asyncio.start_server(
                         self._handle_client,
                         self.bind_address,
                         port,
+                        ssl=tls_ctx if use_tls else None,
                     )
                     self._servers.append(server)
-                    logger.info("Bind server listening on %s:%s", self.bind_address, port)
+                    logger.info(
+                        "Bind server listening on %s:%s (%s)",
+                        self.bind_address,
+                        port,
+                        "TLS" if use_tls else "plain",
+                    )
                 except OSError as e:
                     if e.errno == 98:
                         logger.warning("Bind server port %s already in use, skipping", port)

+ 32 - 0
backend/app/services/virtual_printer/manager.py

@@ -102,6 +102,7 @@ class VirtualPrinterInstance:
         target_printer_ip: str = "",
         target_printer_serial: str = "",
         target_printer_id: int | None = None,
+        auto_dispatch: bool = True,
         bind_ip: str = "",
         remote_interface_ip: str = "",
         base_dir: Path,
@@ -116,6 +117,7 @@ class VirtualPrinterInstance:
         self.target_printer_ip = target_printer_ip
         self.target_printer_serial = target_printer_serial
         self.target_printer_id = target_printer_id
+        self.auto_dispatch = auto_dispatch
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self._session_factory = session_factory
@@ -320,6 +322,7 @@ class VirtualPrinterInstance:
                         plate_id=plate_id,
                         position=1,
                         status="pending",
+                        manual_start=not self.auto_dispatch,
                     )
                     db.add(queue_item)
                     await db.commit()
@@ -410,6 +413,8 @@ class VirtualPrinterInstance:
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             name=self.name,
             bind_address=bind_addr,
+            cert_path=cert_path,
+            key_path=key_path,
         )
         self._tasks.append(
             asyncio.create_task(
@@ -626,6 +631,31 @@ class VirtualPrinterManager:
                         if printer:
                             proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
 
+        # Detect config changes on running instances and restart if needed
+        for vp in enabled_vps:
+            instance = self._instances.get(vp.id)
+            if not instance:
+                continue
+
+            changed = (
+                instance.mode != vp.mode
+                or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
+                or instance.access_code != (vp.access_code or "")
+                or instance.bind_ip != (vp.bind_ip or "")
+                or instance.remote_interface_ip != (vp.remote_interface_ip or "")
+                or instance.target_printer_id != vp.target_printer_id
+                or instance.auto_dispatch != vp.auto_dispatch
+            )
+
+            if changed:
+                logger.info(
+                    "VP %s config changed (mode: %s→%s), restarting",
+                    instance.name,
+                    instance.mode,
+                    vp.mode,
+                )
+                await self.remove_instance(vp.id)
+
         # Start instances for all enabled VPs (skip already running)
         for vp in enabled_vps:
             if vp.id in self._instances:
@@ -646,6 +676,7 @@ class VirtualPrinterManager:
                     serial_suffix=vp.serial_suffix,
                     target_printer_ip=target_ip,
                     target_printer_serial=target_serial,
+                    auto_dispatch=vp.auto_dispatch,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     base_dir=self._base_dir,
@@ -663,6 +694,7 @@ class VirtualPrinterManager:
                     access_code=vp.access_code or "",
                     serial_suffix=vp.serial_suffix,
                     target_printer_id=vp.target_printer_id,
+                    auto_dispatch=vp.auto_dispatch,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     base_dir=self._base_dir,

+ 27 - 12
backend/app/services/virtual_printer/tcp_proxy.py

@@ -129,6 +129,9 @@ class TLSProxy:
         ctx.check_hostname = False
         ctx.verify_mode = ssl.CERT_NONE
         ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Bambu printers use plain RSA key exchange (no ECDHE/DHE),
+        # which modern OpenSSL 3.x defaults exclude. Add them back.
+        ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         return ctx
 
     async def start(self) -> None:
@@ -346,7 +349,7 @@ class TLSProxy:
 class TCPProxy:
     """Raw TCP proxy that forwards data without TLS termination.
 
-    Used for protocols where the printer doesn't use TLS (e.g., port 3002
+    Used for protocols where the printer doesn't use TLS (e.g., port 3000
     binding/authentication protocol).
     """
 
@@ -1123,18 +1126,30 @@ class SlicerProxyManager:
             bind_address=self.bind_address,
         )
 
-        # Bind/auth proxy (ports 3000 + 3002) - raw TCP, no TLS
-        # Different BambuStudio versions use different ports
+        # Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
         for bind_port in self.PRINTER_BIND_PORTS:
-            proxy = TCPProxy(
-                name="Bind",
-                listen_port=bind_port,
-                target_host=self.target_host,
-                target_port=bind_port,
-                on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-                on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-                bind_address=self.bind_address,
-            )
+            if bind_port == 3002:
+                proxy = TLSProxy(
+                    name="Bind-TLS",
+                    listen_port=bind_port,
+                    target_host=self.target_host,
+                    target_port=bind_port,
+                    server_cert_path=self.cert_path,
+                    server_key_path=self.key_path,
+                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                    bind_address=self.bind_address,
+                )
+            else:
+                proxy = TCPProxy(
+                    name="Bind",
+                    listen_port=bind_port,
+                    target_host=self.target_host,
+                    target_port=bind_port,
+                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                    bind_address=self.bind_address,
+                )
             self._bind_proxies.append(proxy)
 
         # Start as background tasks

+ 78 - 0
backend/app/utils/filament_ids.py

@@ -0,0 +1,78 @@
+"""Utility functions for converting between filament_id and setting_id formats.
+
+Bambu printers use two ID formats for filament presets:
+  - **filament_id** (aka tray_info_idx): e.g. "GFL05", "GFG02", "GFA00"
+    Reported by printer firmware (RFID tags, AMS status).
+  - **setting_id**: e.g. "GFSL05", "GFSG02", "GFSA00"
+    Used by BambuStudio / Bambu Cloud API to resolve presets.
+
+The only difference for official Bambu filaments is an "S" inserted after "GF".
+User presets (starting with "P") use the same ID in both contexts.
+"""
+
+
+def filament_id_to_setting_id(filament_id: str) -> str:
+    """Convert filament_id → setting_id (e.g. "GFL05" → "GFSL05").
+
+    - Already a setting_id ("GFS…") → returned unchanged.
+    - User presets ("P…") → returned unchanged.
+    - Empty / unknown → returned unchanged.
+    """
+    if not filament_id:
+        return filament_id
+
+    # User presets start with "P" - leave unchanged
+    if filament_id.startswith("P"):
+        return filament_id
+
+    # Official Bambu presets: GFx## -> GFSx##
+    if filament_id.startswith("GF") and len(filament_id) >= 4:
+        # Already a setting_id (has S after GF)
+        if filament_id[2] == "S":
+            return filament_id
+        return f"GFS{filament_id[2:]}"
+
+    return filament_id
+
+
+def setting_id_to_filament_id(setting_id: str) -> str:
+    """Convert setting_id → filament_id (e.g. "GFSL05" → "GFL05").
+
+    - Already a filament_id ("GF" without "S") → returned unchanged.
+    - User presets ("P…") → returned unchanged.
+    - Empty / unknown → returned unchanged.
+    """
+    if not setting_id:
+        return setting_id
+
+    # User presets start with "P" - leave unchanged
+    if setting_id.startswith("P"):
+        return setting_id
+
+    # Setting_id format: GFSx## -> GFx##  (remove the "S")
+    if setting_id.startswith("GFS") and len(setting_id) >= 5:
+        return f"GF{setting_id[3:]}"
+
+    return setting_id
+
+
+def normalize_slicer_filament(slicer_filament: str | None) -> tuple[str, str]:
+    """Normalize a slicer_filament value into (tray_info_idx, setting_id).
+
+    The slicer_filament field on a spool can be stored in either format:
+      - filament_id: "GFL05"  (from RFID tag scan)
+      - setting_id:  "GFSL05" or "GFSL05_07"  (from cloud preset picker)
+
+    Returns (tray_info_idx, setting_id) with version suffixes stripped.
+    """
+    raw = slicer_filament or ""
+    if not raw:
+        return ("", "")
+
+    # Strip version suffix (e.g. "GFSL05_07" → "GFSL05")
+    base = raw.split("_")[0] if "_" in raw else raw
+
+    tray_info_idx = setting_id_to_filament_id(base)
+    sid = filament_id_to_setting_id(base)
+
+    return (tray_info_idx, sid)

+ 49 - 3
backend/app/utils/printer_models.py

@@ -50,7 +50,8 @@ PRINTER_MODEL_ID_MAP = {
 
 
 # Rod/rail type classification for maintenance tasks.
-# Carbon rods: X1, P1, P2S series (CoreXY with carbon fiber rods)
+# Carbon rods: X1, P1 series (CoreXY with carbon fiber rods)
+# Steel rods: P2S series (hardened steel linear shafts)
 # Linear rails: A1, H2 series (linear rail motion system)
 # Values must be uppercase with spaces stripped for normalized comparison.
 CARBON_ROD_MODELS = frozenset(
@@ -61,11 +62,18 @@ CARBON_ROD_MODELS = frozenset(
         "X1E",
         "P1P",
         "P1S",
-        "P2S",
         # Internal codes
         "C11",  # X1C
         "C12",  # X1
         "C13",  # X1E
+    ]
+)
+
+STEEL_ROD_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "P2S",
+        # Internal codes
         "N7",  # P2S
     ]
 )
@@ -95,11 +103,47 @@ LINEAR_RAIL_MODELS = frozenset(
 )
 
 
+# Models with an ethernet port.
+# X1, P1P, A1, A1 Mini do NOT have ethernet.
+ETHERNET_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "X1C",
+        "X1E",
+        "P1S",
+        "P2S",
+        "H2D",
+        "H2DPRO",
+        "H2C",
+        "H2S",
+        # Internal codes
+        "C11",  # X1C
+        "C13",  # X1E
+        "P1S",  # P1S
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro (alternate)
+        "O1C",  # H2C
+        "O1C2",  # H2C (dual nozzle variant)
+        "O1S",  # H2S
+    ]
+)
+
+
+def has_ethernet(model: str | None) -> bool:
+    """Return True if the printer model has an ethernet port."""
+    if not model:
+        return False
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized in ETHERNET_MODELS
+
+
 def get_rod_type(model: str | None) -> str | None:
     """Return the rod/rail type for a printer model.
 
     Returns:
-        "carbon" for X1/P1/P2S series (carbon fiber rods),
+        "carbon" for X1/P1 series (carbon fiber rods),
+        "steel_rod" for P2S series (hardened steel rods),
         "linear_rail" for A1/H2 series (linear rails),
         None for unknown models.
     """
@@ -108,6 +152,8 @@ def get_rod_type(model: str | None) -> str | None:
     normalized = model.strip().upper().replace(" ", "").replace("-", "")
     if normalized in CARBON_ROD_MODELS:
         return "carbon"
+    if normalized in STEEL_ROD_MODELS:
+        return "steel_rod"
     if normalized in LINEAR_RAIL_MODELS:
         return "linear_rail"
     return None

+ 10 - 2
backend/tests/conftest.py

@@ -50,8 +50,12 @@ def event_loop():
     """Create an instance of the default event loop for each test session."""
     loop = asyncio.get_event_loop_policy().new_event_loop()
     yield loop
-    # Drain pending callbacks so aiosqlite threads can finish before loop closes
-    loop.run_until_complete(asyncio.sleep(0.1))
+    # Dispose the module-level engine so aiosqlite worker threads finish
+    # before the event loop closes, preventing "Event loop is closed" errors.
+    from backend.app.core.database import engine
+
+    loop.run_until_complete(engine.dispose())
+    loop.run_until_complete(asyncio.sleep(0.05))
     loop.close()
 
 
@@ -63,8 +67,10 @@ async def test_engine():
     # Import all models to register them
     from backend.app.models import (
         ams_history,
+        ams_label,
         api_key,
         archive,
+        color_catalog,
         external_link,
         filament,
         group,
@@ -79,8 +85,10 @@ async def test_engine():
         smart_plug,
         spool,
         spool_assignment,
+        spool_catalog,
         spool_usage_history,
         user,
+        virtual_printer,
     )
 
     async with engine.begin() as conn:

+ 6 - 1
backend/tests/integration/test_advanced_auth_api.py

@@ -120,12 +120,17 @@ class TestSMTPConfigAPI:
     @pytest.mark.integration
     async def test_test_smtp_connection(self, async_client: AsyncClient, admin_token: str):
         """POST /auth/smtp/test with mocked send_email returns success."""
+        await async_client.post(
+            "/api/v1/auth/smtp",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json=SMTP_DATA,
+        )
+
         with patch("backend.app.api.routes.auth.send_email"):
             response = await async_client.post(
                 "/api/v1/auth/smtp/test",
                 headers={"Authorization": f"Bearer {admin_token}"},
                 json={
-                    **SMTP_DATA,
                     "test_recipient": "recipient@test.com",
                 },
             )

+ 207 - 0
backend/tests/integration/test_ams_labels_api.py

@@ -0,0 +1,207 @@
+"""Integration tests for AMS Labels API endpoints."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.ams_label import AmsLabel
+
+
+class TestAmsLabelsAPI:
+    """Integration tests for /api/v1/printers/{printer_id}/ams-labels endpoints."""
+
+    def _mock_printer_state(self, ams_units=None):
+        """Create a mock printer state with AMS data."""
+        state = MagicMock()
+        state.connected = True
+        state.raw_data = {
+            "ams": ams_units
+            or [
+                {"id": "0", "sn": "AMS_SERIAL_0"},
+                {"id": "1", "sn": "AMS_SERIAL_1"},
+            ],
+        }
+        return state
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_labels_empty(self, async_client: AsyncClient, printer_factory):
+        """Returns empty dict when no labels are saved."""
+        printer = await printer_factory()
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = self._mock_printer_state()
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+        assert response.status_code == 200
+        assert response.json() == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_with_serial(self, async_client: AsyncClient, printer_factory):
+        """Save a label keyed by AMS serial number."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Workshop AMS", "ams_serial": "AMS_SERIAL_0"},
+        )
+        assert response.status_code == 200
+        assert response.json() == {"ams_id": 0, "label": "Workshop AMS"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_without_serial_uses_synthetic_key(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """When no serial is provided, a synthetic key p{printer_id}a{ams_id} is used."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/2",
+            json={"label": "Old Firmware AMS"},
+        )
+        assert response.status_code == 200
+
+        # Verify the synthetic key was stored
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a2"))
+        label = result.scalar_one_or_none()
+        assert label is not None
+        assert label.label == "Old Firmware AMS"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_whitespace_serial_uses_synthetic_key(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Whitespace-only serial falls back to synthetic key."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Whitespace Test", "ams_serial": "   "},
+        )
+        assert response.status_code == 200
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a0"))
+        label = result.scalar_one_or_none()
+        assert label is not None
+        assert label.label == "Whitespace Test"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_updates_existing(self, async_client: AsyncClient, printer_factory):
+        """Saving a label with the same serial updates the existing record."""
+        printer = await printer_factory()
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Original Name", "ams_serial": "SN123"},
+        )
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Updated Name", "ams_serial": "SN123"},
+        )
+        assert response.status_code == 200
+        assert response.json()["label"] == "Updated Name"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_printer_not_found(self, async_client: AsyncClient):
+        """Returns 404 when printer does not exist."""
+        response = await async_client.put(
+            "/api/v1/printers/99999/ams-labels/0",
+            json={"label": "Ghost Printer"},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_validation_empty_label(self, async_client: AsyncClient, printer_factory):
+        """Rejects empty label."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": ""},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_labels_resolves_serial_to_ams_id(self, async_client: AsyncClient, printer_factory):
+        """GET returns labels keyed by ams_id, resolved from live printer state."""
+        printer = await printer_factory()
+
+        # Save a label with a known serial
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Silk Colours", "ams_serial": "AMS_SERIAL_0"},
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = self._mock_printer_state()
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data.get("0") == "Silk Colours"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_labels_no_printer_state(self, async_client: AsyncClient, printer_factory):
+        """GET returns empty when printer has no live state."""
+        printer = await printer_factory()
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = None
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+        assert response.status_code == 200
+        assert response.json() == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_label(self, async_client: AsyncClient, printer_factory, db_session):
+        """Delete removes the label from the database."""
+        printer = await printer_factory()
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "To Delete", "ams_serial": "DEL_SN"},
+        )
+
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=DEL_SN")
+        assert response.status_code == 200
+        assert response.json() == {"success": True}
+
+        # Verify it's gone
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == "DEL_SN"))
+        assert result.scalar_one_or_none() is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_nonexistent_label_succeeds(self, async_client: AsyncClient, printer_factory):
+        """Delete returns success even if no label exists (idempotent)."""
+        printer = await printer_factory()
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=NONEXISTENT")
+        assert response.status_code == 200
+        assert response.json() == {"success": True}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_label_whitespace_serial_uses_synthetic_key(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Delete with whitespace serial falls back to synthetic key."""
+        printer = await printer_factory()
+        # Save with synthetic key
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Synthetic Label"},
+        )
+
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=%20%20")
+        assert response.status_code == 200
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a0"))
+        assert result.scalar_one_or_none() is None

+ 150 - 0
backend/tests/integration/test_archives_api.py

@@ -227,6 +227,156 @@ class TestArchivesAPI:
         assert "successful_prints" in result
 
 
+class TestArchivesSlimAPI:
+    """Integration tests for /api/v1/archives/slim endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_empty(self, async_client: AsyncClient):
+        """Verify empty list when no archives exist."""
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_returns_only_expected_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify response contains only slim fields, not full archive data."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Slim Test",
+            status="completed",
+            filament_type="PLA",
+            filament_color="#FF0000",
+            filament_used_grams=50.0,
+            print_time_seconds=3600,
+            cost=1.50,
+            quantity=2,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        item = data[0]
+
+        # Expected fields present
+        assert item["printer_id"] == printer.id
+        assert item["print_name"] == "Slim Test"
+        assert item["status"] == "completed"
+        assert item["filament_type"] == "PLA"
+        assert item["filament_color"] == "#FF0000"
+        assert item["filament_used_grams"] == 50.0
+        assert item["print_time_seconds"] == 3600
+        assert item["cost"] == 1.50
+        assert item["quantity"] == 2
+        assert "created_at" in item
+
+        # Full archive fields must NOT be present
+        assert "id" not in item
+        assert "filename" not in item
+        assert "file_path" not in item
+        assert "file_size" not in item
+        assert "extra_data" not in item
+        assert "notes" not in item
+        assert "tags" not in item
+        assert "photos" not in item
+        assert "thumbnail_path" not in item
+        assert "content_hash" not in item
+        assert "duplicates" not in item
+        assert "duplicate_count" not in item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_computes_actual_time(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is computed from started_at/completed_at."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
+        completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)  # 2 hours = 7200s
+        await archive_factory(
+            printer.id,
+            status="completed",
+            started_at=started,
+            completed_at=completed,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] == 7200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_actual_time_null_for_failed(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is null for non-completed prints."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="failed",
+            started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify date_from and date_to filters work."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Old Print",
+            created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
+        )
+        await archive_factory(
+            printer.id,
+            print_name="New Print",
+            created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
+        )
+
+        # Filter to only June 2024
+        response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["print_name"] == "New Print"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify limit and offset work."""
+        printer = await printer_factory()
+        for i in range(5):
+            await archive_factory(printer.id, print_name=f"Print {i}")
+
+        response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
+
+        assert response.status_code == 200
+        assert len(response.json()) == 2
+
+
 class TestArchiveDataIntegrity:
     """Tests for archive data integrity."""
 

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

@@ -180,6 +180,68 @@ class TestAuthMeAPI:
         assert result["role"] == "admin"
         assert result["is_active"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):
+        """Verify /me returns synthetic admin user when using API key via Bearer token."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        # Create an API key directly in the database
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(name="test-kiosk", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
+        db_session.add(api_key)
+        await db_session.commit()
+
+        # Call /me with the API key as Bearer token
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": f"Bearer {full_key}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == 0
+        assert result["username"].startswith("api-key:")
+        assert result["role"] == "admin"
+        assert result["is_admin"] is True
+        assert result["is_active"] is True
+        assert len(result["permissions"]) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):
+        """Verify /me returns synthetic admin user when using X-API-Key header."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(name="test-kiosk-header", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
+        db_session.add(api_key)
+        await db_session.commit()
+
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"X-API-Key": full_key},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == 0
+        assert result["username"].startswith("api-key:")
+        assert result["is_admin"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_invalid_api_key(self, async_client: AsyncClient):
+        """Verify /me rejects invalid API key."""
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": "Bearer bb_invalid_key_value"},
+        )
+
+        assert response.status_code == 401
+
 
 class TestUsersAPI:
     """Integration tests for /api/v1/users/ endpoints."""

+ 219 - 0
backend/tests/integration/test_available_filaments.py

@@ -0,0 +1,219 @@
+"""Integration tests for GET /api/v1/printers/available-filaments endpoint.
+
+Tests that the endpoint returns deduplicated filaments with tray_sub_brands,
+correctly distinguishing subtypes like "PLA Basic" vs "PLA Matte".
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _make_mock_status(ams_data: list, vt_tray: list | None = None, ams_extruder_map: dict | None = None) -> MagicMock:
+    """Create a mock printer status with raw_data containing AMS info."""
+    status = MagicMock()
+    raw = {"ams": ams_data}
+    if vt_tray is not None:
+        raw["vt_tray"] = vt_tray
+    if ams_extruder_map is not None:
+        raw["ams_extruder_map"] = ams_extruder_map
+    else:
+        raw["ams_extruder_map"] = {}
+    status.raw_data = raw
+    return status
+
+
+class TestAvailableFilaments:
+    """Tests for /api/v1/printers/available-filaments endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_tray_sub_brands(self, async_client: AsyncClient, printer_factory):
+        """Verify tray_sub_brands is included in the response."""
+        await printer_factory(name="Test Printer", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "000000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        },
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["tray_sub_brands"] == "PLA Basic"
+        assert data[0]["type"] == "PLA"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dedup_distinguishes_subtypes(self, async_client: AsyncClient, printer_factory):
+        """PLA Basic Black and PLA Matte Black should be separate entries."""
+        await printer_factory(name="Printer 1", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "000000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        },
+                        {
+                            "id": 1,
+                            "tray_type": "PLA",
+                            "tray_color": "000000FF",
+                            "tray_info_idx": "GFL05",
+                            "tray_sub_brands": "PLA Matte",
+                        },
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        # Same type + color but different tray_sub_brands → 2 entries
+        assert len(data) == 2
+        sub_brands = {d["tray_sub_brands"] for d in data}
+        assert sub_brands == {"PLA Basic", "PLA Matte"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dedup_same_subtype_same_color(self, async_client: AsyncClient, printer_factory):
+        """Same subtype + same color across two printers should be deduped to one entry."""
+        await printer_factory(name="Printer 1", model="X1C")
+        await printer_factory(name="Printer 2", model="X1C")
+
+        status1 = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "FF0000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        }
+                    ],
+                },
+            ]
+        )
+        status2 = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "FF0000FF",
+                            "tray_info_idx": "GFL99",
+                            "tray_sub_brands": "PLA Basic",
+                        }
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.side_effect = [status1, status2]
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_sub_brands_handled(self, async_client: AsyncClient, printer_factory):
+        """Filaments with empty/missing tray_sub_brands should still be returned."""
+        await printer_factory(name="Test Printer", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"},
+                    ],
+                },
+            ]
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["tray_sub_brands"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_spool_includes_sub_brands(self, async_client: AsyncClient, printer_factory):
+        """External spools (vt_tray) should also include tray_sub_brands."""
+        await printer_factory(name="Test Printer", model="X1C")
+
+        status = _make_mock_status(
+            ams_data=[],
+            vt_tray=[
+                {
+                    "id": 254,
+                    "tray_type": "PETG",
+                    "tray_color": "00FF00FF",
+                    "tray_info_idx": "GFG00",
+                    "tray_sub_brands": "PETG HF",
+                },
+            ],
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["tray_sub_brands"] == "PETG HF"
+        assert data[0]["type"] == "PETG"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_printers_returns_empty(self, async_client: AsyncClient):
+        """Verify empty list when no printers match the model."""
+        response = await async_client.get("/api/v1/printers/available-filaments?model=X1C")
+
+        assert response.status_code == 200
+        assert response.json() == []

+ 391 - 0
backend/tests/integration/test_cloud_auth.py

@@ -0,0 +1,391 @@
+"""Integration tests for per-user cloud credentials and cloud endpoint permissions.
+
+Regression tests for:
+- Per-user cloud token storage (when auth enabled)
+- Global fallback (when auth disabled)
+- Cloud endpoints use CLOUD_AUTH permission (not SETTINGS_READ)
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestPerUserCloudCredentials:
+    """Tests that cloud credentials are stored per-user when auth is enabled."""
+
+    @pytest.fixture
+    async def user_with_cloud_auth(self, db_session):
+        """Create a user with CLOUD_AUTH permission via a group."""
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.group import Group
+        from backend.app.models.user import User
+
+        group = Group(
+            name="CloudUsers",
+            permissions=["cloud:auth", "filaments:read", "printers:read", "firmware:read"],
+        )
+        db_session.add(group)
+        await db_session.flush()
+
+        user = User(
+            username="clouduser",
+            password_hash=get_password_hash("testpass123"),
+            role="user",
+        )
+        db_session.add(user)
+        await db_session.flush()
+        user.groups.append(group)
+        await db_session.commit()
+        await db_session.refresh(user)
+        return user
+
+    @pytest.fixture
+    async def second_user_with_cloud_auth(self, db_session):
+        """Create a second user with CLOUD_AUTH permission."""
+        from sqlalchemy import select
+
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.group import Group
+        from backend.app.models.user import User
+
+        result = await db_session.execute(select(Group).where(Group.name == "CloudUsers"))
+        group = result.scalar_one_or_none()
+        if not group:
+            group = Group(
+                name="CloudUsers2",
+                permissions=["cloud:auth", "filaments:read", "printers:read", "firmware:read"],
+            )
+            db_session.add(group)
+            await db_session.flush()
+
+        user = User(
+            username="clouduser2",
+            password_hash=get_password_hash("testpass456"),
+            role="user",
+        )
+        db_session.add(user)
+        await db_session.flush()
+        user.groups.append(group)
+        await db_session.commit()
+        await db_session.refresh(user)
+        return user
+
+    @pytest.fixture
+    async def cloud_auth_token(self, user_with_cloud_auth, async_client: AsyncClient):
+        """Get auth token for user with cloud permissions."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "clouduser", "password": "testpass123"},
+        )
+        if response.status_code == 200:
+            return response.json().get("access_token")
+        return None
+
+    @pytest.fixture
+    async def second_auth_token(self, second_user_with_cloud_auth, async_client: AsyncClient):
+        """Get auth token for second user."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "clouduser2", "password": "testpass456"},
+        )
+        if response.status_code == 200:
+            return response.json().get("access_token")
+        return None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_status_returns_not_authenticated_by_default(self, async_client: AsyncClient):
+        """Cloud status should show not authenticated when no token is stored."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/cloud/status")
+            assert response.status_code == 200
+            data = response.json()
+            assert data["is_authenticated"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_status_accessible_when_auth_disabled(self, async_client: AsyncClient):
+        """Cloud endpoints should work when auth is disabled (global fallback)."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/cloud/status")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_status_requires_auth_when_enabled(self, async_client: AsyncClient):
+        """Cloud endpoints should require auth when auth is enabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
+            response = await async_client.get("/api/v1/cloud/status")
+            assert response.status_code == 401
+
+
+class TestCloudEndpointPermissions:
+    """Tests that cloud endpoints use CLOUD_AUTH permission, not SETTINGS_READ.
+
+    Uses JWT tokens created directly (not via login endpoint) to avoid
+    test infrastructure complexity with user creation across sessions.
+    """
+
+    @pytest.fixture
+    async def settings_only_setup(self, async_client: AsyncClient):
+        """Create user with settings:read but NOT cloud:auth, return JWT."""
+        from backend.app.core.auth import create_access_token, get_password_hash
+        from backend.app.core.database import async_session
+        from backend.app.models.group import Group
+        from backend.app.models.user import User
+
+        async with async_session() as db:
+            group = Group(name="SettingsReaders", permissions=["settings:read"])
+            db.add(group)
+            user = User(
+                username="settingsuser",
+                password_hash=get_password_hash("testpass123"),
+                role="user",
+            )
+            db.add(user)
+            await db.commit()
+            await db.refresh(group)
+            await db.refresh(user)
+
+            from sqlalchemy import text
+
+            await db.execute(
+                text("INSERT INTO user_groups (user_id, group_id) VALUES (:uid, :gid)"),
+                {"uid": user.id, "gid": group.id},
+            )
+            await db.commit()
+
+        return create_access_token(data={"sub": "settingsuser"})
+
+    @pytest.fixture
+    async def cloud_only_setup(self, async_client: AsyncClient):
+        """Create user with cloud:auth but NOT settings:read, return JWT."""
+        from backend.app.core.auth import create_access_token, get_password_hash
+        from backend.app.core.database import async_session
+        from backend.app.models.group import Group
+        from backend.app.models.user import User
+
+        async with async_session() as db:
+            group = Group(name="CloudOnly", permissions=["cloud:auth"])
+            db.add(group)
+            user = User(
+                username="cloudonly",
+                password_hash=get_password_hash("testpass123"),
+                role="user",
+            )
+            db.add(user)
+            await db.commit()
+            await db.refresh(group)
+            await db.refresh(user)
+
+            from sqlalchemy import text
+
+            await db.execute(
+                text("INSERT INTO user_groups (user_id, group_id) VALUES (:uid, :gid)"),
+                {"uid": user.id, "gid": group.id},
+            )
+            await db.commit()
+
+        return create_access_token(data={"sub": "cloudonly"})
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_settings_requires_cloud_auth_not_settings_read(
+        self, async_client: AsyncClient, settings_only_setup, cloud_only_setup
+    ):
+        """GET /cloud/settings should require CLOUD_AUTH, not SETTINGS_READ.
+
+        Regression test: previously used SETTINGS_READ which blocked users who
+        had cloud:auth permission but not settings:read.
+        """
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
+            # User with only settings:read should be denied
+            response = await async_client.get(
+                "/api/v1/cloud/settings",
+                headers={"Authorization": f"Bearer {settings_only_setup}"},
+            )
+            assert response.status_code == 403
+
+            # User with cloud:auth should be allowed (will get 401 since no cloud token,
+            # but NOT 403 — permission check passes)
+            response = await async_client.get(
+                "/api/v1/cloud/settings",
+                headers={"Authorization": f"Bearer {cloud_only_setup}"},
+            )
+            assert response.status_code == 401  # No cloud token, but permission OK
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_status_requires_cloud_auth(
+        self, async_client: AsyncClient, settings_only_setup, cloud_only_setup
+    ):
+        """GET /cloud/status should require CLOUD_AUTH."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
+            # settings:read only → 403
+            response = await async_client.get(
+                "/api/v1/cloud/status",
+                headers={"Authorization": f"Bearer {settings_only_setup}"},
+            )
+            assert response.status_code == 403
+
+            # cloud:auth → 200
+            response = await async_client.get(
+                "/api/v1/cloud/status",
+                headers={"Authorization": f"Bearer {cloud_only_setup}"},
+            )
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_fields_requires_cloud_auth(
+        self, async_client: AsyncClient, settings_only_setup, cloud_only_setup
+    ):
+        """GET /cloud/fields should require CLOUD_AUTH, not SETTINGS_READ."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=True):
+            # settings:read only → 403
+            response = await async_client.get(
+                "/api/v1/cloud/fields",
+                headers={"Authorization": f"Bearer {settings_only_setup}"},
+            )
+            assert response.status_code == 403
+
+            # cloud:auth → 200
+            response = await async_client.get(
+                "/api/v1/cloud/fields",
+                headers={"Authorization": f"Bearer {cloud_only_setup}"},
+            )
+            assert response.status_code == 200
+
+
+class TestCloudTokenStorage:
+    """Unit-level tests for the token storage functions."""
+
+    @pytest.mark.asyncio
+    async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):
+        """get_stored_token with user=None and no global token returns (None, None)."""
+        from backend.app.api.routes.cloud import get_stored_token
+
+        token, email = await get_stored_token(db_session, user=None)
+        assert token is None
+        assert email is None
+
+    @pytest.mark.asyncio
+    async def test_store_and_get_global_token(self, db_session):
+        """store_token with user=None stores in global Settings table."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+
+        await store_token(db_session, "test-token-123", "test@example.com", user=None)
+        token, email = await get_stored_token(db_session, user=None)
+        assert token == "test-token-123"
+        assert email == "test@example.com"
+
+    @pytest.mark.asyncio
+    async def test_store_and_get_per_user_token(self, db_session):
+        """store_token with user stores on the user record."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        user = User(username="tokentest", password_hash=get_password_hash("pass"), role="user")
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        await store_token(db_session, "user-token-abc", "user@example.com", user=user)
+
+        # Re-fetch user to verify persistence
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(User).where(User.id == user.id))
+        refreshed = result.scalar_one()
+        assert refreshed.cloud_token == "user-token-abc"
+        assert refreshed.cloud_email == "user@example.com"
+
+    @pytest.mark.asyncio
+    async def test_per_user_token_does_not_affect_global(self, db_session):
+        """Storing per-user token should not affect global Settings."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        user = User(username="isolationtest", password_hash=get_password_hash("pass"), role="user")
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        # Store per-user token
+        await store_token(db_session, "per-user-token", "per-user@test.com", user=user)
+
+        # Global should still be empty
+        global_token, global_email = await get_stored_token(db_session, user=None)
+        assert global_token is None
+        assert global_email is None
+
+    @pytest.mark.asyncio
+    async def test_clear_per_user_token(self, db_session):
+        """clear_token with user clears only that user's credentials."""
+        from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        user = User(username="cleartest", password_hash=get_password_hash("pass"), role="user")
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        await store_token(db_session, "to-clear", "clear@test.com", user=user)
+        await clear_token(db_session, user=user)
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(User).where(User.id == user.id))
+        refreshed = result.scalar_one()
+        assert refreshed.cloud_token is None
+        assert refreshed.cloud_email is None
+
+    @pytest.mark.asyncio
+    async def test_clear_global_token(self, db_session):
+        """clear_token with user=None clears from global Settings."""
+        from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
+
+        await store_token(db_session, "global-token", "global@test.com", user=None)
+        await clear_token(db_session, user=None)
+
+        token, email = await get_stored_token(db_session, user=None)
+        assert token is None
+        assert email is None
+
+    @pytest.mark.asyncio
+    async def test_two_users_independent_tokens(self, db_session):
+        """Two users should have completely independent cloud tokens."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        user_a = User(username="user_a", password_hash=get_password_hash("pass"), role="user")
+        user_b = User(username="user_b", password_hash=get_password_hash("pass"), role="user")
+        db_session.add_all([user_a, user_b])
+        await db_session.commit()
+        await db_session.refresh(user_a)
+        await db_session.refresh(user_b)
+
+        await store_token(db_session, "token-a", "a@test.com", user=user_a)
+        await store_token(db_session, "token-b", "b@test.com", user=user_b)
+
+        # Verify each user reads their own token (re-fetch from DB)
+        from sqlalchemy import select
+
+        result_a = await db_session.execute(select(User).where(User.id == user_a.id))
+        result_b = await db_session.execute(select(User).where(User.id == user_b.id))
+        fresh_a = result_a.scalar_one()
+        fresh_b = result_b.scalar_one()
+
+        token_a, email_a = await get_stored_token(db_session, user=fresh_a)
+        token_b, email_b = await get_stored_token(db_session, user=fresh_b)
+
+        assert token_a == "token-a"
+        assert email_a == "a@test.com"
+        assert token_b == "token-b"
+        assert email_b == "b@test.com"

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

@@ -336,6 +336,61 @@ class TestNotificationsAPI:
 
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_with_first_layer_complete(self, async_client: AsyncClient):
+        """Verify first layer complete toggle persists on create."""
+        data = {
+            "name": "First Layer Test",
+            "provider_type": "ntfy",
+            "config": {"server": "https://ntfy.sh", "topic": "test"},
+            "on_first_layer_complete": True,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_first_layer_complete"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_first_layer_complete_toggle(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """CRITICAL: Verify first layer complete toggle persists correctly."""
+        provider = await notification_provider_factory(on_first_layer_complete=False)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_first_layer_complete": True},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_first_layer_complete"] is True
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_first_layer_complete"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_first_layer_complete_independent_from_other_toggles(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify first layer complete is independent from bed cooled and print complete."""
+        provider = await notification_provider_factory(
+            on_print_complete=True,
+            on_bed_cooled=False,
+            on_first_layer_complete=True,
+        )
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        result = response.json()
+        assert result["on_print_complete"] is True
+        assert result["on_bed_cooled"] is False
+        assert result["on_first_layer_complete"] is True
+
 
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""
@@ -411,3 +466,65 @@ class TestNotificationTemplatesAPI:
         assert response.status_code == 200
         result = response.json()
         assert result["is_default"] is True
+
+
+class TestHomeAssistantNotificationProvider:
+    """Integration tests for Home Assistant notification provider."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_homeassistant_provider(self, async_client: AsyncClient):
+        """Verify homeassistant notification provider can be created with empty config."""
+        data = {
+            "name": "HA Notifications",
+            "provider_type": "homeassistant",
+            "enabled": True,
+            "config": {},
+            "on_print_complete": True,
+            "on_print_failed": True,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "HA Notifications"
+        assert result["provider_type"] == "homeassistant"
+        assert result["on_print_complete"] is True
+        assert result["on_print_failed"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_homeassistant_provider(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify homeassistant provider can be updated."""
+        provider = await notification_provider_factory(
+            name="HA Test",
+            provider_type="homeassistant",
+            config="{}",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_print_start": True, "on_printer_offline": True},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_print_start"] is True
+        assert result["on_printer_offline"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_test_homeassistant_config_without_ha_settings(self, async_client: AsyncClient):
+        """Verify test-config returns error when HA is not configured."""
+        response = await async_client.post(
+            "/api/v1/notifications/test-config",
+            json={"provider_type": "homeassistant", "config": {}},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is False
+        assert "not configured" in result["message"].lower() or "Home Assistant" in result["message"]

+ 22 - 1
backend/tests/integration/test_print_lifecycle.py

@@ -65,15 +65,26 @@ class TestPrintCompleteLogic:
     @pytest.mark.asyncio
     async def test_print_complete_no_import_errors(self, capture_logs):
         """Verify on_print_complete doesn't have import shadowing issues."""
+        # Snapshot tasks before the call so we can cancel orphans afterwards.
+        # on_print_complete fires background tasks (maintenance check, notifications,
+        # smart-plug) via asyncio.create_task.  If those tasks outlive the mock
+        # context they use the *real* async_session and can send real notifications.
+        tasks_before = set(asyncio.all_tasks())
+
         with (
             patch("backend.app.main.async_session") as mock_session_maker,
             patch("backend.app.main.notification_service") as mock_notif,
             patch("backend.app.main.smart_plug_manager") as mock_plug,
             patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.printer_manager") as mock_pm,
         ):
             mock_notif.on_print_complete = AsyncMock()
             mock_plug.on_print_complete = AsyncMock()
             mock_ws.send_print_complete = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+            mock_relay.on_print_complete = AsyncMock()
+            mock_pm.get_printer.return_value = None
 
             # Mock the database session
             mock_session = AsyncMock()
@@ -94,6 +105,16 @@ class TestPrintCompleteLogic:
                 },
             )
 
+            # Cancel background tasks spawned by on_print_complete before
+            # leaving the mock context — prevents them from running with
+            # the real async_session and sending real notifications.
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
         # Verify no import shadowing errors - this would have caught the ArchiveService bug
         errors = [r for r in capture_logs.get_errors() if "cannot access local variable" in str(r.message)]
         assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
@@ -215,7 +236,7 @@ class TestTimelapseTracking:
             {
                 "print": {
                     "gcode_state": "RUNNING",
-                    "hms": [{"attr": 0x07000002, "code": 0x1234}],  # Filament module error
+                    "hms": [{"attr": 0x07000002, "code": 0x8001}],  # Filament module error (code must be >= 0x4000)
                 }
             }
         )

+ 247 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1176,3 +1176,250 @@ class TestTargetLocationFeature:
         assert response.status_code == 200
         result = response.json()
         assert result["target_location"] is None
+
+
+class TestAbortedStatusNormalisation:
+    """Tests for issue #558: 'aborted' queue status causes 500 error."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Abort Test Printer {counter}",
+                "ip_address": f"192.168.1.{60 + counter}",
+                "serial_number": f"TESTABORT{counter:04d}",
+                "access_code": "12345678",
+                "model": "P1S",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"abort_test_{counter}.3mf",
+                "print_name": f"Abort Test Print {counter}",
+                "file_path": f"/tmp/abort_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"aborthash{counter:06d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_on_print_complete_normalises_aborted_to_cancelled(self, queue_item_factory, db_session):
+        """Verify the completion handler maps 'aborted' → 'cancelled' for queue items."""
+        import asyncio
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        item = await queue_item_factory(status="printing")
+
+        # Build a mock session whose execute returns our item
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [item]
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+        mock_session.execute = AsyncMock(return_value=mock_result)
+        mock_session.commit = AsyncMock()
+
+        tasks_before = set(asyncio.all_tasks())
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.notification_service") as mock_notif,
+            patch("backend.app.main.smart_plug_manager") as mock_plug,
+            patch("backend.app.main.printer_manager") as mock_pm,
+        ):
+            mock_ws.send_print_complete = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+            mock_relay.on_print_complete = AsyncMock()
+            mock_relay.on_queue_job_completed = AsyncMock()
+            mock_notif.on_print_complete = AsyncMock()
+            mock_plug.on_print_complete = AsyncMock()
+            mock_pm.get_printer.return_value = None
+
+            from backend.app.main import on_print_complete
+
+            await on_print_complete(
+                item.printer_id,
+                {
+                    "status": "aborted",
+                    "filename": "test.gcode",
+                    "subtask_name": "Test",
+                    "timelapse_was_active": False,
+                },
+            )
+
+            # Cancel background tasks before leaving mock context
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
+        # The item status should be normalised to 'cancelled', not 'aborted'
+        assert item.status == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_startup_fixup_converts_aborted_to_cancelled(self, queue_item_factory, db_session):
+        """Verify the startup fixup converts existing 'aborted' rows to 'cancelled'."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        # Create items with various statuses including 'aborted'
+        item_aborted = await queue_item_factory(status="pending")
+        item_pending = await queue_item_factory(status="pending")
+
+        # Manually set the invalid status
+        item_aborted.status = "aborted"
+        db_session.add(item_aborted)
+        await db_session.commit()
+
+        # Run the fixup query (same logic as lifespan)
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
+        aborted_items = result.scalars().all()
+        for i in aborted_items:
+            i.status = "cancelled"
+        await db_session.commit()
+
+        # Verify: no more 'aborted' items
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
+        assert len(result.scalars().all()) == 0
+
+        # The previously aborted item should now be 'cancelled'
+        await db_session.refresh(item_aborted)
+        assert item_aborted.status == "cancelled"
+
+        # The pending item should be unchanged
+        await db_session.refresh(item_pending)
+        assert item_pending.status == "pending"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_completed_status_passes_through_unchanged(self, queue_item_factory, db_session):
+        """Verify normal statuses like 'completed' are not affected by normalisation."""
+        import asyncio
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        item = await queue_item_factory(status="printing")
+
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [item]
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock(return_value=False)
+        mock_session.execute = AsyncMock(return_value=mock_result)
+        mock_session.commit = AsyncMock()
+
+        tasks_before = set(asyncio.all_tasks())
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.notification_service") as mock_notif,
+            patch("backend.app.main.smart_plug_manager") as mock_plug,
+            patch("backend.app.main.printer_manager") as mock_pm,
+        ):
+            mock_ws.send_print_complete = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+            mock_relay.on_print_complete = AsyncMock()
+            mock_relay.on_queue_job_completed = AsyncMock()
+            mock_notif.on_print_complete = AsyncMock()
+            mock_plug.on_print_complete = AsyncMock()
+            mock_pm.get_printer.return_value = None
+
+            from backend.app.main import on_print_complete
+
+            await on_print_complete(
+                item.printer_id,
+                {
+                    "status": "completed",
+                    "filename": "test.gcode",
+                    "subtask_name": "Test",
+                    "timelapse_was_active": False,
+                },
+            )
+
+            # Cancel background tasks before leaving mock context
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
+        assert item.status == "completed"

+ 115 - 0
backend/tests/integration/test_projects_api.py

@@ -262,6 +262,121 @@ class TestProjectPartsTracking:
         assert data["stats"]["parts_progress_percent"] == 40.0  # parts: 10/25
 
 
+class TestProjectArchivedStatusNotCounted:
+    """Tests for bug #630: archived files added to a project should not count as printed."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {
+                "name": "Archived Status Test",
+                "description": "Test project",
+                "color": "#FF0000",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            defaults = {
+                "filename": "test.3mf",
+                "file_path": "test/test.3mf",
+                "file_size": 1000,
+                "print_name": "Test Print",
+                "status": "completed",
+                "quantity": 1,
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archived_files_not_counted_as_completed(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Archived files added to a project should not count in completed_prints stats."""
+        project = await project_factory(target_parts_count=20)
+
+        # 2 actually printed (completed), 3 just archived (not printed yet)
+        await archive_factory(project_id=project.id, quantity=2, status="completed")
+        await archive_factory(project_id=project.id, quantity=3, status="archived")
+        await archive_factory(project_id=project.id, quantity=5, status="archived")
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+
+        # Only the completed archive should count
+        assert data["stats"]["completed_prints"] == 2
+        assert data["stats"]["parts_progress_percent"] == 10.0  # 2/20 = 10%
+        assert data["stats"]["remaining_parts"] == 18
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archived_files_not_counted_in_project_list(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Project list endpoint should not count archived files as completed."""
+        project = await project_factory(name="List Archived Test", target_parts_count=50)
+
+        await archive_factory(project_id=project.id, quantity=4, status="completed")
+        await archive_factory(project_id=project.id, quantity=6, status="archived")
+
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        data = response.json()
+
+        our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
+        assert our_project is not None
+        assert our_project["completed_count"] == 4  # Only completed, not archived
+        assert our_project["archive_count"] == 2  # Both archives exist as plates
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_only_completed_status_counts(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Only 'completed' status should count in stats, not archived/failed/etc."""
+        project = await project_factory(target_parts_count=100)
+
+        await archive_factory(project_id=project.id, quantity=10, status="completed")
+        await archive_factory(project_id=project.id, quantity=5, status="archived")
+        await archive_factory(project_id=project.id, quantity=3, status="failed")
+        await archive_factory(project_id=project.id, quantity=2, status="aborted")
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+
+        assert data["stats"]["completed_prints"] == 10  # Only "completed"
+        assert data["stats"]["failed_prints"] == 2  # failed + aborted (count of archives, not sum)
+        assert data["stats"]["total_archives"] == 4  # All archives
+        assert data["stats"]["total_items"] == 20  # Sum of all quantities
+
+
 class TestProjectArchivesAPI:
     """Tests for project-archive relationships."""
 

+ 21 - 0
backend/tests/integration/test_settings_api.py

@@ -166,6 +166,27 @@ class TestSettingsAPI:
         assert result["ams_temp_good"] == 25.0
         assert result["ams_temp_fair"] == 32.0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_low_stock_threshold(self, async_client: AsyncClient):
+        """Verify low stock threshold setting can be updated."""
+        # Get default value
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 20.0
+
+        # Update to custom value
+        response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5})
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["low_stock_threshold"] == 15.5
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 15.5
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_notification_language(self, async_client: AsyncClient):

+ 743 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -0,0 +1,743 @@
+"""Integration tests for SpoolBuddy API endpoints."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+def device_factory(db_session: AsyncSession):
+    """Factory to create SpoolBuddyDevice records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        n = _counter[0]
+        defaults = {
+            "device_id": f"sb-{n:04d}",
+            "hostname": f"spoolbuddy-{n}",
+            "ip_address": f"10.0.0.{n}",
+            "firmware_version": "1.0.0",
+            "has_nfc": True,
+            "has_scale": True,
+            "tare_offset": 0,
+            "calibration_factor": 1.0,
+            "last_seen": datetime.now(timezone.utc),
+        }
+        defaults.update(kwargs)
+        device = SpoolBuddyDevice(**defaults)
+        db_session.add(device)
+        await db_session.commit()
+        await db_session.refresh(device)
+        return device
+
+    return _create
+
+
+@pytest.fixture
+def spool_factory(db_session: AsyncSession):
+    """Factory to create Spool records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Polymaker",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+# ============================================================================
+# Device endpoints
+# ============================================================================
+
+
+class TestDeviceEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_register_new_device(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-new",
+                    "hostname": "spoolbuddy-new",
+                    "ip_address": "10.0.0.99",
+                    "firmware_version": "1.2.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["device_id"] == "sb-new"
+        assert data["hostname"] == "spoolbuddy-new"
+        assert data["online"] is True
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(
+            device_id="sb-exist",
+            tare_offset=12345,
+            calibration_factor=0.0042,
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-exist",
+                    "hostname": "updated-host",
+                    "ip_address": "10.0.0.200",
+                    "firmware_version": "2.0.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["id"] == device.id
+        assert data["hostname"] == "updated-host"
+        assert data["ip_address"] == "10.0.0.200"
+        assert data["firmware_version"] == "2.0.0"
+        # Calibration preserved on re-register
+        assert data["tare_offset"] == 12345
+        assert data["calibration_factor"] == pytest.approx(0.0042)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices_empty(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-a", hostname="alpha")
+        await device_factory(device_id="sb-b", hostname="beta")
+
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        devices = resp.json()
+        assert len(devices) == 2
+        hostnames = {d["hostname"] for d in devices}
+        assert hostnames == {"alpha", "beta"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-hb")
+
+        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-hb/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == device.tare_offset
+        assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-cmd", pending_command="tare")
+
+        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-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert resp.status_code == 200
+        assert resp.json()["pending_command"] == "tare"
+
+        # Second heartbeat should have no pending command (cleared)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp2 = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+
+        assert resp2.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/nonexistent/heartbeat",
+                json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
+            )
+
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    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)
+        await device_factory(
+            device_id="sb-offline",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        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-offline/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
+            )
+
+        assert resp.status_code == 200
+        # Should broadcast online since device was offline
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-offline"
+
+
+# ============================================================================
+# NFC endpoints
+# ============================================================================
+
+
+class TestNfcEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tag_uid="AABB1122", material="PLA")
+        mock_spool = MagicMock()
+        mock_spool.id = spool.id
+        mock_spool.material = spool.material
+        mock_spool.subtype = spool.subtype
+        mock_spool.color_name = spool.color_name
+        mock_spool.rgba = spool.rgba
+        mock_spool.brand = spool.brand
+        mock_spool.label_weight = spool.label_weight
+        mock_spool.core_weight = spool.core_weight
+        mock_spool.weight_used = spool.weight_used
+
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = mock_spool
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is True
+        assert data["spool_id"] == spool.id
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = None
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is False
+        assert data["spool_id"] is None
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_removed(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-removed",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_removed"
+        assert msg["device_id"] == "sb-1"
+        assert msg["tag_uid"] == "AABB1122"
+
+
+# ============================================================================
+# NFC write-tag endpoints
+# ============================================================================
+
+
+class TestWriteTagEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-wt")
+        spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "queued"
+
+        # Verify heartbeat returns write_tag command with payload
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        hb_data = hb.json()
+        assert hb_data["pending_command"] == "write_tag"
+        assert hb_data["pending_write_payload"] is not None
+        assert hb_data["pending_write_payload"]["spool_id"] == spool.id
+        assert "ndef_data_hex" in hb_data["pending_write_payload"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
+        """write_tag command persists across heartbeats until write-result clears it."""
+        device = await device_factory(device_id="sb-wt-persist")
+        spool = await spool_factory(material="PETG")
+
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        # First heartbeat — command present
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb1 = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+        assert hb1.json()["pending_command"] == "write_tag"
+
+        # Second heartbeat — should still be present (not cleared like tare)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb2 = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+        assert hb2.json()["pending_command"] == "write_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-wt-nospool")
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": 99999},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": "nonexistent", "spool_id": spool.id},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-wr", pending_command="write_tag")
+        spool = await spool_factory(material="PLA", tag_uid=None)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "04AABB11223344",
+                    "success": True,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_written"
+        assert msg["spool_id"] == spool.id
+        assert msg["tag_uid"] == "04AABB11223344"
+
+        # Verify spool got tag linked
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        spool_data = spool_resp.json()
+        assert spool_data["tag_uid"] == "04AABB11223344"
+        assert spool_data["tag_type"] == "ntag"
+        assert spool_data["data_origin"] == "opentag3d"
+        assert spool_data["encode_time"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_failure_broadcasts_error(
+        self, async_client: AsyncClient, device_factory, spool_factory
+    ):
+        device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
+        spool = await spool_factory(material="PLA", tag_uid=None)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "04AABB",
+                    "success": False,
+                    "message": "Write or verification failed",
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_write_failed"
+        assert msg["message"] == "Write or verification failed"
+
+        # Verify spool NOT linked
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        assert spool_resp.json()["tag_uid"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(
+            device_id="sb-wr-clear",
+            pending_command="write_tag",
+            pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
+        )
+        spool = await spool_factory()
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "AABB",
+                    "success": True,
+                },
+            )
+
+        # Heartbeat should have no pending 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/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
+            )
+        assert hb.json()["pending_command"] is None
+        assert hb.json()["pending_write_payload"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-cancel")
+        spool = await spool_factory()
+
+        # Queue a write
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        # Cancel it
+        resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
+        assert resp.status_code == 200
+
+        # Heartbeat should have no pending 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/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+        assert hb.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
+        """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
+        device = await device_factory(device_id="sb-wt-ndef")
+        spool = await spool_factory(
+            material="PLA",
+            brand="Polymaker",
+            color_name="White",
+            rgba="FFFFFFFF",
+            label_weight=1000,
+        )
+
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        payload = hb.json()["pending_write_payload"]
+        ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
+
+        # CC bytes
+        assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        # TLV type
+        assert ndef_bytes[4] == 0x03
+        # NDEF record: TNF=MIME, type=application/opentag3d
+        assert ndef_bytes[6] == 0xD2
+        assert ndef_bytes[9:30] == b"application/opentag3d"
+        # Terminator
+        assert ndef_bytes[-1] == 0xFE
+        # Total size fits NTAG213
+        assert len(ndef_bytes) <= 144
+
+
+# ============================================================================
+# Scale endpoints
+# ============================================================================
+
+
+class TestScaleEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scale_reading_broadcast(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/scale/reading",
+                json={
+                    "device_id": "sb-1",
+                    "weight_grams": 823.5,
+                    "stable": True,
+                    "raw_adc": 456789,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_weight"
+        assert msg["device_id"] == "sb-1"
+        assert msg["weight_grams"] == 823.5
+        assert msg["stable"] is True
+        assert msg["raw_adc"] == 456789
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 750g
+        # net_filament = max(0, 750 - 250) = 500
+        # weight_used = max(0, 1000 - 500) = 500
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 750},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 500
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 1250g (full spool)
+        # net_filament = max(0, 1250 - 250) = 1000
+        # weight_used = max(0, 1000 - 1000) = 0
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 1250},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
+        """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 750},
+        )
+        assert resp.status_code == 200
+
+        # Fetch the spool via inventory API to verify stored fields
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        assert spool_resp.status_code == 200
+        spool_data = spool_resp.json()
+        assert spool_data["last_scale_weight"] == 750
+        assert spool_data["last_weighed_at"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": 99999, "weight_grams": 500},
+        )
+        assert resp.status_code == 404
+
+
+# ============================================================================
+# Calibration endpoints
+# ============================================================================
+
+
+class TestCalibrationEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-tare")
+
+        resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "ok"
+
+        # Verify pending_command via heartbeat
+        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-tare/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
+            )
+        assert hb.json()["pending_command"] == "tare"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-st", calibration_factor=0.005)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-st/calibration/set-tare",
+            json={"tare_offset": 54321},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 54321
+        assert data["calibration_factor"] == pytest.approx(0.005)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
+        # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
+        await device_factory(device_id="sb-cf", tare_offset=10000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-cf/calibration/set-factor",
+            json={"known_weight_grams": 200, "raw_adc": 50000},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["calibration_factor"] == pytest.approx(0.005)
+        assert data["tare_offset"] == 10000
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
+        # raw_adc == tare_offset → delta is 0 → 400 error
+        await device_factory(device_id="sb-zero", tare_offset=5000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-zero/calibration/set-factor",
+            json={"known_weight_grams": 100, "raw_adc": 5000},
+        )
+
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_calibration(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-gcal",
+            tare_offset=11111,
+            calibration_factor=0.0042,
+        )
+
+        resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 11111
+        assert data["calibration_factor"] == pytest.approx(0.0042)

+ 19 - 1
backend/tests/integration/test_spoolman_api.py

@@ -430,7 +430,7 @@ class TestSpoolmanAPI:
             json={"tray_uuid": "ABC123"},  # Too short
         )
         assert response.status_code == 400
-        assert "32 hex characters" in response.json()["detail"]
+        assert "16 or 32 hex characters" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -465,6 +465,24 @@ class TestSpoolmanAPI:
         # Verify update_spool was called
         mock_spoolman_client.update_spool.assert_called_once()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unlink_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify successfully unlinking a spool clears extra.tag."""
+        mock_spoolman_client.update_spool = AsyncMock(return_value={"id": 1, "extra": {"tag": '""'}})
+
+        response = await async_client.post("/api/v1/spoolman/spools/1/unlink")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        assert "unlinked" in data["message"].lower()
+
+        mock_spoolman_client.update_spool.assert_called_once_with(
+            spool_id=1,
+            clear_location=True,
+            extra={"tag": '""'},
+        )
+
     # =========================================================================
     # Sync Tests
     # =========================================================================

+ 68 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -270,3 +270,71 @@ class TestPendingUploadsAPI:
         assert response.status_code == 200
         result = response.json()
         assert "discarded" in result
+
+
+class TestVirtualPrinterAutoDispatchAPI:
+    """Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):
+        """Verify creating a VP without auto_dispatch defaults to true."""
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestDefaultDispatch",
+                "mode": "print_queue",
+                "access_code": "12345678",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_dispatch"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):
+        """Verify creating a VP with auto_dispatch=false persists correctly."""
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestManualDispatch",
+                "mode": "print_queue",
+                "access_code": "12345678",
+                "auto_dispatch": False,
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_dispatch"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):
+        """Verify auto_dispatch can be toggled via PUT and persists."""
+        # Create with auto_dispatch=True (default)
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestToggleDispatch",
+                "mode": "print_queue",
+                "access_code": "12345678",
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        # Update to auto_dispatch=False
+        update_resp = await async_client.put(
+            f"/api/v1/virtual-printers/{vp_id}",
+            json={"auto_dispatch": False},
+        )
+        assert update_resp.status_code == 200
+        assert update_resp.json()["auto_dispatch"] is False
+
+        # Verify it persists by fetching
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
+        assert get_resp.status_code == 200
+        assert get_resp.json()["auto_dispatch"] is False

+ 29 - 48
backend/tests/unit/services/test_bambu_ftp.py

@@ -28,7 +28,7 @@ from backend.app.services.bambu_ftp import (
 )
 
 # Brief delay to allow pyftpdlib to flush uploaded files to disk.
-# Needed because upload_file() skips voidresp() for A1 compatibility,
+# Needed because upload_file() skips voidresp() for all models,
 # so the server may still be processing the data channel close event.
 _UPLOAD_FLUSH_DELAY = 0.3
 
@@ -306,8 +306,8 @@ class TestUpload:
         result = client.upload_file(local, "/cache/upload.3mf")
         assert result is True
         client.disconnect()
-        # Verify via fresh connection (upload_file skips voidresp()
-        # so the original session can't be reused for download)
+        # Verify via fresh connection (upload_file skips voidresp() for all
+        # models, so the original session can't be reused for download)
         time.sleep(_UPLOAD_FLUSH_DELAY)
         client2 = ftp_client_factory()
         client2.connect()
@@ -403,11 +403,11 @@ class TestUpload:
     def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
         """Large file upload in chunks completes without error.
 
-        Uses 2.5MB to trigger multiple chunks with 1MB CHUNK_SIZE.
-        Content verification skipped because upload_file() doesn't call
-        voidresp() (for A1 compatibility), so the server may still be
-        flushing when we check. The upload result=True confirms the
-        client sent all chunks without error.
+        Uses 2.5MB to trigger multiple chunks with 64KB CHUNK_SIZE.
+        Content verification skipped because upload_file() skips
+        voidresp() for all models, so the server may still be flushing
+        when we check. The upload result=True confirms the client sent
+        all chunks without error.
         """
         content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
         local = tmp_path / "large.bin"
@@ -422,8 +422,8 @@ class TestUpload:
         client.connect()
         result = client.upload_file(local, "/cache/large.bin", on_progress)
         assert result is True
-        # Verify multiple chunks were sent
-        assert len(progress_calls) >= 3  # 2.5MB / 1MB = at least 3 chunks
+        # Verify many chunks were sent (2.5MB / 64KB = 40 chunks)
+        assert len(progress_calls) >= 38
         assert progress_calls[-1][0] == len(content)
         client.disconnect()
 
@@ -871,46 +871,27 @@ class TestFailureScenarios:
         assert result2 == b"data after retry"
         client.disconnect()
 
-    def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
-        """Upload returns True even when voidresp() gets a non-clean response.
+    def test_upload_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
+        """Upload returns True without calling voidresp() for any model.
 
-        Regression: Previously, a voidresp() error after successful data transfer
-        returned False, which caused with_ftp_retry to re-upload the entire file
-        in a loop.
+        voidresp() is skipped for all models: A1 printers hang on it,
+        H2D printers delay the 226 response by 30+ seconds, and X1C/P1S
+        gain nothing from waiting. The file is on the SD card once
+        sendall() returns.
         """
         content = b"voidresp test data"
         local = tmp_path / "voidresp_test.3mf"
         local.write_bytes(content)
-        client = ftp_client_factory(printer_model="X1C")
-        client.connect()
-        result = client.upload_file(local, "/cache/voidresp_test.3mf")
-        assert result is True
-        client.disconnect()
-        # Verify the file is actually on the server
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/voidresp_test.3mf")
-        assert downloaded == content
-        client2.disconnect()
-
-    def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
-        """A1 models skip voidresp() entirely and still return True.
-
-        Regression: A1 printers hang on voidresp() after transfercmd uploads.
-        """
-        content = b"A1 upload test"
-        local = tmp_path / "a1_test.3mf"
-        local.write_bytes(content)
-        client = ftp_client_factory(printer_model="A1")
-        client.connect()
-        result = client.upload_file(local, "/cache/a1_test.3mf")
-        assert result is True
-        client.disconnect()
-        # Verify the file is actually on the server
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/a1_test.3mf")
-        assert downloaded == content
-        client2.disconnect()
+        for model in ("X1C", "A1", "H2D", None):
+            client = ftp_client_factory(printer_model=model)
+            client.connect()
+            result = client.upload_file(local, "/cache/voidresp_test.3mf")
+            assert result is True, f"Upload failed for model={model}"
+            client.disconnect()
+            # Verify the file is actually on the server
+            time.sleep(_UPLOAD_FLUSH_DELAY)
+            client2 = ftp_client_factory()
+            client2.connect()
+            downloaded = client2.download_file("/cache/voidresp_test.3mf")
+            assert downloaded == content, f"Content mismatch for model={model}"
+            client2.disconnect()

+ 81 - 8
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1550,10 +1550,11 @@ class TestTrayNowDualNozzleH2DSetup:
         assert mqtt_client._is_dual_nozzle is True
 
     def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
-        """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
+        """AMS info field is hex: 0x2003 → ext 0 (right), 0x2104 → ext 1 (left)."""
+        # MQTT sends info as string; BambuStudio parses as hex via stoull(str, 16)
         ams_units = [
-            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
-            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+            {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": "2104", "tray": [{"id": 0}]},
         ]
         payload = {
             "print": {
@@ -1566,8 +1567,80 @@ class TestTrayNowDualNozzleH2DSetup:
         }
         mqtt_client._process_message(payload)
 
-        # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
-        # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
+        # 0x2003: bits 8-11 = (0x2003 >> 8) & 0xF = 0x20 & 0xF = 0 → extruder 0 (right)
+        # 0x2104: bits 8-11 = (0x2104 >> 8) & 0xF = 0x21 & 0xF = 1 → extruder 1 (left)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_ams_extruder_map_real_h2d_values(self, mqtt_client):
+        """Real H2D MQTT values: AMS2 Pro on right, AMS-HT on left."""
+        ams_units = [
+            {"id": 0, "info": "10001003", "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": "10002104", "tray": [{"id": 0}]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000a",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # 0x10001003: bits 8-11 = (0x10001003 >> 8) & 0xF = 0x10 & 0xF = 0 → right
+        # 0x10002104: bits 8-11 = (0x10002104 >> 8) & 0xF = 0x21 & 0xF = 1 → left
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_ams_extruder_map_skips_uninitialized(self, mqtt_client):
+        """extruder_id 0xE means uninitialized AMS — should be skipped."""
+        ams_units = [
+            {"id": 0, "info": "e03", "tray": [{"id": i} for i in range(4)]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.ams_extruder_map == {}
+
+    def test_ams_extruder_map_partial_update_preserves_entries(self, mqtt_client):
+        """Partial MQTT update with one AMS should not overwrite other entries."""
+        # First: full update with both AMS units
+        full_payload = {
+            "print": {
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+                        {"id": 128, "info": "2104", "tray": [{"id": 0}]},
+                    ],
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(full_payload)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+        # Then: partial update with only AMS 0 (no info field this time)
+        partial_payload = {
+            "print": {
+                "ams": {
+                    "ams": [
+                        {"id": 0, "tray": [{"id": 0, "remain": 50}]},
+                    ],
+                    "tray_now": "0",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(partial_payload)
+        # Both entries should still be present
         assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
 
     def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
@@ -1588,7 +1661,7 @@ class TestTrayNowDualNozzleH2DSetup:
                 },
                 "ams": {
                     "ams": [
-                        {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                        {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
                     ],
                     "tray_now": "2",
                     "tray_exist_bits": "f",
@@ -1639,8 +1712,8 @@ class _H2DFixtureMixin:
                     },
                     "ams": {
                         "ams": [
-                            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
-                            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+                            {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+                            {"id": 128, "info": "2104", "tray": [{"id": 0}]},
                         ],
                         "tray_now": "255",
                         "tray_exist_bits": "1000f",

+ 155 - 0
backend/tests/unit/services/test_camera_tls_proxy.py

@@ -0,0 +1,155 @@
+"""Tests for the camera TLS proxy and RTSP URL rewriting."""
+
+import asyncio
+
+import pytest
+
+from backend.app.services.camera import create_tls_proxy, rewrite_rtsp_request_url
+
+
+class TestRewriteRtspRequestUrl:
+    """Tests for RTSP request-line URL rewriting."""
+
+    def test_rewrites_describe_request_line(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\nCSeq: 1\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        assert b"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\r\n" in result
+
+    def test_rewrites_setup_request_line(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"SETUP rtsp://127.0.0.1:45221/streaming/live/1/trackID=0 RTSP/1.0\r\nCSeq: 3\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        assert b"SETUP rtsps://192.168.1.100:322/streaming/live/1/trackID=0 RTSP/1.0\r\n" in result
+
+    def test_rewrites_play_request_line(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"PLAY rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\nCSeq: 5\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        assert b"PLAY rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\r\n" in result
+
+    def test_preserves_authorization_header(self):
+        """Digest auth embeds the URI in a hash — rewriting it breaks auth."""
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = (
+            b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\n"
+            b"CSeq: 2\r\n"
+            b'Authorization: Digest username="bblp", '
+            b'uri="rtsp://127.0.0.1:45221/streaming/live/1", '
+            b'response="abc123"\r\n'
+            b"\r\n"
+        )
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        # Request line IS rewritten
+        assert b"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\r\n" in result
+        # Authorization header is NOT rewritten
+        assert b'uri="rtsp://127.0.0.1:45221/streaming/live/1"' in result
+        assert b'response="abc123"' in result
+
+    def test_no_rewrite_on_non_rtsp_data(self):
+        """Binary RTP data and other non-RTSP data should pass through unchanged."""
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        # Interleaved RTP data (starts with $)
+        data = b"$\x00\x00\x10" + b"\x00" * 16
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+        assert result == data
+
+    def test_no_rewrite_on_empty_data(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        assert rewrite_rtsp_request_url(b"", proxy_url, real_url) == b""
+
+    def test_only_first_rtsp_line_rewritten(self):
+        """If somehow multiple RTSP/1.0 lines exist, only the first is rewritten."""
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = (
+            b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\n"
+            b"CSeq: 1\r\n"
+            b"X-Custom: rtsp://127.0.0.1:45221/other RTSP/1.0\r\n"
+            b"\r\n"
+        )
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        lines = result.split(b"\r\n")
+        # First line rewritten
+        assert lines[0] == b"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0"
+        # Hypothetical other line NOT rewritten
+        assert lines[2] == b"X-Custom: rtsp://127.0.0.1:45221/other RTSP/1.0"
+
+    def test_preserves_crlf_structure(self):
+        proxy_url = b"rtsp://127.0.0.1:45221"
+        real_url = b"rtsps://192.168.1.100:322"
+
+        data = b"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\r\nCSeq: 1\r\n\r\n"
+        result = rewrite_rtsp_request_url(data, proxy_url, real_url)
+
+        # Must still end with double CRLF (empty line terminates headers)
+        assert result.endswith(b"\r\n\r\n")
+        # Must have CSeq intact
+        assert b"CSeq: 1\r\n" in result
+
+
+class TestCreateTlsProxy:
+    """Tests for TLS proxy server lifecycle."""
+
+    @pytest.mark.asyncio
+    async def test_proxy_returns_port_and_server(self):
+        """Verify proxy creates a listening server on an ephemeral port."""
+        # Use a non-routable target — we just test the server starts, not the TLS connection
+        port, server = await create_tls_proxy("192.0.2.1", 322)
+
+        assert isinstance(port, int)
+        assert port > 0
+        assert server.is_serving()
+
+        server.close()
+        await server.wait_closed()
+
+    @pytest.mark.asyncio
+    async def test_proxy_accepts_connection(self):
+        """Verify proxy accepts TCP connections (TLS to target will fail, but accept works)."""
+        port, server = await create_tls_proxy("192.0.2.1", 322)
+
+        try:
+            # Connect to the proxy — it should accept the connection
+            reader, writer = await asyncio.wait_for(
+                asyncio.open_connection("127.0.0.1", port),
+                timeout=2.0,
+            )
+            # The proxy will try to connect to 192.0.2.1:322 (non-routable), fail,
+            # and close our connection. That's expected.
+            writer.close()
+            await writer.wait_closed()
+        except (ConnectionError, TimeoutError):
+            pass  # Expected — target is unreachable
+
+        server.close()
+        await server.wait_closed()
+
+    @pytest.mark.asyncio
+    async def test_proxy_cleanup(self):
+        """Verify proxy stops serving after close."""
+        port, server = await create_tls_proxy("192.0.2.1", 322)
+        assert server.is_serving()
+
+        server.close()
+        await server.wait_closed()
+
+        assert not server.is_serving()

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

@@ -589,6 +589,154 @@ class TestNotificationProviderTypes:
             assert "source" not in payload
 
 
+class TestHomeAssistantProvider:
+    """Tests for Home Assistant notification provider."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_success(self, service):
+        """Verify HA provider sends persistent notification to correct endpoint."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            success, message = await service._send_homeassistant({}, "Test Title", "Test Message", db=mock_db)
+
+            assert success is True
+            mock_client.post.assert_called_once()
+            call_args = mock_client.post.call_args
+            assert call_args[0][0] == "http://ha.local:8123/api/services/persistent_notification/create"
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert payload["title"] == "Test Title"
+            assert payload["message"] == "Test Message"
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_no_db_no_env(self, service):
+        """Verify HA provider fails gracefully without DB or env vars."""
+        with patch.dict("os.environ", {}, clear=True):
+            success, message = await service._send_homeassistant({}, "Test", "Test", db=None)
+
+        assert success is False
+        assert "not configured" in message.lower()
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_auth_failure(self, service):
+        """Verify HA provider reports auth failure."""
+        mock_response = MagicMock()
+        mock_response.status_code = 401
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "bad-token",
+                "ha_enabled": True,
+            }
+
+            success, message = await service._send_homeassistant({}, "Test", "Test", db=mock_db)
+
+        assert success is False
+        assert "authentication" in message.lower()
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_env_fallback(self, service):
+        """Verify HA provider falls back to env vars when no DB session."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch.dict("os.environ", {"HA_URL": "http://env-ha:8123", "HA_TOKEN": "env-token"}),
+        ):
+            mock_get_client.return_value = mock_client
+
+            success, message = await service._send_homeassistant({}, "Test", "Test", db=None)
+
+        assert success is True
+        call_args = mock_client.post.call_args
+        assert "env-ha:8123" in call_args[0][0]
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_empty_config_accepted(self, service):
+        """Verify HA provider works with empty config dict (no fields needed)."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "token",
+                "ha_enabled": True,
+            }
+
+            success, _ = await service._send_homeassistant({}, "Title", "Body", db=mock_db)
+
+        assert success is True
+
+    @pytest.mark.asyncio
+    async def test_send_to_provider_dispatches_homeassistant(self, service):
+        """Verify _send_to_provider dispatches to _send_homeassistant."""
+        provider = MagicMock()
+        provider.provider_type = "homeassistant"
+        provider.config = "{}"
+        provider.quiet_hours_enabled = False
+
+        with patch.object(service, "_send_homeassistant", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+
+            success, _ = await service._send_to_provider(provider, "Title", "Message", db=AsyncMock())
+
+        assert success is True
+        mock_send.assert_called_once()
+
+
 class TestNotificationVariableFallbacks:
     """Tests for notification variable fallback values."""
 
@@ -738,6 +886,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
@@ -772,6 +921,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
@@ -837,6 +987,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             mock_get.return_value = [mock_provider]
 
@@ -869,6 +1020,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             mock_get.return_value = [mock_provider]
 
@@ -905,6 +1057,7 @@ class TestNotificationVariableFallbacks:
             patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
             patch.object(service, "_send_to_providers", new_callable=AsyncMock),
             patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
         ):
             mock_get.return_value = [mock_provider]
 
@@ -923,6 +1076,105 @@ class TestNotificationVariableFallbacks:
             # Should use MQTT remaining_time
             assert captured_variables.get("estimated_time") == "30m"
 
+    @pytest.mark.asyncio
+    async def test_print_start_eta_calculated_from_estimated_time(self, service):
+        """Verify ETA is calculated as wall-clock time from estimated_time."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test",
+                data={"subtask_name": "test"},
+                db=mock_db,
+                archive_data={"print_time_seconds": 3600},  # 1 hour
+            )
+
+            # ETA should be a time string in HH:MM format
+            eta = captured_variables.get("eta")
+            assert eta is not None
+            assert eta != "Unknown"
+            assert ":" in eta  # HH:MM format
+
+    @pytest.mark.asyncio
+    async def test_print_start_eta_unknown_when_no_time(self, service):
+        """Verify ETA shows 'Unknown' when no time data available."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test",
+                data={"subtask_name": "test"},
+                db=mock_db,
+            )
+
+            assert captured_variables.get("eta") == "Unknown"
+
+    @pytest.mark.asyncio
+    async def test_print_start_eta_respects_12h_format(self, service):
+        """Verify ETA uses 12-hour format when time_format is '12h'."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value="12h"),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test",
+                data={"subtask_name": "test"},
+                db=mock_db,
+                archive_data={"print_time_seconds": 3600},
+            )
+
+            eta = captured_variables.get("eta")
+            assert eta is not None
+            # 12h format should contain AM or PM
+            assert "AM" in eta or "PM" in eta
+
 
 class TestNotificationTemplates:
     """Tests for notification message template rendering."""
@@ -1321,3 +1573,127 @@ class TestBedCooledNotifications:
             )
 
             assert captured_variables["filename"] == "Unknown"
+
+
+class TestFirstLayerCompleteNotifications:
+    """Tests for first layer complete notifications."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider with first layer complete enabled."""
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_first_layer_complete = True
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_sends_notification(self, service, mock_provider, mock_db):
+        """Verify first layer complete notification is sent when triggered."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("First Layer Complete", "Test Printer: benchy.3mf")
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="Test Printer",
+                filename="benchy.3mf",
+                total_layers=50,
+                db=mock_db,
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_skipped_when_no_providers(self, service, mock_db):
+        """Verify notification is skipped when no providers have first layer complete enabled."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            mock_get.return_value = []
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="Test Printer",
+                filename="benchy.3mf",
+                total_layers=50,
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_includes_correct_variables(self, service, mock_provider, mock_db):
+        """Verify printer name, filename, and total_layers are passed to template variables."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                filename="benchy.gcode.3mf",
+                total_layers=120,
+                db=mock_db,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["filename"] == "benchy"
+            assert captured_variables["total_layers"] == "120"
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_passes_image_data(self, service, mock_provider, mock_db):
+        """Verify image_data is passed through to _send_to_providers."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("First Layer Complete", "Test message")
+            fake_image = b"\x89PNG\r\n\x1a\nfakeimage"
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="Test Printer",
+                filename="benchy.3mf",
+                total_layers=50,
+                db=mock_db,
+                image_data=fake_image,
+            )
+
+            mock_send.assert_called_once()
+            call_kwargs = mock_send.call_args
+            assert call_kwargs.kwargs.get("image_data") == fake_image

+ 122 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -15,6 +15,7 @@ from backend.app.services.printer_manager import (
     init_printer_connections,
     printer_state_to_dict,
     supports_chamber_temp,
+    supports_drying,
 )
 
 
@@ -673,6 +674,7 @@ class TestPrinterStateToDict:
         state.wifi_signal = -50
         state.raw_data = {}
         state.stg_cur = -1  # No calibration stage active
+        state.firmware_version = None
         return state
 
     def test_basic_conversion(self, mock_state):
@@ -885,6 +887,79 @@ class TestPrinterStateToDict:
         # Let's check the actual behavior
         assert "chamber" not in result["temperatures"]
 
+    def test_ams_drying_fields_included(self, mock_state):
+        """Verify AMS drying fields (dry_time, module_type) are included in output."""
+        mock_state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "dry_time": 42,
+                    "module_type": "n3f",
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_color": "FF0000",
+                            "tray_type": "PLA",
+                            "drying_temp": 55,
+                            "drying_time": 240,
+                        }
+                    ],
+                }
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        ams_unit = result["ams"][0]
+        assert ams_unit["dry_time"] == 42
+        assert ams_unit["module_type"] == "n3f"
+        # Tray-level drying fields
+        tray = ams_unit["tray"][0]
+        assert tray["drying_temp"] == 55
+        assert tray["drying_time"] == 240
+
+
+class TestStatusKeyDryingDedup:
+    """Regression tests for WebSocket dedup including drying fields.
+
+    The WebSocket broadcast deduplication uses printer_state_to_dict output
+    to detect changes. If drying fields (like dry_time) are missing from
+    the dict, changes to those fields won't trigger broadcasts.
+    """
+
+    def test_dry_time_change_changes_status_key(self):
+        """Verify dry_time is present in AMS unit data so dedup can detect changes."""
+        state = MagicMock()
+        state.connected = True
+        state.state = "IDLE"
+        state.current_print = None
+        state.subtask_name = None
+        state.gcode_file = None
+        state.progress = 0
+        state.remaining_time = 0
+        state.layer_num = 0
+        state.total_layers = 0
+        state.temperatures = {"nozzle": 25, "bed": 25}
+        state.hms_errors = []
+        state.ams_status_main = 0
+        state.ams_status_sub = 0
+        state.tray_now = None
+        state.wifi_signal = -50
+        state.stg_cur = -1
+
+        # First state: drying active with 30 minutes remaining
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 30, "module_type": "n3f", "tray": [{"id": 0}]}]}
+        result1 = printer_state_to_dict(state)
+
+        # Second state: drying time decreased
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 29, "module_type": "n3f", "tray": [{"id": 0}]}]}
+        result2 = printer_state_to_dict(state)
+
+        # The dicts should differ — dry_time changed
+        assert result1["ams"][0]["dry_time"] == 30
+        assert result2["ams"][0]["dry_time"] == 29
+        assert result1["ams"] != result2["ams"]
+
 
 class TestSupportsChamberTemp:
     """Tests for supports_chamber_temp helper function."""
@@ -955,6 +1030,53 @@ class TestSupportsChamberTemp:
         assert supports_chamber_temp("N1") is False
 
 
+class TestSupportsDrying:
+    """Tests for supports_drying helper function."""
+
+    def test_known_supported_with_firmware(self):
+        """Verify known models with sufficient firmware return True."""
+        assert supports_drying("X1C", "01.09.00.00") is True
+        assert supports_drying("P1S", "01.08.00.00") is True
+        assert supports_drying("H2D", "01.02.30.00") is True
+
+    def test_known_supported_old_firmware(self):
+        """Verify known models with old firmware return False."""
+        assert supports_drying("X1C", "01.08.00.00") is False
+        assert supports_drying("P1S", "01.07.00.00") is False
+
+    def test_known_supported_no_firmware(self):
+        """Verify known models with no firmware return False."""
+        assert supports_drying("X1C", None) is False
+
+    def test_unsupported_models(self):
+        """Verify models without AMS drying support return False regardless of firmware."""
+        for model in ["P2S", "A1", "A1MINI", "A1-MINI", "H2S", "H2C", "N7", "N1", "N2S"]:
+            assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
+
+    def test_unknown_models_allowed(self):
+        """Verify unknown models are allowed (graceful fallback).
+
+        Models not in the unsupported set AND not matching any known firmware-gated
+        model substring get the benefit of the doubt and return True.
+        "H2D Pro" contains "H2D" so it IS firmware-gated (needs firmware).
+        """
+        # Truly unknown models: no substring match in _DRYING_MIN_FIRMWARE
+        assert supports_drying("FUTURE_MODEL", None) is True
+        # X1E contains "X1" substring, so it IS firmware-gated
+        assert supports_drying("X1E", "01.09.00.00") is True
+        # H2D Pro contains "H2D" substring, so it IS firmware-gated
+        assert supports_drying("H2D Pro", "01.02.30.00") is True
+
+    def test_none_model(self):
+        """Verify None model returns False."""
+        assert supports_drying(None, "01.09.00.00") is False
+
+    def test_case_insensitive(self):
+        """Verify model matching is case-insensitive."""
+        assert supports_drying("x1c", "01.09.00.00") is True
+        assert supports_drying("p2s", "99.99.99.99") is False
+
+
 class TestGetDerivedStatusName:
     """Tests for get_derived_status_name function."""
 

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

@@ -325,6 +325,98 @@ class TestSmartPlugManager:
             mock_loop.assert_not_called()  # Should not call _schedule_loop
 
 
+class TestGetPlugForPrinter:
+    """Tests for _get_plug_for_printer with multiple plugs per printer."""
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.mark.asyncio
+    async def test_returns_none_when_no_plugs(self, manager):
+        """Verify None is returned when no plugs are linked to printer."""
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = []
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_returns_single_plug(self, manager):
+        """Verify single plug is returned directly."""
+        plug = MagicMock()
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [plug]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is plug
+
+    @pytest.mark.asyncio
+    async def test_prefers_main_plug_over_script(self, manager):
+        """Verify main power plug is returned when both main and script exist."""
+        script_plug = MagicMock()
+        script_plug.plug_type = "homeassistant"
+        script_plug.ha_entity_id = "script.pre_print"
+
+        main_plug = MagicMock()
+        main_plug.plug_type = "tasmota"
+        main_plug.ha_entity_id = None
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [script_plug, main_plug]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is main_plug
+
+    @pytest.mark.asyncio
+    async def test_handles_multiple_non_script_plugs(self, manager):
+        """Verify no crash when multiple non-script plugs exist (e.g., Tasmota + HA switch)."""
+        tasmota_plug = MagicMock()
+        tasmota_plug.plug_type = "tasmota"
+        tasmota_plug.ha_entity_id = None
+
+        ha_switch = MagicMock()
+        ha_switch.plug_type = "homeassistant"
+        ha_switch.ha_entity_id = "switch.bathroom"
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [tasmota_plug, ha_switch]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        # Should return first non-script plug (tasmota), not crash
+        assert result is tasmota_plug
+
+    @pytest.mark.asyncio
+    async def test_returns_first_script_when_all_are_scripts(self, manager):
+        """Verify first script is returned when only scripts are linked."""
+        script1 = MagicMock()
+        script1.plug_type = "homeassistant"
+        script1.ha_entity_id = "script.heat_chamber"
+
+        script2 = MagicMock()
+        script2.plug_type = "homeassistant"
+        script2.ha_entity_id = "script.exhaust_fan"
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [script1, script2]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is script1
+
+
 class TestScheduleLoop:
     """Tests for the schedule-based plug control."""
 

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

@@ -0,0 +1,354 @@
+"""Tests for spool_tag_matcher service — RFID auto-assign and relationship loading."""
+
+import pytest
+from sqlalchemy import inspect
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.spool_tag_matcher import (
+    auto_assign_spool,
+    create_spool_from_tray,
+    get_spool_by_tag,
+    is_bambu_tag,
+    is_valid_tag,
+)
+
+# -- helpers -----------------------------------------------------------------
+
+SAMPLE_TRAY = {
+    "tray_type": "PLA",
+    "tray_sub_brands": "PLA Basic",
+    "tray_color": "FFFFFFFF",
+    "tray_id_name": "",
+    "tag_uid": "AABBCCDD11223344",
+    "tray_uuid": "AABBCCDD11223344AABBCCDD11223344",
+    "tray_info_idx": "GFL99",
+    "nozzle_temp_min": 190,
+    "nozzle_temp_max": 230,
+    "tray_weight": "1000",
+    "remain": 80,
+}
+
+
+def _relationship_is_loaded(obj, attr_name: str) -> bool:
+    """Check if a relationship attribute has been eagerly loaded (not lazy)."""
+    return attr_name in inspect(obj).dict
+
+
+# -- is_valid_tag / is_bambu_tag --------------------------------------------
+
+
+def test_is_valid_tag_with_real_uid():
+    assert is_valid_tag("AABBCCDD11223344", "") is True
+
+
+def test_is_valid_tag_with_real_uuid():
+    assert is_valid_tag("", "AABBCCDD11223344AABBCCDD11223344") is True
+
+
+def test_is_valid_tag_all_zeros():
+    assert is_valid_tag("0000000000000000", "00000000000000000000000000000000") is False
+
+
+def test_is_valid_tag_empty():
+    assert is_valid_tag("", "") is False
+
+
+def test_is_bambu_tag_with_uuid():
+    assert is_bambu_tag("", "AABBCCDD11223344AABBCCDD11223344", "") is True
+
+
+def test_is_bambu_tag_with_uid_and_preset():
+    assert is_bambu_tag("AABBCCDD11223344", "", "GFL99") is True
+
+
+def test_is_bambu_tag_uid_only_no_preset():
+    """A tag UID alone (no UUID, no preset) is NOT considered a Bambu tag."""
+    assert is_bambu_tag("AABBCCDD11223344", "", "") is False
+
+
+# -- create_spool_from_tray -------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_basic(db_session):
+    """Created spool has correct material and tag fields."""
+    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    await db_session.commit()
+
+    assert spool.id is not None
+    assert spool.material == "PLA"
+    assert spool.brand == "Bambu Lab"
+    assert spool.tag_uid == "AABBCCDD11223344"
+    assert spool.tray_uuid == "AABBCCDD11223344AABBCCDD11223344"
+    assert spool.data_origin == "rfid_auto"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_weight_from_remain(db_session):
+    """weight_used is calculated from the AMS remain percentage."""
+    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    # remain=80 → 20% used → 200g of 1000g
+    assert spool.weight_used == 200.0
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_relationships_loaded(db_session):
+    """Both k_profiles and assignments must be eagerly initialized.
+
+    If these are lazy, db.add(SpoolAssignment(spool_id=spool.id)) triggers
+    a back_populates lazy load outside the async greenlet → greenlet_spawn error.
+    Regression test for #612.
+    """
+    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+
+    assert _relationship_is_loaded(spool, "k_profiles"), "k_profiles not eagerly initialized"
+    assert _relationship_is_loaded(spool, "assignments"), "assignments not eagerly initialized"
+    assert spool.k_profiles == []
+    assert spool.assignments == []
+
+
+# -- get_spool_by_tag -------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_by_uuid(db_session):
+    """Look up a spool by tray_uuid."""
+    spool = Spool(
+        material="PLA",
+        tray_uuid="AABBCCDD11223344AABBCCDD11223344",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await get_spool_by_tag(db_session, "", "AABBCCDD11223344AABBCCDD11223344")
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_by_uid(db_session):
+    """Fall back to tag_uid when tray_uuid doesn't match."""
+    spool = Spool(
+        material="PETG",
+        tag_uid="1122334455667788",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await get_spool_by_tag(db_session, "1122334455667788", "")
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_skips_archived(db_session):
+    """Archived spools are not returned."""
+    from datetime import datetime
+
+    spool = Spool(
+        material="PLA",
+        tray_uuid="AABBCCDD11223344AABBCCDD11223344",
+        label_weight=1000,
+        core_weight=250,
+        archived_at=datetime.now(),
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await get_spool_by_tag(db_session, "", "AABBCCDD11223344AABBCCDD11223344")
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_relationships_loaded(db_session):
+    """Both k_profiles and assignments must be eagerly loaded.
+
+    Regression test for #612 — without selectinload(Spool.assignments),
+    accessing spool.assignments after get_spool_by_tag triggers a lazy load
+    in async context → greenlet_spawn error.
+    """
+    spool = Spool(
+        material="PLA",
+        tray_uuid="AABBCCDD11223344AABBCCDD11223344",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    # Expire to clear in-session state — forces selectinload to actually load
+    db_session.expire(spool)
+
+    found = await get_spool_by_tag(db_session, "", "AABBCCDD11223344AABBCCDD11223344")
+    assert found is not None
+    assert _relationship_is_loaded(found, "k_profiles"), "k_profiles not eagerly loaded"
+    assert _relationship_is_loaded(found, "assignments"), "assignments not eagerly loaded"
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_returns_none_for_zeros(db_session):
+    """Zero-value tags return None."""
+    found = await get_spool_by_tag(db_session, "0000000000000000", "00000000000000000000000000000000")
+    assert found is None
+
+
+# -- auto_assign_spool (SpoolAssignment creation) ---------------------------
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_creates_assignment(db_session, printer_factory):
+    """auto_assign_spool creates a SpoolAssignment for the given slot."""
+    from unittest.mock import MagicMock
+
+    printer = await printer_factory()
+    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    await db_session.commit()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    assignment = await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=2,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+    )
+    await db_session.commit()
+
+    assert assignment.spool_id == spool.id
+    assert assignment.printer_id == printer.id
+    assert assignment.ams_id == 0
+    assert assignment.tray_id == 2
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_replaces_existing(db_session, printer_factory):
+    """auto_assign_spool removes old assignment for the same slot."""
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select
+
+    printer = await printer_factory()
+
+    # Create two spools
+    spool1 = Spool(material="PLA", label_weight=1000, core_weight=250)
+    spool1.k_profiles = []
+    spool1.assignments = []
+    db_session.add(spool1)
+    await db_session.flush()
+
+    spool2 = Spool(material="PETG", label_weight=1000, core_weight=250)
+    spool2.k_profiles = []
+    spool2.assignments = []
+    db_session.add(spool2)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    # Assign spool1 to slot
+    await auto_assign_spool(printer.id, 0, 0, spool1, mock_pm, db_session)
+    await db_session.commit()
+
+    # Assign spool2 to same slot — should replace
+    await auto_assign_spool(printer.id, 0, 0, spool2, mock_pm, db_session)
+    await db_session.commit()
+
+    result = await db_session.execute(
+        select(SpoolAssignment).where(
+            SpoolAssignment.printer_id == printer.id,
+            SpoolAssignment.ams_id == 0,
+            SpoolAssignment.tray_id == 0,
+        )
+    )
+    assignments = result.scalars().all()
+    assert len(assignments) == 1
+    assert assignments[0].spool_id == spool2.id
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_no_greenlet_error_new_spool(db_session, printer_factory):
+    """Creating a SpoolAssignment for a newly created spool must not trigger
+    a lazy load on spool.assignments (greenlet_spawn error).
+
+    Regression test for #612: db.add(SpoolAssignment) resolves
+    back_populates synchronously. If spool.assignments is uninitialized,
+    SQLAlchemy attempts a lazy load outside the async greenlet.
+    """
+    from unittest.mock import MagicMock
+
+    printer = await printer_factory()
+    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    # Don't commit yet — keep spool in same session state as production flow
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    # This must NOT raise MissingGreenlet / greenlet_spawn error
+    assignment = await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+    )
+    await db_session.commit()
+
+    assert assignment is not None
+    assert assignment.spool_id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_no_greenlet_error_existing_spool(db_session, printer_factory):
+    """Creating a SpoolAssignment for an existing spool (from get_spool_by_tag)
+    must not trigger a lazy load on spool.assignments.
+
+    Regression test for #612.
+    """
+    from unittest.mock import MagicMock
+
+    printer = await printer_factory()
+
+    # Create spool directly (simulating one that was created in a previous session)
+    spool = Spool(
+        material="PLA",
+        tray_uuid="AABBCCDD11223344AABBCCDD11223344",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    # Expire to clear in-session state — simulates fresh query
+    db_session.expire(spool)
+
+    # Look up via get_spool_by_tag (must eagerly load relationships)
+    found = await get_spool_by_tag(db_session, "", "AABBCCDD11223344AABBCCDD11223344")
+    assert found is not None
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    # This must NOT raise MissingGreenlet / greenlet_spawn error
+    assignment = await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+        spool=found,
+        printer_manager=mock_pm,
+        db=db_session,
+    )
+    await db_session.commit()
+
+    assert assignment is not None
+    assert assignment.spool_id == found.id

+ 99 - 0
backend/tests/unit/services/test_spoolman_tracking.py

@@ -1,9 +1,18 @@
 """Unit tests for Spoolman tracking service helpers."""
 
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
 from backend.app.services.spoolman_tracking import (
+    _get_fallback_spool_tag,
+    _global_tray_id_to_ams_slot,
+    _hash_serial_to_hex32,
     _resolve_global_tray_id,
     _resolve_spool_tag,
     build_ams_tray_lookup,
+    store_print_data,
 )
 
 
@@ -22,6 +31,20 @@ class TestResolveSpoolTag:
         tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "DEADBEEF"
 
+    def test_rejects_zero_tag_uid(self):
+        tray = {"tray_uuid": "", "tag_uid": "0000000000000000"}
+        assert _resolve_spool_tag(tray) == ""
+
+    def test_uses_fallback_tag_when_ids_missing(self):
+        tray = {"tray_uuid": "", "tag_uid": ""}
+        # global_tray_id 0 -> ams_id 0, tray_id 0
+        assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "ABA7845700000000"
+
+    def test_uses_fallback_tag_when_ids_zero(self):
+        tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "0000000000000000"}
+        # global_tray_id 5 -> ams_id 1, tray_id 1
+        assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001"
+
     def test_empty_both(self):
         tray = {"tray_uuid": "", "tag_uid": ""}
         assert _resolve_spool_tag(tray) == ""
@@ -65,6 +88,36 @@ class TestResolveGlobalTrayId:
         assert _resolve_global_tray_id(1, mapping) == 0
 
 
+class TestFallbackTagHelpers:
+    """Tests for frontend-mirrored fallback tag helpers."""
+
+    def test_hash_serial_matches_frontend_algorithm(self):
+        assert _hash_serial_to_hex32("01P00A000000000") == "ABA78457"
+        # Frontend trims and uppercases before hashing
+        assert _hash_serial_to_hex32(" 01p00a000000000 ") == "ABA78457"
+
+    def test_global_tray_to_ams_slot_standard_ams(self):
+        assert _global_tray_id_to_ams_slot(0) == (0, 0)
+        assert _global_tray_id_to_ams_slot(7) == (1, 3)
+
+    def test_global_tray_to_ams_slot_ams_ht(self):
+        assert _global_tray_id_to_ams_slot(128) == (128, 0)
+        assert _global_tray_id_to_ams_slot(135) == (135, 0)
+
+    def test_global_tray_to_ams_slot_external(self):
+        assert _global_tray_id_to_ams_slot(254) == (255, 0)
+        assert _global_tray_id_to_ams_slot(255) == (255, 1)
+
+    def test_get_fallback_spool_tag_standard(self):
+        assert _get_fallback_spool_tag("01P00A000000000", 5) == "ABA7845700010001"
+
+    def test_get_fallback_spool_tag_ams_ht(self):
+        assert _get_fallback_spool_tag("01P00A000000000", 128) == "ABA7845700800000"
+
+    def test_get_fallback_spool_tag_external(self):
+        assert _get_fallback_spool_tag("01P00A000000000", 255) == "ABA7845700FF0001"
+
+
 class TestBuildAmsTrayLookup:
     """Tests for build_ams_tray_lookup()."""
 
@@ -118,3 +171,49 @@ class TestBuildAmsTrayLookup:
         raw = {"ams": [{"id": 0, "tray": [{"id": 0}]}]}
         lookup = build_ams_tray_lookup(raw)
         assert lookup[0] == {"tray_uuid": "", "tag_uid": "", "tray_type": ""}
+
+
+class TestStorePrintData:
+    """Tests for store_print_data()."""
+
+    @pytest.mark.asyncio
+    async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
+        db = AsyncMock()
+        delete_result = MagicMock()
+        db.execute = AsyncMock(side_effect=[delete_result])
+        db.add = MagicMock()
+        db.commit = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1, "tray_type": "PLA"}]}]}
+        )
+
+        mock_settings = MagicMock()
+        mock_path = MagicMock()
+        mock_path.exists.return_value = True
+        mock_settings.base_dir.__truediv__.return_value = mock_path
+
+        with (
+            patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true", "true"])),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=[{"slot_id": 1, "used_g": 3.83, "type": "PLA", "color": "#FF0000"}],
+            ),
+            patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
+            patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
+        ):
+            await store_print_data(
+                printer_id=1,
+                archive_id=15,
+                file_path="archives/test.3mf",
+                db=db,
+                printer_manager=printer_manager,
+                ams_mapping=[1, -1, -1, -1],
+            )
+
+        db.add.assert_called_once()
+        tracking = db.add.call_args.args[0]
+        assert tracking.slot_to_tray == [1, -1, -1, -1]
+        db.execute.assert_called_once()

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

@@ -156,6 +156,127 @@ class TestVirtualPrinterInstance:
 
             assert "verify_job" not in instance._pending_files
 
+    # ========================================================================
+    # Tests for auto_dispatch
+    # ========================================================================
+
+    def test_auto_dispatch_defaults_to_true(self, tmp_path):
+        """Verify auto_dispatch defaults to True when not specified."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=10,
+            name="DefaultDispatch",
+            mode="print_queue",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800010",
+            base_dir=tmp_path,
+        )
+        assert inst.auto_dispatch is True
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path):
+        """Verify queue items have manual_start=False when auto_dispatch=True."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        added_items = []
+
+        def capture_add(item):
+            added_items.append(item)
+
+        mock_db.add = MagicMock(side_effect=capture_add)
+        mock_db.commit = AsyncMock()
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=11,
+            name="AutoDispatchOn",
+            mode="print_queue",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800011",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        # Create a temp 3mf file
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with patch(
+            "backend.app.services.archive.ArchiveService.archive_print",
+            new_callable=AsyncMock,
+            return_value=mock_archive,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.manual_start is False
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):
+        """Verify queue items have manual_start=True when auto_dispatch=False."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        added_items = []
+
+        def capture_add(item):
+            added_items.append(item)
+
+        mock_db.add = MagicMock(side_effect=capture_add)
+        mock_db.commit = AsyncMock()
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=12,
+            name="AutoDispatchOff",
+            mode="print_queue",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800012",
+            auto_dispatch=False,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        # Create a temp 3mf file
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with patch(
+            "backend.app.services.archive.ArchiveService.archive_print",
+            new_callable=AsyncMock,
+            return_value=mock_archive,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.manual_start is True
+
 
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""
@@ -310,6 +431,193 @@ class TestVirtualPrinterManager:
         await manager.stop_all()
         assert len(manager._instances) == 0
 
+    # ========================================================================
+    # Tests for sync_from_db config change detection
+    # ========================================================================
+
+    def _make_db_vp(self, **overrides):
+        """Create a mock VirtualPrinter DB object."""
+        defaults = {
+            "id": 1,
+            "name": "TestVP",
+            "enabled": True,
+            "mode": "immediate",
+            "model": "C11",
+            "access_code": "12345678",
+            "serial_suffix": "391800001",
+            "bind_ip": "",
+            "remote_interface_ip": "",
+            "target_printer_id": None,
+            "auto_dispatch": True,
+            "position": 0,
+        }
+        defaults.update(overrides)
+        vp = MagicMock()
+        for k, v in defaults.items():
+            setattr(vp, k, v)
+        return vp
+
+    def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
+        """Wire up session_factory mock for sync_from_db."""
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = enabled_vps
+
+        mock_db = AsyncMock()
+        mock_db.execute = AsyncMock(return_value=mock_result)
+        mock_db.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_db.__aexit__ = AsyncMock(return_value=False)
+
+        manager._session_factory = MagicMock(return_value=mock_db)
+        manager._base_dir = tmp_path
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when mode changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        # DB says mode changed to "archive"
+        db_vp = self._make_db_vp(mode="archive")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            # Patch VirtualPrinterInstance to prevent actual start
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when access_code changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(access_code="newcode99")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
+        """Verify sync_from_db does NOT restart when config is identical."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        manager._instances[1] = inst
+
+        # DB matches running config exactly
+        db_vp = self._make_db_vp()
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            await manager.sync_from_db()
+
+            mock_remove.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when bind_ip changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            bind_ip="192.168.1.10",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(bind_ip="192.168.1.20")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
+        """Verify sync_from_db restarts VP when model changes."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(model="C12")
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
 
 class TestFTPSession:
     """Tests for FTP session handling."""
@@ -1219,4 +1527,6 @@ class TestBindServer:
                 model="3DPrinter-X1-Carbon",
                 name="Bambuddy",
                 bind_address="192.168.1.50",
+                cert_path=Path("/tmp/cert.pem"),  # nosec B108
+                key_path=Path("/tmp/key.pem"),  # nosec B108
             )

+ 336 - 0
backend/tests/unit/test_bug_report.py

@@ -0,0 +1,336 @@
+"""Unit tests for bug report service and route."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestBugReportService:
+    """Tests for bug_report.submit_report()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_success(self):
+        """Successful relay call saves report and returns issue details."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "success": True,
+            "message": "Created",
+            "issue_url": "https://github.com/maziggy/bambuddy/issues/99",
+            "issue_number": 99,
+        }
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test bug",
+                reporter_email="user@test.com",
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is True
+        assert result["issue_number"] == 99
+        assert result["issue_url"] == "https://github.com/maziggy/bambuddy/issues/99"
+        mock_db.add.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_rate_limited(self):
+        """Returns failure when rate limit exceeded."""
+        import time
+
+        from backend.app.services.bug_report import submit_report
+
+        timestamps = [time.time()] * 5  # Already at limit
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "Rate limit" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_no_relay_url(self):
+        """Returns failure when relay URL is not configured."""
+        from backend.app.services.bug_report import submit_report
+
+        with (
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", ""),
+        ):
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "not configured" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_http_error(self):
+        """Non-200 relay response saves failed report."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 500
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "not available" in result["message"]
+        mock_db.add.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_connection_error(self):
+        """Connection failure saves failed report."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(side_effect=ConnectionError("Connection refused"))
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "Failed to submit" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_failure_response(self):
+        """Relay returns success=false in JSON body."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "success": False,
+            "message": "GitHub API error",
+        }
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "GitHub API error" in result["message"]
+
+
+class TestCollectDebugLogs:
+    """Tests for _collect_debug_logs()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_enables_debug_when_not_already_enabled(self):
+        """Debug logging is enabled, then restored after collection."""
+        from backend.app.api.routes.bug_report import _collect_debug_logs
+
+        apply_calls = []
+
+        mock_db = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(False, None)),
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch(
+                "backend.app.api.routes.bug_report._apply_log_level",
+                side_effect=lambda v: apply_calls.append(v),
+            ),
+            patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
+            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
+        ):
+            mock_pm._clients = {}
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await _collect_debug_logs()
+
+        assert result == "DEBUG log line"
+        assert apply_calls == [True, False]  # enabled then restored
+        assert mock_set.call_count == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_skips_enable_when_already_debug(self):
+        """Debug logging not toggled when already enabled."""
+        from backend.app.api.routes.bug_report import _collect_debug_logs
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
+            patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
+            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
+        ):
+            mock_pm._clients = {}
+            mock_db = AsyncMock()
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await _collect_debug_logs()
+
+        assert result == "logs"
+        mock_apply.assert_not_called()
+        mock_set.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_pushes_all_connected_printers(self):
+        """Sends status update request to all connected printers."""
+        from backend.app.api.routes.bug_report import _collect_debug_logs
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report._apply_log_level"),
+            patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value=""),
+            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
+        ):
+            mock_pm._clients = {"printer1": MagicMock(), "printer2": MagicMock()}
+            mock_db = AsyncMock()
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            await _collect_debug_logs()
+
+        assert mock_pm.request_status_update.call_count == 2
+
+
+class TestRateLimit:
+    """Tests for rate limiting in bug report service."""
+
+    def test_check_rate_limit_allows_first(self):
+        """First request within window is allowed."""
+        from backend.app.services.bug_report import _check_rate_limit
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", []):
+            assert _check_rate_limit() is True
+
+    def test_check_rate_limit_blocks_at_max(self):
+        """Requests at max limit are blocked."""
+        import time
+
+        from backend.app.services.bug_report import _check_rate_limit
+
+        now = time.time()
+        timestamps = [now] * 5
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            assert _check_rate_limit() is False
+
+    def test_check_rate_limit_clears_old(self):
+        """Old timestamps outside window are cleared."""
+        import time
+
+        from backend.app.services.bug_report import _check_rate_limit
+
+        old_time = time.time() - 7200  # 2 hours ago
+        timestamps = [old_time] * 5
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            assert _check_rate_limit() is True

+ 22 - 0
backend/tests/unit/test_catalog_bulk_delete.py

@@ -0,0 +1,22 @@
+"""Unit tests for catalog bulk delete endpoints."""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.api.routes.inventory import BulkDeleteIdsRequest
+
+
+class TestBulkDeleteIdsRequest:
+    """Tests for BulkDeleteIdsRequest schema."""
+
+    def test_accepts_list_of_ids(self):
+        req = BulkDeleteIdsRequest(ids=[1, 2, 3])
+        assert req.ids == [1, 2, 3]
+
+    def test_accepts_empty_list(self):
+        req = BulkDeleteIdsRequest(ids=[])
+        assert req.ids == []
+
+    def test_rejects_missing_ids(self):
+        with pytest.raises(ValidationError):
+            BulkDeleteIdsRequest()

+ 56 - 0
backend/tests/unit/test_maintenance_rod_filtering.py

@@ -0,0 +1,56 @@
+"""Unit tests for maintenance rod-type filtering logic."""
+
+import pytest
+
+from backend.app.api.routes.maintenance import _should_apply_to_printer
+
+
+class TestShouldApplyToPrinter:
+    """Tests for _should_apply_to_printer() model-specific filtering."""
+
+    # Carbon rod tasks should only apply to X1/P1 models
+    @pytest.mark.parametrize("model", ["X1C", "X1", "X1E", "P1P", "P1S"])
+    def test_carbon_rod_tasks_apply_to_carbon_models(self, model: str):
+        assert _should_apply_to_printer("Lubricate Carbon Rods", model) is True
+        assert _should_apply_to_printer("Clean Carbon Rods", model) is True
+
+    def test_carbon_rod_tasks_do_not_apply_to_p2s(self):
+        """P2S has steel rods, not carbon rods (#640)."""
+        assert _should_apply_to_printer("Lubricate Carbon Rods", "P2S") is False
+        assert _should_apply_to_printer("Clean Carbon Rods", "P2S") is False
+
+    def test_carbon_rod_tasks_do_not_apply_to_a1(self):
+        assert _should_apply_to_printer("Lubricate Carbon Rods", "A1") is False
+
+    # Steel rod tasks should only apply to P2S
+    def test_steel_rod_tasks_apply_to_p2s(self):
+        assert _should_apply_to_printer("Lubricate Steel Rods", "P2S") is True
+        assert _should_apply_to_printer("Clean Steel Rods", "P2S") is True
+
+    def test_steel_rod_tasks_do_not_apply_to_x1c(self):
+        assert _should_apply_to_printer("Lubricate Steel Rods", "X1C") is False
+        assert _should_apply_to_printer("Clean Steel Rods", "X1C") is False
+
+    def test_steel_rod_tasks_do_not_apply_to_a1(self):
+        assert _should_apply_to_printer("Lubricate Steel Rods", "A1") is False
+
+    # Linear rail tasks should only apply to A1/H2 models
+    @pytest.mark.parametrize("model", ["A1", "A1 Mini", "H2D", "H2C", "H2S"])
+    def test_linear_rail_tasks_apply_to_rail_models(self, model: str):
+        assert _should_apply_to_printer("Lubricate Linear Rails", model) is True
+        assert _should_apply_to_printer("Clean Linear Rails", model) is True
+
+    def test_linear_rail_tasks_do_not_apply_to_p2s(self):
+        assert _should_apply_to_printer("Lubricate Linear Rails", "P2S") is False
+
+    # Universal tasks apply to all models
+    @pytest.mark.parametrize("model", ["X1C", "P2S", "A1", "H2D"])
+    def test_universal_tasks_apply_to_all(self, model: str):
+        assert _should_apply_to_printer("Clean Nozzle/Hotend", model) is True
+        assert _should_apply_to_printer("Check Belt Tension", model) is True
+
+    # Unknown models default to carbon (legacy behavior)
+    def test_unknown_model_defaults_to_carbon(self):
+        assert _should_apply_to_printer("Lubricate Carbon Rods", "UNKNOWN") is True
+        assert _should_apply_to_printer("Lubricate Steel Rods", "UNKNOWN") is False
+        assert _should_apply_to_printer("Lubricate Linear Rails", "UNKNOWN") is False

Некоторые файлы не были показаны из-за большого количества измененных файлов