Browse Source

Merge pull request #324 from maziggy/0.1.9

  v0.1.9

  Bambuddy v0.1.9 Release Notes

  **Highlights**

This is the biggest release yet — Advanced Authentication via Email, full H2C/H2D Pro nozzle rack support, OrcaSlicer local profiles, configurable slicer preference,  Italian localization, and 50+ bug fixes spanning every printer model.

  ---
  **New Features**

  Advanced Authentication via Email (https://github.com/maziggy/bambuddy/pull/322)

  - Optional SMTP-based email integration for streamlined user onboarding and self-service password management
  - Admins configure SMTP settings and create users with just a username and email — the system generates a secure random password and emails it directly to the new user
  - One-click admin password resets from User Management
  - Users can reset their own forgotten password from the login screen without contacting an admin
  - Customizable email templates for welcome emails and password resets
  - Username and email login is case-insensitive
  - Can be enabled or disabled independently at any time without affecting existing accounts

  Configurable Slicer Preference (https://github.com/maziggy/bambuddy/issues/313)

  - New "Preferred Slicer" setting in General settings to choose between Bambu Studio and OrcaSlicer
  - Controls the protocol used by all "Open in Slicer" buttons across Archives, 3D Preview, and context menus
  - OrcaSlicer uses the orcaslicer://open?file= protocol; default remains Bambu Studio

  Local Profiles — OrcaSlicer Import (https://github.com/maziggy/bambuddy/issues/310)

  - Import slicer presets from OrcaSlicer without Bambu Cloud
  - Supports .orca_filament, .bbscfg, .bbsflmt, .zip, and .json exports
  - Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL)
  - New "Local Profiles" tab on the Profiles page with drag-and-drop import, search, and expandable details
  - Local filament presets appear in AMS slot configuration alongside cloud presets

  Hostname Support for Printers (https://github.com/maziggy/bambuddy/issues/290)

  - Printers can now be added using hostnames (e.g., printer.local, my-printer.home.lan) in addition to IPv4 addresses

  Camera View Controls (https://github.com/maziggy/bambuddy/issues/291)

  - Added chamber light toggle and skip objects buttons to embedded camera viewer and standalone camera page

  Extended Support Bundle Diagnostics

  - Collects comprehensive diagnostic data: printer connectivity, integration status, network interfaces, Python packages, database health, Docker details, WebSocket
  connections, and log file info
  - All data properly anonymized — no IPs, names, or serials included

  ---
  **Improved**

  H2C Nozzle Rack — Full 6-Slot Display (https://github.com/maziggy/bambuddy/issues/300)

  - Always shows all 6 rack positions with filled slots and empty placeholders
  - Compact single-row layout with bottom accent bars (green = mounted, gray = docked)
  - Translates raw nozzle type codes to human-readable names (Hardened Steel, Stainless Steel, Tungsten Carbide, High Flow, Standard)
  - Shows loaded filament material and color in hover card
  - Resolves filament names with 4-tier fallback: Cloud → Local Profiles → built-in lookup table (86 known Bambu codes) → raw ID

  H2 Series — L/R Nozzle Hover Card (https://github.com/maziggy/bambuddy/issues/300)

  - New dual-nozzle hover card shows L and R nozzle details side by side (diameter, type, flow, status, wear, max temp, serial)
  - Active nozzle highlighted in amber; single-nozzle H2D/H2S printers show extended nozzle details on hover

  Firmware Version Badge on Printer Card (https://github.com/maziggy/bambuddy/issues/311)

  - Green badge with checkmark when up to date, orange with download icon when update available
  - Click opens firmware info modal with release notes; respects firmware:read and firmware:update permissions

  Auto-Detect Subnet for Printer Discovery

  - Docker users no longer need to manually enter a subnet — auto-detects available networks with dropdown for multiple subnets

  Japanese Locale Complete Overhaul

  - Restructured ja.ts to match EN/DE structure; translated all 2,083 keys for full parity

  Italian Localization (https://github.com/maziggy/bambuddy/pull/309)

  - New Italian locale contributed by @Keybored02

  ---
  **Bug Fixes**

  - H2C Firmware Downloads Wrong Firmware (https://github.com/maziggy/bambuddy/issues/311) — H2C was mapped to H2D firmware track; added separate h2c API key
  - H2D Pro L/R Nozzle Hover Card Swapped (https://github.com/maziggy/bambuddy/issues/300) — Left and right nozzles were swapped in the hover card
  - H2C Printer Card Shows H2D Image (https://github.com/maziggy/bambuddy/issues/300) — Added dedicated H2C printer image
  - H2C Nozzle Rack Shows Wrong Slots and Missing Colors (https://github.com/maziggy/bambuddy/issues/300) — Fixed mapping by nozzle ID instead of array index; added
  fallback for H2C MQTT field names
  - Nozzle Rack Hides 0% Wear (https://github.com/maziggy/bambuddy/issues/300) — Now correctly shows "Wear: 0%" for new nozzles
  - AMS-HT Mapping Fails for Left Nozzle on H2D Pro (https://github.com/maziggy/bambuddy/issues/318) — Fixed global tray ID calculation for AMS-HT units (ams_id >= 128)
  - H2D Pro Prints Fail at ~75% With Extrusion Motor Overload (https://github.com/maziggy/bambuddy/issues/245) — Fixed use_ams type from integer to boolean for H2D series
  - Virtual Printer FTP Transfer Fails With Connection Reset (https://github.com/maziggy/bambuddy/issues/58) — Fixed async handler completing while data connection was
  still active
  - Virtual Printer IP Override Ignored in Server Mode (https://github.com/maziggy/bambuddy/issues/52) — Network interface override now applies to all modes (TLS
  certificate, SSDP, service restart)
  - Wrong Thumbnail When Reprinting Same Project (https://github.com/maziggy/bambuddy/issues/314) — Cover image cache now cleared on every print start
  - Wrong Timelapse Attached to Archive (https://github.com/maziggy/bambuddy/issues/315) — Replaced mtime-based selection with snapshot-diff approach for reliable
  timelapse detection
  - Calibration Prints Archived (https://github.com/maziggy/bambuddy/issues/315) — Internal printer gcode files under /usr/ now detected and skipped
  - Sidebar Links Custom Icons Inverted Colors (https://github.com/maziggy/bambuddy/issues/308) — Removed CSS invert() filter from user-uploaded icons
  - Spoolman Creates Duplicate Spools on Startup (https://github.com/maziggy/bambuddy/pull/295) — Cached spool data prevents redundant API calls; added retry logic
  - Firmware Badge Shown for Models Without API Data (https://github.com/maziggy/bambuddy/issues/311) — Badge hidden when API returns no firmware data
  - Support Bundle Shows 0 AMS Units — Now handles both nested dict and flat list formats for AMS data
  - GitHub Backup Description Misleading — Updated to correctly state "complete database"
  - Timezone from .env (https://github.com/maziggy/bambuddy/pull/303) — Use TZ from .env instead of hardcoded value
  - FTP TLS 1.3 Compatibility (https://github.com/maziggy/bambuddy/pull/305) — Limit FTP server TLS to 1.2 max for broader client compatibility

  ---
  **Testing**

  - Mock FTPS Server & FTP Test Suite — 67 automated test cases against a real implicit FTPS mock server covering connection, upload, download, model-specific behavior,
  and failure injection
  - Nozzle Rack Tests — Backend: 7 tests for MQTT nozzle_info parsing; Frontend: 3 tests for rack card rendering
  - Advanced Auth Integration Tests — 27 integration tests covering SMTP config, advanced auth toggle, email login, forgot password, admin password reset, and user
  creation

  ---
  **Documentation**

  - Advanced Auth via Email — Updated README, website, and wiki with SMTP setup and self-service password guides
  - Supported Printers — All 12 Bambu Lab models now listed uniformly (X1, X1C, X1E, P1P, P1S, P2S, A1, A1 Mini, H2D, H2D Pro, H2C, H2S)
  - CONTRIBUTING.md — Added i18n conventions and authentication/permissions guide
  - Proxy Mode — FTP data channel security warning, passive port documentation, SSDP discovery limitations table

  ---
  **Community Contributors**

  Thank you to everyone who contributed to this release!

  - @cadtoolbox (Thomas Rambach) — Advanced Authentication via Email (https://github.com/maziggy/bambuddy/pull/322)
  - @Keybored02 — Italian localization (https://github.com/maziggy/bambuddy/pull/309)
  - @notti (Gernot Vormayr) — FTP TLS 1.2 version limit (https://github.com/maziggy/bambuddy/pull/305)
  - @bnap00 (Bharat Parsiya) — Timezone environment variable fix (https://github.com/maziggy/bambuddy/pull/303)
  - @bambuman — Spoolman duplicate spool fix (https://github.com/maziggy/bambuddy/pull/295) and Home Assistant environment variables
  (https://github.com/maziggy/bambuddy/pull/294)

  ---
MartinNYHC 3 months ago
parent
commit
8225c29cfe
100 changed files with 11922 additions and 703 deletions
  1. 6 0
      .env.example
  2. 70 0
      CHANGELOG.md
  3. 3 0
      Dockerfile
  4. 18 12
      README.md
  5. 5 0
      backend/app/api/routes/archives.py
  6. 360 2
      backend/app/api/routes/auth.py
  7. 212 47
      backend/app/api/routes/cloud.py
  8. 4 0
      backend/app/api/routes/discovery.py
  9. 200 0
      backend/app/api/routes/local_presets.py
  10. 3 0
      backend/app/api/routes/notification_templates.py
  11. 29 1
      backend/app/api/routes/printers.py
  12. 62 0
      backend/app/api/routes/settings.py
  13. 14 7
      backend/app/api/routes/smart_plugs.py
  14. 61 6
      backend/app/api/routes/spoolman.py
  15. 261 3
      backend/app/api/routes/support.py
  16. 88 6
      backend/app/api/routes/users.py
  17. 32 4
      backend/app/core/auth.py
  18. 1 1
      backend/app/core/config.py
  19. 16 0
      backend/app/core/database.py
  20. 206 75
      backend/app/main.py
  21. 4 0
      backend/app/models/__init__.py
  22. 40 0
      backend/app/models/local_preset.py
  23. 12 0
      backend/app/models/notification_template.py
  24. 22 0
      backend/app/models/orca_base_cache.py
  25. 1 1
      backend/app/models/printer.py
  26. 1 0
      backend/app/models/slot_preset.py
  27. 1 0
      backend/app/models/user.py
  28. 51 1
      backend/app/schemas/auth.py
  29. 67 0
      backend/app/schemas/local_preset.py
  30. 18 0
      backend/app/schemas/notification_template.py
  31. 26 2
      backend/app/schemas/printer.py
  32. 14 0
      backend/app/schemas/settings.py
  33. 50 5
      backend/app/services/bambu_mqtt.py
  34. 516 0
      backend/app/services/email_service.py
  35. 2 1
      backend/app/services/firmware_check.py
  36. 511 0
      backend/app/services/orca_profiles.py
  37. 2 1
      backend/app/services/print_scheduler.py
  38. 16 0
      backend/app/services/printer_manager.py
  39. 5 13
      backend/app/services/smart_plug_manager.py
  40. 89 18
      backend/app/services/spoolman.py
  41. 2 1
      backend/app/services/spoolman_tracking.py
  42. 28 10
      backend/app/services/virtual_printer/certificate.py
  43. 113 43
      backend/app/services/virtual_printer/ftp_server.py
  44. 29 7
      backend/app/services/virtual_printer/manager.py
  45. 85 67
      backend/app/services/virtual_printer/mqtt_server.py
  46. 81 9
      backend/app/services/virtual_printer/ssdp_server.py
  47. 570 3
      backend/app/services/virtual_printer/tcp_proxy.py
  48. 5 1
      backend/app/utils/printer_models.py
  49. 625 0
      backend/tests/integration/test_advanced_auth_api.py
  50. 27 0
      backend/tests/integration/test_auth_api.py
  51. 26 0
      backend/tests/integration/test_discovery_api.py
  52. 52 0
      backend/tests/integration/test_printers_api.py
  53. 248 0
      backend/tests/integration/test_settings_api.py
  54. 70 2
      backend/tests/integration/test_spoolman_api.py
  55. 99 0
      backend/tests/unit/services/conftest.py
  56. 240 0
      backend/tests/unit/services/mock_ftp_server.py
  57. 864 0
      backend/tests/unit/services/test_bambu_ftp.py
  58. 255 0
      backend/tests/unit/services/test_bambu_mqtt.py
  59. 245 0
      backend/tests/unit/services/test_email_service.py
  60. 203 1
      backend/tests/unit/services/test_spoolman_service.py
  61. 286 6
      backend/tests/unit/services/test_virtual_printer.py
  62. 486 0
      backend/tests/unit/test_archive_filtering.py
  63. 229 0
      backend/tests/unit/test_homeassistant_settings.py
  64. 341 0
      backend/tests/unit/test_orca_profiles.py
  65. 1 1
      backend/tests/unit/test_scheduler_ams_mapping.py
  66. 48 0
      backend/tests/unit/test_slicer_settings.py
  67. 428 0
      backend/tests/unit/test_support_helpers.py
  68. 13 1
      docker-compose.yml
  69. 224 199
      frontend/package-lock.json
  70. 4 1
      frontend/package.json
  71. BIN
      frontend/public/img/printers/h2c.png
  72. 182 0
      frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx
  73. 168 0
      frontend/src/__tests__/components/FilamentHoverCard.test.tsx
  74. 194 0
      frontend/src/__tests__/components/LocalProfilesView.test.tsx
  75. 90 0
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  76. 1 1
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  77. 13 0
      frontend/src/__tests__/mocks/handlers.ts
  78. 8 5
      frontend/src/__tests__/pages/CameraPage.test.tsx
  79. 197 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  80. 17 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  81. 22 0
      frontend/src/__tests__/pages/SystemInfoPage.test.tsx
  82. 78 0
      frontend/src/__tests__/utils/getSpoolmanFillLevel.test.ts
  83. 115 0
      frontend/src/__tests__/utils/slicer.test.ts
  84. 183 4
      frontend/src/api/client.ts
  85. 2 5
      frontend/src/components/AddExternalLinkModal.tsx
  86. 11 9
      frontend/src/components/AddSmartPlugModal.tsx
  87. 154 82
      frontend/src/components/ConfigureAmsSlotModal.tsx
  88. 181 0
      frontend/src/components/CreateUserAdvancedAuthModal.tsx
  89. 402 0
      frontend/src/components/EmailSettings.tsx
  90. 83 4
      frontend/src/components/EmbeddedCameraViewer.tsx
  91. 5 3
      frontend/src/components/ExternalLinksSettings.tsx
  92. 5 1
      frontend/src/components/FilamentHoverCard.tsx
  93. 5 8
      frontend/src/components/FilamentTrends.tsx
  94. 1 1
      frontend/src/components/GitHubBackupSettings.tsx
  95. 1 1
      frontend/src/components/Layout.tsx
  96. 479 0
      frontend/src/components/LocalProfilesView.tsx
  97. 6 3
      frontend/src/components/ModelViewerModal.tsx
  98. 12 10
      frontend/src/components/RichTextEditor.tsx
  99. 271 0
      frontend/src/components/SkipObjectsModal.tsx
  100. 10 8
      frontend/src/components/SmartPlugCard.tsx

+ 6 - 0
.env.example

@@ -10,3 +10,9 @@ LOG_LEVEL=INFO
 
 
 # Enable file logging (logs written to logs/bambutrack.log)
 # Enable file logging (logs written to logs/bambutrack.log)
 LOG_TO_FILE=true
 LOG_TO_FILE=true
+
+# Home Assistant Integration (for HA Add-on deployments)
+# When both HA_URL and HA_TOKEN are set, Home Assistant integration is automatically enabled
+# and these values override any database settings (read-only in UI)
+# HA_URL=http://supervisor/core
+# HA_TOKEN=your-long-lived-access-token

+ 70 - 0
CHANGELOG.md

@@ -2,6 +2,76 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.9] - 2026-02-10
+
+### New Features
+- **Advanced Authentication via Email** ([#322](https://github.com/maziggy/bambuddy/pull/322)) — Optional SMTP-based email integration for streamlined user onboarding and self-service password management. Admins configure SMTP settings and create users with just a username and email — the system generates a secure random password and emails it directly to the new user. Admins can trigger one-click password resets from User Management. Users can reset their own forgotten password from the login screen without contacting an admin. Includes customizable email templates for welcome emails and password resets. Username and email login is case-insensitive. Can be enabled or disabled independently at any time without affecting existing accounts.
+- **Configurable Slicer Preference** ([#313](https://github.com/maziggy/bambuddy/issues/313)) — New "Preferred Slicer" setting in General settings to choose between Bambu Studio and OrcaSlicer. Controls the protocol used by all "Open in Slicer" buttons across Archives, 3D Preview, and context menus. OrcaSlicer uses the `orcaslicer://open?file=` protocol. Default remains Bambu Studio for backward compatibility.
+- **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.
+- **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
+- **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.
+- **Per-Filament Spoolman Usage Tracking** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers `tray_uuid` over `tag_uid` for spool identification.
+- **Disable AMS Weight Sync Setting** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — New toggle to prevent AMS percentage-based weight estimates from overwriting Spoolman's granular usage-based calculations. Includes conditional "Report Partial Usage for Failed Prints" toggle.
+- **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.
+- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
+- **Extended Support Bundle Diagnostics** — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.
+
+### Improved
+- **H2C Nozzle Rack — 6-Slot Display With Empty Placeholders** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card now always shows 6 rack positions (IDs 16–21), with filled slots showing diameter and empty slots showing placeholder dashes. L/R hotend nozzles (IDs 0, 1) are excluded from the rack card and shown in the dedicated L/R indicator instead.
+- **H2 Series — L/R Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New dual-nozzle hover card shows L and R nozzle details side by side (diameter, type, flow, status, wear, max temp, serial). Active nozzle highlighted in amber with Active/Idle status based on `active_extruder`, replacing the misleading "Docked" label.
+- **H2 Series — Single-Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — H2D/H2S printers with a single nozzle now show extended nozzle details (wear, serial, max temp) on hover over the temperature card. Backend changed from H2C-only (>2 nozzles) to all H2 series (any nozzle_info present).
+- **H2C Nozzle Rack — Translate Type Codes & Add Flow Info** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Raw nozzle type codes (e.g. "HS", "HH01") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New "Flow" row in the hover card. Translations added in all 4 locales (en, de, ja, it).
+- **H2C Nozzle Rack — Show Filament Material in Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle hover card now shows the loaded filament material type (e.g. "PLA", "PETG") alongside the color swatch, captured from MQTT nozzle info data.
+- **H2C Nozzle Rack — Resolve Filament Names From Cloud & Local Profiles** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack hover card previously showed raw filament IDs like "GFU99" instead of human-readable names. Now resolves filament names with a 4-tier fallback: Bambu Cloud preset lookup → local slicer profiles → built-in filament name table (86 known Bambu filament codes) → raw ID fallback. The built-in table resolves names like "Bambu ASA", "Generic TPU", "Generic PLA" when the cloud API returns 400 for certain filament IDs. Also benefits AMS tray tooltips.
+- **H2C Nozzle Rack Compact Layout** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.
+- **Firmware Version Badge on Printer Card** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect `firmware:read` and `firmware:update` permissions. Translations added in all 4 locales.
+- **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
+- **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
+
+### Fixed
+- **Nozzle Rack Hides 0% Wear** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New nozzles with 0% wear showed no wear info in the hover card because the condition treated 0 the same as "not available." Now displays "Wear: 0%" correctly. The field is still hidden when the printer doesn't report wear data.
+- **Nozzle Rack Shows L/R Hotend Nozzles in Rack** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card incorrectly included L/R hotend nozzles (IDs 0, 1) alongside the 6 rack slots. Now filters to IDs >= 2 (rack only) and always pads to 6 positions with empty placeholders.
+- **H2C Firmware Update Downloads Wrong Firmware** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — H2C printers were mapped to the H2D firmware API key (`h2d`), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate `h2c` API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.
+- **Sidebar Links Custom Icons Have Inverted Colors** ([#308](https://github.com/maziggy/bambuddy/issues/308)) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS `invert()` filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.
+- **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.
+- **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
+- **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
+- **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
+- **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
+- **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
+- **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
+- **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.
+- **Energy Cost Shows 0.00 in "Total Consumption" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.
+- **H2D Pro Prints Fail at ~75% With Extrusion Motor Overload** ([#245](https://github.com/maziggy/bambuddy/issues/245)) — H2D Pro firmware interprets `use_ams: 1` (integer) as a nozzle index, routing filament to the deputy nozzle instead of the main nozzle. Bambu Studio sends `use_ams: true` (boolean) while using integers for other fields. Fixed by keeping `use_ams` as boolean for all printers including H2D series.
+- **GitHub Backup Description Misleading** — The "App Settings" backup card said "excludes sensitive data" but the complete database is pushed. Updated description to "complete database."
+- **Support Bundle Shows 0 AMS Units** — The support info always reported `ams_unit_count: 0` because it expected `raw_data["ams"]` to be a nested dict (`{"ams": [...]}`) but the MQTT handler stores it as a flat list. Now handles both formats.
+- **Firmware Badge Shown for Models Without API Data** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printers whose model has no firmware data in Bambu Lab's API (e.g. H2C on public beta firmware) showed a misleading green "up to date" badge. The badge is now hidden when the API returns no `latest_version`, since there is nothing to compare against.
+- **AMS-HT Mapping Fails for Left Nozzle on H2D Pro** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — Printing with the left nozzle on dual-nozzle printers (H2D/H2D Pro) using AMS-HT failed with "Failed to get AMS mapping table." The global tray ID for AMS-HT units (ams_id >= 128) was calculated as `ams_id * 4 + tray_id` (= 512), but AMS-HT uses the raw `ams_id` (128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backend `ams_mapping2` builder, print scheduler, and Spoolman tracking.
+- **H2D Pro L/R Nozzle Hover Card Swapped** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The dual-nozzle hover card had left and right nozzles swapped: nozzle_rack id 0 (extruder 0 = right) was shown as left and vice versa. Serial number and max temp now correctly appear only on the right (removable) nozzle column.
+- **H2C Printer Card Shows H2D Image** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The H2C printer card displayed the H2D printer image because no dedicated H2C image existed in the frontend. Added H2C image and updated `getPrinterImage()` to return it for H2C models.
+- **H2C Nozzle Rack Shows Wrong Empty Slot and Missing Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Empty rack slots always appeared at position 6 instead of their actual position because nozzles were mapped by array index instead of by ID. Fixed by mapping each nozzle to its correct rack position (`id - 16`). Filament colors and materials were missing because the H2C uses different MQTT field names (`color_m`, `fila_id`, `sn`, `tm`) than the H2D (`filament_colour`, `filament_id`, `serial_number`, `max_temp`). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.
+
+### Documentation
+- **Advanced Auth via Email** — Updated README, website features page, and wiki authentication guide with SMTP setup, self-service password reset, admin password reset, email templates, and advanced auth overview.
+- **Supported Printers Updated** — Updated README, website, and wiki to list all 12 supported Bambu Lab printer models: X1, X1C, X1E, P1P, P1S, P2S, A1, A1 Mini, H2D, H2D Pro, H2C, H2S. Removed outdated "Testers Needed" messaging and Tested/Needs Testing distinctions — all models are now uniformly listed as supported. Added H2C printer image to website. Added H2D Pro, H2C columns to wiki feature comparison tables and new P2 Series section.
+- **CONTRIBUTING.md: i18n & Authentication Guides** — Added Internationalization (i18n) section with locale file conventions, code examples, and parity rules. Added Authentication & Permissions section covering the opt-in auth pattern, permission conventions, and default group structure.
+- **Proxy Mode Security Warning** — Added FTP data channel security warning to wiki, README, and website. Bambu Studio does not encrypt the FTP data channel despite negotiating PROT P; MQTT and FTP control channels are fully TLS-encrypted. VPN (Tailscale/WireGuard) recommended for full data encryption.
+- **Docker Proxy Mode Ports** — Documented FTP passive data ports 50000-50100 required for proxy mode in Docker bridge mode. Updated port mappings in wiki virtual-printer and docker guides.
+- **SSDP Discovery Limitations** — Added table showing when SSDP discovery works (same LAN, dual-homed, Docker host mode) vs when manual IP entry is required (VPN, Docker bridge, port forwarding). Updated wiki, README, and website.
+- **Firewall Rules Updated** — Added port 50000-50100/tcp to all UFW, firewalld, and iptables examples for proxy mode FTP passive data.
+
+### Testing
+- **Mock FTPS Server & Comprehensive FTP Test Suite** — Added 67 automated test cases against a real implicit FTPS mock server, covering every known FTP failure mode from 0.1.8+:
+  - Mock server (`mock_ftp_server.py`) implements implicit TLS, custom AVBL command, and per-command failure injection
+  - Connection tests: auth, SSL modes (prot_p/prot_c), timeout, cache, disconnect edge cases
+  - Upload tests: chunked transfer via `transfercmd()`, progress callbacks, 553/550/552 error handling
+  - Download tests: bytes, to-file, 0-byte regression, large files, missing file cleanup
+  - Model-specific tests: X1C session reuse, A1/A1 Mini prot_c fallback, P1S, unknown model defaults
+  - Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download
+  - Failure injection tests: regressions for `error_perm` hierarchy, `diagnose_storage` CWD propagation, injection count decrement
+  - Added `pyOpenSSL` to `requirements-dev.txt` for Docker test image compatibility
+- **Nozzle Rack Tests** — Backend: 7 tests for MQTT nozzle_info parsing (H2C 8-entry, H2D 2-entry, H2S single, empty, sorting, field mapping, nozzle state updates). Frontend: 3 tests for rack card rendering (H2C shows 6 slots, empty placeholders, hidden when no rack IDs).
+
 ## [0.1.8.1] - 2026-02-07
 ## [0.1.8.1] - 2026-02-07
 
 
 ### Fixed
 ### Fixed

+ 3 - 0
Dockerfile

@@ -47,6 +47,9 @@ ENV LOG_DIR=/app/logs
 ENV PORT=8000
 ENV PORT=8000
 
 
 EXPOSE 8000
 EXPOSE 8000
+EXPOSE 8883
+EXPOSE 9990
+EXPOSE 50000-50100
 
 
 # Health check (uses PORT env var via shell)
 # Health check (uses PORT env var via shell)
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \

+ 18 - 12
README.md

@@ -39,7 +39,8 @@
 
 
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
 
-- 🔒 **End-to-end TLS encryption** — Your print data is encrypted from slicer to printer
+- 🔒 **TLS-encrypted control channels** — MQTT and FTP control fully encrypted
+- 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
 - ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
@@ -50,8 +51,6 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 
 
 ---
 ---
 
 
-> **Testers Needed!** I only have X1C and H2D devices. Help make Bambuddy work with all Bambu Lab printers by [reporting your experience](https://github.com/maziggy/bambuddy/issues)!
-
 ## Why Bambuddy?
 ## Why Bambuddy?
 
 
 - **Own your data** — All print history stored locally, no cloud dependency
 - **Own your data** — All print history stored locally, no cloud dependency
@@ -147,10 +146,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Queue events (waiting, skipped, failed)
 - Queue events (waiting, skipped, failed)
 
 
 ### 🔧 Integrations
 ### 🔧 Integrations
-- [Spoolman](https://github.com/Donkie/Spoolman) filament sync
+- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management
 - Bambu Cloud profile management
+- **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - External sidebar links
 - External sidebar links
@@ -163,7 +163,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Send prints directly from Bambu Studio/Orca Slicer
 - Send prints directly from Bambu Studio/Orca Slicer
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Archive mode, Review mode, Queue mode, or Proxy mode
 - Archive mode, Review mode, Queue mode, or Proxy mode
-- SSDP discovery (appears in slicer automatically)
+- SSDP discovery (same LAN) or manual IP entry (VPN/remote)
+- Network interface override for multi-NIC/Docker/VPN setups
 - Secure TLS/MQTT/FTP communication
 - Secure TLS/MQTT/FTP communication
 
 
 ### 🛠️ Maintenance & Support
 ### 🛠️ Maintenance & Support
@@ -171,10 +172,10 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Interval reminders (hours/days)
 - Interval reminders (hours/days)
 - Print time accuracy stats
 - Print time accuracy stats
 - File manager for printer storage
 - File manager for printer storage
-- Firmware update helper (LAN-only printers)
+- Firmware update helper with version badge (LAN-only printers)
 - Debug logging toggle with live indicator
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Live application log viewer with filtering
-- Support bundle generator (privacy-filtered)
+- Support bundle generator with comprehensive diagnostics (privacy-filtered)
 
 
 ### 🔒 Optional Authentication
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
 - Enable/disable authentication any time
@@ -184,12 +185,16 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Comprehensive API protection (200+ endpoints secured)
 - Comprehensive API protection (200+ endpoints secured)
 - User management (create, edit, delete, groups)
 - User management (create, edit, delete, groups)
 - User activity tracking (who uploaded archives, library files, queued prints, started prints)
 - User activity tracking (who uploaded archives, library files, queued prints, started prints)
+- **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)
+- Customizable email templates (welcome email, password reset)
 
 
 </td>
 </td>
 </tr>
 </tr>
 </table>
 </table>
 
 
-**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
 
 
 ---
 ---
 
 
@@ -452,7 +457,7 @@ services:
     network_mode: host
     network_mode: host
 ```
 ```
 
 
-> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy can discover printers via subnet scanning - enter your network range (e.g., `192.168.1.0/24`) in the Add Printer dialog.
+> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy auto-detects your network subnet and can discover printers via subnet scanning in the Add Printer dialog.
 
 
 </details>
 </details>
 
 
@@ -523,9 +528,10 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
 
 
 | Series | Models |
 | Series | Models |
 |--------|--------|
 |--------|--------|
-| H2 | H2D, H2S |
-| X1 | X1, X1 Carbon |
-| P1 | P1P, P1S, P2S |
+| X1 | X1, X1 Carbon, X1E |
+| H2 | H2D, H2D Pro, H2C, H2S |
+| P1 | P1P, P1S |
+| P2 | P2S |
 | A1 | A1, A1 Mini |
 | A1 | A1, A1 Mini |
 
 
 ---
 ---

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

@@ -545,6 +545,11 @@ async def get_archive_stats(
         plugs_result = await db.execute(select(SmartPlug))
         plugs_result = await db.execute(select(SmartPlug))
         plugs = list(plugs_result.scalars().all())
         plugs = list(plugs_result.scalars().all())
 
 
+        # Configure HA service once (needed for homeassistant-type plugs)
+        ha_url = await get_setting(db, "ha_url") or ""
+        ha_token = await get_setting(db, "ha_token") or ""
+        homeassistant_service.configure(ha_url, ha_token)
+
         total_energy_kwh = 0.0
         total_energy_kwh = 0.0
         for plug in plugs:
         for plug in plugs:
             if plug.plug_type == "tasmota":
             if plug.plug_type == "tasmota":

+ 360 - 2
backend/app/api/routes/auth.py

@@ -5,19 +5,45 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
     ACCESS_TOKEN_EXPIRE_MINUTES,
+    Permission,
+    RequirePermissionIfAuthEnabled,
     authenticate_user,
     authenticate_user,
+    authenticate_user_by_email,
     create_access_token,
     create_access_token,
     get_current_active_user,
     get_current_active_user,
     get_password_hash,
     get_password_hash,
+    get_user_by_email,
     get_user_by_username,
     get_user_by_username,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+from backend.app.schemas.auth import (
+    ForgotPasswordRequest,
+    ForgotPasswordResponse,
+    GroupBrief,
+    LoginRequest,
+    LoginResponse,
+    ResetPasswordRequest,
+    ResetPasswordResponse,
+    SetupRequest,
+    SetupResponse,
+    SMTPSettings,
+    TestSMTPRequest,
+    TestSMTPResponse,
+    UserResponse,
+)
+from backend.app.services.email_service import (
+    create_password_reset_email_from_template,
+    generate_secure_password,
+    get_smtp_settings,
+    save_smtp_settings,
+    send_email,
+)
 
 
 
 
 def _user_to_response(user: User) -> UserResponse:
 def _user_to_response(user: User) -> UserResponse:
@@ -25,6 +51,7 @@ def _user_to_response(user: User) -> UserResponse:
     return UserResponse(
     return UserResponse(
         id=user.id,
         id=user.id,
         username=user.username,
         username=user.username,
+        email=user.email,
         role=user.role,
         role=user.role,
         is_active=user.is_active,
         is_active=user.is_active,
         is_admin=user.is_admin,
         is_admin=user.is_admin,
@@ -46,6 +73,27 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
     return setting.value.lower() == "true"
     return setting.value.lower() == "true"
 
 
 
 
+async def is_advanced_auth_enabled(db: AsyncSession) -> bool:
+    """Check if advanced authentication is enabled."""
+    result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
+    setting = result.scalar_one_or_none()
+    if setting is None:
+        return False
+    return setting.value.lower() == "true"
+
+
+async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
+    """Set advanced authentication enabled status."""
+    from sqlalchemy import func
+    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+    stmt = sqlite_insert(Settings).values(key="advanced_auth_enabled", value="true" if enabled else "false")
+    stmt = stmt.on_conflict_do_update(
+        index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
+    )
+    await db.execute(stmt)
+
+
 async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
 async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
     """Set authentication enabled status."""
     """Set authentication enabled status."""
     from sqlalchemy import func
     from sqlalchemy import func
@@ -216,7 +264,10 @@ async def disable_auth(
 
 
 @router.post("/login", response_model=LoginResponse)
 @router.post("/login", response_model=LoginResponse)
 async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
 async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
-    """Login and get access token."""
+    """Login and get access token.
+
+    Supports username or email-based login. Username lookup is case-insensitive.
+    """
     # Check if auth is enabled
     # Check if auth is enabled
     auth_enabled = await is_auth_enabled(db)
     auth_enabled = await is_auth_enabled(db)
     if not auth_enabled:
     if not auth_enabled:
@@ -225,7 +276,15 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             detail="Authentication is not enabled",
             detail="Authentication is not enabled",
         )
         )
 
 
+    # Try username-based authentication first
     user = await authenticate_user(db, request.username, request.password)
     user = await authenticate_user(db, request.username, request.password)
+
+    # If username auth failed and advanced auth is enabled, try email-based authentication
+    if not user:
+        advanced_auth = await is_advanced_auth_enabled(db)
+        if advanced_auth:
+            user = await authenticate_user_by_email(db, request.username, request.password)
+
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             status_code=status.HTTP_401_UNAUTHORIZED,
@@ -263,3 +322,302 @@ async def get_current_user_info(
 async def logout():
 async def logout():
     """Logout (client should discard token)."""
     """Logout (client should discard token)."""
     return {"message": "Logged out successfully"}
     return {"message": "Logged out successfully"}
+
+
+# Advanced Authentication Endpoints
+
+
+@router.post("/smtp/test", response_model=TestSMTPResponse)
+async def test_smtp_connection(
+    test_request: TestSMTPRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Test SMTP connection with provided 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,
+        )
+
+        # Send test email
+        send_email(
+            smtp_settings=smtp_settings,
+            to_email=test_request.test_recipient,
+            subject="BamBuddy SMTP Test",
+            body_text="This is a test email from BamBuddy. If you received this, your SMTP settings are working correctly!",
+            body_html="<p>This is a test email from <strong>BamBuddy</strong>.</p><p>If you received this, your SMTP settings are working correctly!</p>",
+        )
+
+        logger.info(f"Test email sent successfully to {test_request.test_recipient}")
+        return TestSMTPResponse(success=True, message="Test email sent successfully")
+    except Exception as e:
+        logger.error(f"Failed to send test email: {e}")
+        return TestSMTPResponse(success=False, message=f"Failed to send test email: {str(e)}")
+
+
+@router.get("/smtp", response_model=SMTPSettings | None)
+async def get_smtp_config(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get SMTP settings (admin only when auth enabled). Password is not returned."""
+    smtp_settings = await get_smtp_settings(db)
+    if smtp_settings:
+        # Don't return password in response
+        smtp_settings.smtp_password = None
+    return smtp_settings
+
+
+@router.post("/smtp", response_model=dict)
+async def save_smtp_config(
+    smtp_settings: SMTPSettings,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Save SMTP settings (admin only when auth enabled)."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    try:
+        await save_smtp_settings(db, smtp_settings)
+        await db.commit()
+        logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
+        return {"message": "SMTP settings saved successfully"}
+    except Exception as e:
+        await db.rollback()
+        logger.error(f"Failed to save SMTP settings: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Failed to save SMTP settings: {str(e)}",
+        )
+
+
+@router.post("/advanced-auth/enable", response_model=dict)
+async def enable_advanced_auth(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
+    """Enable advanced authentication (admin only).
+
+    Requires SMTP settings to be configured and tested first.
+    """
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    # Reload user with groups for proper is_admin check
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
+    if not user.is_admin:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Only admins can enable advanced authentication",
+        )
+
+    # Verify SMTP settings are configured
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="SMTP settings must be configured before enabling advanced authentication",
+        )
+
+    try:
+        await set_advanced_auth_enabled(db, True)
+        await db.commit()
+        logger.info(f"Advanced authentication enabled by admin user: {user.username}")
+        return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
+    except Exception as e:
+        await db.rollback()
+        logger.error(f"Failed to enable advanced authentication: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Failed to enable advanced authentication: {str(e)}",
+        )
+
+
+@router.post("/advanced-auth/disable", response_model=dict)
+async def disable_advanced_auth(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
+    """Disable advanced authentication (admin only)."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    # Reload user with groups for proper is_admin check
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
+    if not user.is_admin:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Only admins can disable advanced authentication",
+        )
+
+    try:
+        await set_advanced_auth_enabled(db, False)
+        await db.commit()
+        logger.info(f"Advanced authentication disabled by admin user: {user.username}")
+        return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
+    except Exception as e:
+        await db.rollback()
+        logger.error(f"Failed to disable advanced authentication: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Failed to disable advanced authentication: {str(e)}",
+        )
+
+
+@router.get("/advanced-auth/status")
+async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
+    """Get advanced authentication status."""
+    advanced_auth_enabled = await is_advanced_auth_enabled(db)
+    smtp_configured = await get_smtp_settings(db) is not None
+    return {
+        "advanced_auth_enabled": advanced_auth_enabled,
+        "smtp_configured": smtp_configured,
+    }
+
+
+@router.post("/forgot-password", response_model=ForgotPasswordResponse)
+async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
+    """Request password reset via email (advanced auth only)."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    # Check if advanced auth is enabled
+    advanced_auth = await is_advanced_auth_enabled(db)
+    if not advanced_auth:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Advanced authentication is not enabled",
+        )
+
+    # Get SMTP settings
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail="Email service is not configured",
+        )
+
+    # Find user by email
+    user = await get_user_by_email(db, request.email)
+
+    # Always return success message to prevent email enumeration
+    # but only send email if user exists
+    if user and user.is_active:
+        try:
+            # Generate new password
+            new_password = generate_secure_password()
+            user.password_hash = get_password_hash(new_password)
+            await db.commit()
+
+            login_url = await get_external_login_url(db)
+
+            # Send password reset email
+            subject, text_body, html_body = await create_password_reset_email_from_template(
+                db, user.username, new_password, login_url
+            )
+            send_email(smtp_settings, user.email, subject, text_body, html_body)
+
+            logger.info(f"Password reset email sent to {user.email}")
+        except Exception as e:
+            logger.error(f"Failed to send password reset email: {e}")
+            # Don't reveal error to user for security
+
+    return ForgotPasswordResponse(
+        message="If the email address is associated with an account, a password reset email has been sent."
+    )
+
+
+@router.post("/reset-password", response_model=ResetPasswordResponse)
+async def reset_user_password(
+    request: ResetPasswordRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
+    """Reset a user's password and send them an email (admin only, advanced auth only)."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    # Reload user with groups for proper is_admin check
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    admin_user = result.scalar_one()
+
+    if not admin_user.is_admin:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Only admins can reset user passwords",
+        )
+
+    # Check if advanced auth is enabled
+    advanced_auth = await is_advanced_auth_enabled(db)
+    if not advanced_auth:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Advanced authentication is not enabled",
+        )
+
+    # Get SMTP settings
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail="Email service is not configured",
+        )
+
+    # Find user to reset
+    result = await db.execute(select(User).where(User.id == request.user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    if not user.email:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User does not have an email address configured",
+        )
+
+    try:
+        # Generate new password
+        new_password = generate_secure_password()
+        user.password_hash = get_password_hash(new_password)
+        await db.commit()
+
+        login_url = await get_external_login_url(db)
+
+        # Send password reset email
+        subject, text_body, html_body = await create_password_reset_email_from_template(
+            db, user.username, new_password, login_url
+        )
+        send_email(smtp_settings, user.email, subject, text_body, html_body)
+
+        logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")
+        return ResetPasswordResponse(message=f"Password reset email sent to {user.email}")
+    except Exception as e:
+        await db.rollback()
+        logger.error(f"Failed to reset password for user {user.username}: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Failed to reset password: {str(e)}",
+        )

+ 212 - 47
backend/app/api/routes/cloud.py

@@ -307,6 +307,170 @@ _filament_cache: dict[str, dict] = {}
 _filament_cache_time: float = 0
 _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
 
+# Built-in filament ID → name mapping (fallback when cloud API and local profiles
+# don't have the entry). Based on Bambu Lab's known filament catalogue.
+_BUILTIN_FILAMENT_NAMES: dict[str, str] = {
+    "GFA00": "Bambu PLA Basic",
+    "GFA01": "Bambu PLA Matte",
+    "GFA02": "Bambu PLA Metal",
+    "GFA05": "Bambu PLA Silk",
+    "GFA06": "Bambu PLA Silk+",
+    "GFA07": "Bambu PLA Marble",
+    "GFA08": "Bambu PLA Sparkle",
+    "GFA09": "Bambu PLA Tough",
+    "GFA11": "Bambu PLA Aero",
+    "GFA12": "Bambu PLA Glow",
+    "GFA13": "Bambu PLA Dynamic",
+    "GFA15": "Bambu PLA Galaxy",
+    "GFA16": "Bambu PLA Wood",
+    "GFA50": "Bambu PLA-CF",
+    "GFB00": "Bambu ABS",
+    "GFB01": "Bambu ASA",
+    "GFB02": "Bambu ASA-Aero",
+    "GFB50": "Bambu ABS-GF",
+    "GFB51": "Bambu ASA-CF",
+    "GFB60": "PolyLite ABS",
+    "GFB61": "PolyLite ASA",
+    "GFB98": "Generic ASA",
+    "GFB99": "Generic ABS",
+    "GFC00": "Bambu PC",
+    "GFC01": "Bambu PC FR",
+    "GFC99": "Generic PC",
+    "GFG00": "Bambu PETG Basic",
+    "GFG01": "Bambu PETG Translucent",
+    "GFG02": "Bambu PETG HF",
+    "GFG50": "Bambu PETG-CF",
+    "GFG60": "PolyLite PETG",
+    "GFG96": "Generic PETG HF",
+    "GFG97": "Generic PCTG",
+    "GFG98": "Generic PETG-CF",
+    "GFG99": "Generic PETG",
+    "GFL00": "PolyLite PLA",
+    "GFL01": "PolyTerra PLA",
+    "GFL03": "eSUN PLA+",
+    "GFL04": "Overture PLA",
+    "GFL05": "Overture Matte PLA",
+    "GFL06": "Fiberon PETG-ESD",
+    "GFL50": "Fiberon PA6-CF",
+    "GFL51": "Fiberon PA6-GF",
+    "GFL52": "Fiberon PA12-CF",
+    "GFL53": "Fiberon PA612-CF",
+    "GFL54": "Fiberon PET-CF",
+    "GFL55": "Fiberon PETG-rCF",
+    "GFL95": "Generic PLA High Speed",
+    "GFL96": "Generic PLA Silk",
+    "GFL98": "Generic PLA-CF",
+    "GFL99": "Generic PLA",
+    "GFN03": "Bambu PA-CF",
+    "GFN04": "Bambu PAHT-CF",
+    "GFN05": "Bambu PA6-CF",
+    "GFN06": "Bambu PPA-CF",
+    "GFN08": "Bambu PA6-GF",
+    "GFN96": "Generic PPA-GF",
+    "GFN97": "Generic PPA-CF",
+    "GFN98": "Generic PA-CF",
+    "GFN99": "Generic PA",
+    "GFP95": "Generic PP-GF",
+    "GFP96": "Generic PP-CF",
+    "GFP97": "Generic PP",
+    "GFP98": "Generic PE-CF",
+    "GFP99": "Generic PE",
+    "GFR98": "Generic PHA",
+    "GFR99": "Generic EVA",
+    "GFS00": "Bambu Support W",
+    "GFS01": "Bambu Support G",
+    "GFS02": "Bambu Support For PLA",
+    "GFS03": "Bambu Support For PA/PET",
+    "GFS04": "Bambu PVA",
+    "GFS05": "Bambu Support For PLA/PETG",
+    "GFS06": "Bambu Support for ABS",
+    "GFS97": "Generic BVOH",
+    "GFS98": "Generic HIPS",
+    "GFS99": "Generic PVA",
+    "GFT01": "Bambu PET-CF",
+    "GFT02": "Bambu PPS-CF",
+    "GFT97": "Generic PPS",
+    "GFT98": "Generic PPS-CF",
+    "GFU00": "Bambu TPU 95A HF",
+    "GFU01": "Bambu TPU 95A",
+    "GFU02": "Bambu TPU for AMS",
+    "GFU98": "Generic TPU for AMS",
+    "GFU99": "Generic TPU",
+}
+
+
+async def _enrich_from_local_presets(
+    unresolved_ids: list[str],
+    result: dict,
+    db: AsyncSession,
+) -> dict:
+    """Fall back to local profiles for filament IDs not resolved by cloud.
+
+    Matches by checking the setting_id field inside the local preset's
+    resolved JSON blob (stored in the 'setting' column).
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.local_preset import LocalPreset
+
+    # Build lookup: converted setting_id -> original filament_id
+    id_map: dict[str, str] = {}
+    for fid in unresolved_ids:
+        converted = _filament_id_to_setting_id(fid)
+        id_map[converted] = fid
+        # Also map the original in case the JSON uses that form
+        id_map[fid] = fid
+
+    try:
+        # Query filament presets that have a setting_id matching any of our IDs
+        # json_extract is supported in SQLite >= 3.9 and all modern Python builds
+        candidates = await db.execute(
+            select(LocalPreset).where(
+                LocalPreset.preset_type == "filament",
+                text("json_extract(setting, '$.setting_id') IS NOT NULL"),
+            )
+        )
+        for preset in candidates.scalars().all():
+            try:
+                setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting
+                preset_setting_id = setting_data.get("setting_id", "")
+                if preset_setting_id in id_map:
+                    original_id = id_map[preset_setting_id]
+                    info = {"name": preset.name, "k": None}
+                    # Try to extract K value from the local preset
+                    pa = setting_data.get("pressure_advance")
+                    if pa is not None:
+                        try:
+                            k_val = float(pa[0]) if isinstance(pa, list) else float(pa)
+                            info["k"] = k_val
+                        except (ValueError, TypeError, IndexError):
+                            pass
+                    _filament_cache[original_id] = info
+                    result[original_id] = info
+            except Exception:
+                continue
+    except Exception as e:
+        logger.warning("Failed to search local presets for filament info: %s", e)
+
+    # Phase 4: Fall back to built-in filament name table for any still without a name
+    for fid in unresolved_ids:
+        if fid not in result or not result[fid].get("name"):
+            name = _BUILTIN_FILAMENT_NAMES.get(fid, "")
+            if name:
+                # Preserve K value from earlier phases if available
+                existing_k = result.get(fid, {}).get("k")
+                info = {"name": name, "k": existing_k}
+                _filament_cache[fid] = info
+                result[fid] = info
+
+    # Fill remaining unresolved with empty entries
+    for fid in unresolved_ids:
+        if fid not in result:
+            _filament_cache[fid] = {"name": "", "k": None}
+            result[fid] = {"name": "", "k": None}
+
+    return result
+
 
 
 def _filament_id_to_setting_id(filament_id: str) -> str:
 def _filament_id_to_setting_id(filament_id: str) -> str:
     """
     """
@@ -345,7 +509,8 @@ async def get_filament_info(
     """
     """
     Get filament preset info (name and K value) for multiple setting IDs.
     Get filament preset info (name and K value) for multiple setting IDs.
 
 
-    Used to enrich AMS tray tooltips with cloud preset data.
+    Used to enrich AMS tray and nozzle rack tooltips with preset data.
+    Lookup order: cache → cloud → local profiles → built-in table → empty fallback.
     """
     """
     import time
     import time
 
 
@@ -358,58 +523,58 @@ async def get_filament_info(
         _filament_cache = {}
         _filament_cache = {}
         _filament_cache_time = time.time()
         _filament_cache_time = time.time()
 
 
-    token, _ = await get_stored_token(db)
-    if not token:
-        logger.info("get_filament_info: Not authenticated, returning empty")
-        # Return empty results if not authenticated (graceful degradation)
-        return {}
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
-        return {}
-
     result = {}
     result = {}
+    unresolved_ids: list[str] = []
+
+    # Phase 1: Check cache
     for setting_id in setting_ids:
     for setting_id in setting_ids:
         if not setting_id:
         if not setting_id:
             continue
             continue
-
-        # Check cache first
         if setting_id in _filament_cache:
         if setting_id in _filament_cache:
             result[setting_id] = _filament_cache[setting_id]
             result[setting_id] = _filament_cache[setting_id]
-            continue
-
-        try:
-            # Transform filament_id to setting_id format (GFA00 -> GFSA00)
-            api_setting_id = _filament_id_to_setting_id(setting_id)
-
-            data = await cloud.get_setting_detail(api_setting_id)
-            setting = data.get("setting", {})
-
-            # Extract name (e.g., "Bambu PLA Basic Jade White")
-            name = data.get("name", "")
-
-            # Extract K value (pressure_advance)
-            k_value = setting.get("pressure_advance")
-            if k_value is not None:
-                try:
-                    k_value = float(k_value)
-                except (ValueError, TypeError):
-                    k_value = None
-
-            info = {"name": name, "k": k_value}
-            # Cache using original ID so frontend gets expected response
-            _filament_cache[setting_id] = info
-            result[setting_id] = info
-
-        except Exception as e:
-            logger.warning(
-                f"Failed to get cloud preset {setting_id} (API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
-            )
-            # Cache the failure to avoid repeated requests
-            _filament_cache[setting_id] = {"name": "", "k": None}
-            result[setting_id] = {"name": "", "k": None}
+        else:
+            unresolved_ids.append(setting_id)
+
+    # Phase 2: Try cloud for uncached IDs
+    if unresolved_ids:
+        token, _ = await get_stored_token(db)
+        if token:
+            cloud = get_cloud_service()
+            cloud.set_token(token)
+
+            if cloud.is_authenticated:
+                still_unresolved: list[str] = []
+                for setting_id in unresolved_ids:
+                    try:
+                        api_setting_id = _filament_id_to_setting_id(setting_id)
+                        data = await cloud.get_setting_detail(api_setting_id)
+                        setting = data.get("setting", {})
+                        name = data.get("name", "")
+                        k_value = setting.get("pressure_advance")
+                        if k_value is not None:
+                            try:
+                                k_value = float(k_value)
+                            except (ValueError, TypeError):
+                                k_value = None
+
+                        info = {"name": name, "k": k_value}
+                        _filament_cache[setting_id] = info
+                        result[setting_id] = info
+
+                        if not name:
+                            still_unresolved.append(setting_id)
+                    except Exception as e:
+                        logger.warning(
+                            f"Failed to get cloud preset {setting_id} "
+                            f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
+                        )
+                        still_unresolved.append(setting_id)
+
+                unresolved_ids = still_unresolved
+
+    # Phase 3: Try local profiles for any IDs still without a name
+    if unresolved_ids:
+        result = await _enrich_from_local_presets(unresolved_ids, result, db)
 
 
     return result
     return result
 
 

+ 4 - 0
backend/app/api/routes/discovery.py

@@ -18,6 +18,7 @@ from backend.app.services.discovery import (
     is_running_in_docker,
     is_running_in_docker,
     subnet_scanner,
     subnet_scanner,
 )
 )
+from backend.app.services.network_utils import get_network_interfaces
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/discovery", tags=["discovery"])
 router = APIRouter(prefix="/discovery", tags=["discovery"])
@@ -35,6 +36,7 @@ class DiscoveryInfo(BaseModel):
     is_docker: bool
     is_docker: bool
     ssdp_running: bool
     ssdp_running: bool
     scan_running: bool
     scan_running: bool
+    subnets: list[str] = []
 
 
 
 
 class SubnetScanRequest(BaseModel):
 class SubnetScanRequest(BaseModel):
@@ -67,10 +69,12 @@ async def get_discovery_info(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
 ):
 ):
     """Get discovery environment info (Docker detection, etc.)."""
     """Get discovery environment info (Docker detection, etc.)."""
+    subnets = [iface["subnet"] for iface in get_network_interfaces()]
     return DiscoveryInfo(
     return DiscoveryInfo(
         is_docker=is_running_in_docker(),
         is_docker=is_running_in_docker(),
         ssdp_running=discovery_service.is_running,
         ssdp_running=discovery_service.is_running,
         scan_running=subnet_scanner.is_running,
         scan_running=subnet_scanner.is_running,
+        subnets=subnets,
     )
     )
 
 
 
 

+ 200 - 0
backend/app/api/routes/local_presets.py

@@ -0,0 +1,200 @@
+"""API routes for local slicer presets (imported from OrcaSlicer, etc.)."""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.user import User
+from backend.app.schemas.local_preset import (
+    ImportResponse,
+    LocalPresetCreate,
+    LocalPresetDetail,
+    LocalPresetResponse,
+    LocalPresetsResponse,
+    LocalPresetUpdate,
+)
+from backend.app.services.orca_profiles import (
+    extract_core_fields,
+    get_cache_status,
+    import_orca_file,
+    reclassify_presets,
+    refresh_base_cache,
+    resolve_preset,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/local-presets", tags=["Local Presets"])
+
+
+@router.get("/", response_model=LocalPresetsResponse)
+async def list_local_presets(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all local presets grouped by type."""
+    result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
+    presets = result.scalars().all()
+
+    grouped = LocalPresetsResponse()
+    for p in presets:
+        resp = LocalPresetResponse.model_validate(p)
+        if p.preset_type == "filament":
+            grouped.filament.append(resp)
+        elif p.preset_type == "printer":
+            grouped.printer.append(resp)
+        elif p.preset_type == "process":
+            grouped.process.append(resp)
+
+    return grouped
+
+
+@router.get("/{preset_id}", response_model=LocalPresetDetail)
+async def get_local_preset(
+    preset_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get full detail for a local preset including the setting JSON."""
+    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
+    preset = result.scalar_one_or_none()
+    if not preset:
+        raise HTTPException(404, "Local preset not found")
+
+    data = LocalPresetResponse.model_validate(preset).model_dump()
+    try:
+        data["setting"] = json.loads(preset.setting)
+    except Exception:
+        data["setting"] = {}
+
+    return LocalPresetDetail(**data)
+
+
+@router.post("/import", response_model=ImportResponse)
+async def import_presets(
+    file: UploadFile,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip)."""
+    if not file.filename:
+        raise HTTPException(400, "No filename provided")
+
+    content = await file.read()
+    if not content:
+        raise HTTPException(400, "Empty file")
+
+    result = await import_orca_file(file.filename, content, db)
+    return ImportResponse(**result)
+
+
+@router.post("/", response_model=LocalPresetResponse)
+async def create_local_preset(
+    data: LocalPresetCreate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually create a local preset."""
+    if data.preset_type not in ("filament", "printer", "process"):
+        raise HTTPException(400, "preset_type must be filament, printer, or process")
+
+    # Extract core fields
+    core = extract_core_fields(data.setting)
+
+    preset = LocalPreset(
+        name=data.name,
+        preset_type=data.preset_type,
+        source="manual",
+        setting=json.dumps(data.setting),
+        **core,
+    )
+    db.add(preset)
+    await db.flush()
+    await db.refresh(preset)
+    return LocalPresetResponse.model_validate(preset)
+
+
+@router.put("/{preset_id}", response_model=LocalPresetResponse)
+async def update_local_preset(
+    preset_id: int,
+    data: LocalPresetUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a local preset's name or settings."""
+    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
+    preset = result.scalar_one_or_none()
+    if not preset:
+        raise HTTPException(404, "Local preset not found")
+
+    if data.name is not None:
+        preset.name = data.name
+
+    if data.setting is not None:
+        # Re-resolve and extract core fields
+        resolved = await resolve_preset(data.setting, preset.preset_type, db)
+        core = extract_core_fields(resolved)
+        preset.setting = json.dumps(resolved)
+        preset.filament_type = core.get("filament_type")
+        preset.filament_vendor = core.get("filament_vendor")
+        preset.nozzle_temp_min = core.get("nozzle_temp_min")
+        preset.nozzle_temp_max = core.get("nozzle_temp_max")
+        preset.pressure_advance = core.get("pressure_advance")
+        preset.default_filament_colour = core.get("default_filament_colour")
+        preset.filament_cost = core.get("filament_cost")
+        preset.filament_density = core.get("filament_density")
+        preset.compatible_printers = core.get("compatible_printers")
+
+    await db.flush()
+    await db.refresh(preset)
+    return LocalPresetResponse.model_validate(preset)
+
+
+@router.delete("/{preset_id}")
+async def delete_local_preset(
+    preset_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a local preset."""
+    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
+    preset = result.scalar_one_or_none()
+    if not preset:
+        raise HTTPException(404, "Local preset not found")
+
+    await db.delete(preset)
+    return {"success": True}
+
+
+@router.get("/base-cache/status")
+async def base_cache_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the status of the OrcaSlicer base profile cache."""
+    return await get_cache_status(db)
+
+
+@router.post("/base-cache/refresh")
+async def refresh_cache(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Force refresh all cached base profiles from GitHub."""
+    return await refresh_base_cache(db)
+
+
+@router.post("/reclassify")
+async def reclassify(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Re-evaluate preset types for all local presets using the improved heuristic."""
+    return await reclassify_presets(db)

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

@@ -43,6 +43,9 @@ EVENT_NAMES = {
     "queue_job_skipped": "Queue Job Skipped",
     "queue_job_skipped": "Queue Job Skipped",
     "queue_job_failed": "Queue Job Failed",
     "queue_job_failed": "Queue Job Failed",
     "queue_completed": "Queue Completed",
     "queue_completed": "Queue Completed",
+    # User management
+    "user_created": "Welcome Email",
+    "password_reset": "Password Reset",
 }
 }
 
 
 
 

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

@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
     AMSUnit,
     AMSUnit,
     HMSErrorResponse,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleInfoResponse,
+    NozzleRackSlot,
     PrinterCreate,
     PrinterCreate,
     PrinterResponse,
     PrinterResponse,
     PrinterStatus,
     PrinterStatus,
@@ -360,6 +361,23 @@ async def get_printer_status(
         for n in (state.nozzles or [])
         for n in (state.nozzles or [])
     ]
     ]
 
 
+    # H2C nozzle rack (tool-changer dock positions)
+    nozzle_rack = [
+        NozzleRackSlot(
+            id=n.get("id", 0),
+            nozzle_type=n.get("type", ""),
+            nozzle_diameter=n.get("diameter", ""),
+            wear=n.get("wear"),
+            stat=n.get("stat"),
+            max_temp=n.get("max_temp", 0),
+            serial_number=n.get("serial_number", ""),
+            filament_color=n.get("filament_color", ""),
+            filament_id=n.get("filament_id", ""),
+            filament_type=n.get("filament_type", ""),
+        )
+        for n in (state.nozzle_rack or [])
+    ]
+
     # Convert print options to response format
     # Convert print options to response format
     print_options = PrintOptionsResponse(
     print_options = PrintOptionsResponse(
         spaghetti_detector=state.print_options.spaghetti_detector,
         spaghetti_detector=state.print_options.spaghetti_detector,
@@ -423,6 +441,7 @@ async def get_printer_status(
         ipcam=state.ipcam,
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
         wifi_signal=state.wifi_signal,
         nozzles=nozzles,
         nozzles=nozzles,
+        nozzle_rack=nozzle_rack,
         print_options=print_options,
         print_options=print_options,
         stg_cur=state.stg_cur,
         stg_cur=state.stg_cur,
         stg_cur_name=get_derived_status_name(state, printer.model),
         stg_cur_name=get_derived_status_name(state, printer.model),
@@ -535,10 +554,15 @@ async def test_printer_connection(
     return result
     return result
 
 
 
 
-# Cache for cover images (printer_id -> {(gcode_file, view) -> image_bytes})
+# Cache for cover images (printer_id -> {(subtask_name, plate_num, view) -> image_bytes})
 _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 
 
 
 
+def clear_cover_cache(printer_id: int) -> None:
+    """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
+    _cover_cache.pop(printer_id, None)
+
+
 @router.get("/{printer_id}/cover")
 @router.get("/{printer_id}/cover")
 async def get_printer_cover(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
@@ -1484,6 +1508,7 @@ async def save_slot_preset(
     tray_id: int,
     tray_id: int,
     preset_id: str,
     preset_id: str,
     preset_name: str,
     preset_name: str,
+    preset_source: str = "cloud",
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -1507,6 +1532,7 @@ async def save_slot_preset(
         # Update existing
         # Update existing
         mapping.preset_id = preset_id
         mapping.preset_id = preset_id
         mapping.preset_name = preset_name
         mapping.preset_name = preset_name
+        mapping.preset_source = preset_source
     else:
     else:
         # Create new
         # Create new
         mapping = SlotPresetMapping(
         mapping = SlotPresetMapping(
@@ -1515,6 +1541,7 @@ async def save_slot_preset(
             tray_id=tray_id,
             tray_id=tray_id,
             preset_id=preset_id,
             preset_id=preset_id,
             preset_name=preset_name,
             preset_name=preset_name,
+            preset_source=preset_source,
         )
         )
         db.add(mapping)
         db.add(mapping)
 
 
@@ -1526,6 +1553,7 @@ async def save_slot_preset(
         "tray_id": mapping.tray_id,
         "tray_id": mapping.tray_id,
         "preset_id": mapping.preset_id,
         "preset_id": mapping.preset_id,
         "preset_name": mapping.preset_name,
         "preset_name": mapping.preset_name,
+        "preset_source": mapping.preset_source,
     }
     }
 
 
 
 

+ 62 - 0
backend/app/api/routes/settings.py

@@ -32,6 +32,27 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
     return setting.value if setting else None
     return setting.value if setting else None
 
 
 
 
+async def get_external_login_url(db: AsyncSession) -> str:
+    """Get the external URL for the login page.
+
+    Uses external_url from settings if available, otherwise falls back to APP_URL env var.
+
+    Args:
+        db: Database session
+
+    Returns:
+        Full URL to the login page
+    """
+    import os
+
+    external_url = await get_setting(db, "external_url")
+    if external_url:
+        external_url = external_url.rstrip("/")
+    else:
+        external_url = os.environ.get("APP_URL", "http://localhost:5173")
+    return external_url + "/login"
+
+
 async def set_setting(db: AsyncSession, key: str, value: str) -> None:
 async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     """Set a single setting value."""
     """Set a single setting value."""
     from sqlalchemy import func
     from sqlalchemy import func
@@ -101,6 +122,10 @@ async def get_settings(
             else:
             else:
                 settings_dict[setting.key] = setting.value
                 settings_dict[setting.key] = setting.value
 
 
+    # Get Home Assistant settings (with environment variable overrides)
+    ha_settings = await get_homeassistant_settings(db)
+    settings_dict.update(ha_settings)
+
     return AppSettings(**settings_dict)
     return AppSettings(**settings_dict)
 
 
 
 
@@ -247,6 +272,43 @@ async def update_spoolman_settings(
     return await get_spoolman_settings(db)
     return await get_spoolman_settings(db)
 
 
 
 
+async def get_homeassistant_settings(db: AsyncSession) -> dict:
+    """
+    Get Home Assistant integration settings.
+    Environment variables (HA_URL, HA_TOKEN) take precedence over database settings.
+    """
+    import os
+
+    # Check environment variables first
+    ha_url_env = os.environ.get("HA_URL")
+    ha_token_env = os.environ.get("HA_TOKEN")
+
+    # Fall back to database values
+    ha_url = ha_url_env or await get_setting(db, "ha_url") or ""
+    ha_token = ha_token_env or await get_setting(db, "ha_token") or ""
+    ha_enabled_db = await get_setting(db, "ha_enabled") or "false"
+
+    # Track which settings come from environment
+    ha_url_from_env = bool(ha_url_env)
+    ha_token_from_env = bool(ha_token_env)
+    ha_env_managed = ha_url_from_env and ha_token_from_env
+
+    # Auto-enable when both env vars are set, otherwise use database value
+    if ha_url_env and ha_token_env:
+        ha_enabled = True
+    else:
+        ha_enabled = ha_enabled_db.lower() == "true"
+
+    return {
+        "ha_enabled": ha_enabled,
+        "ha_url": ha_url,
+        "ha_token": ha_token,
+        "ha_url_from_env": ha_url_from_env,
+        "ha_token_from_env": ha_token_from_env,
+        "ha_env_managed": ha_env_managed,
+    }
+
+
 @router.get("/backup")
 @router.get("/backup")
 async def create_backup(
 async def create_backup(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),

+ 14 - 7
backend/app/api/routes/smart_plugs.py

@@ -354,8 +354,11 @@ async def list_ha_entities(
 
 
     Requires HA connection settings to be configured in Settings.
     Requires HA connection settings to be configured in Settings.
     """
     """
-    ha_url = await get_setting(db, "ha_url") or ""
-    ha_token = await get_setting(db, "ha_token") or ""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    ha_settings = await get_homeassistant_settings(db)
+    ha_url = ha_settings["ha_url"]
+    ha_token = ha_settings["ha_token"]
 
 
     if not ha_url or not ha_token:
     if not ha_url or not ha_token:
         raise HTTPException(
         raise HTTPException(
@@ -376,8 +379,11 @@ async def list_ha_sensor_entities(
     Returns sensors with power/energy units (W, kW, kWh, Wh).
     Returns sensors with power/energy units (W, kW, kWh, Wh).
     Requires HA connection settings to be configured in Settings.
     Requires HA connection settings to be configured in Settings.
     """
     """
-    ha_url = await get_setting(db, "ha_url") or ""
-    ha_token = await get_setting(db, "ha_token") or ""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    ha_settings = await get_homeassistant_settings(db)
+    ha_url = ha_settings["ha_url"]
+    ha_token = ha_settings["ha_token"]
 
 
     if not ha_url or not ha_token:
     if not ha_url or not ha_token:
         raise HTTPException(
         raise HTTPException(
@@ -546,9 +552,10 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
     """
     """
     if plug.plug_type == "homeassistant":
     if plug.plug_type == "homeassistant":
         # Configure HA service with current settings
         # Configure HA service with current settings
-        ha_url = await get_setting(db, "ha_url") or ""
-        ha_token = await get_setting(db, "ha_token") or ""
-        homeassistant_service.configure(ha_url, ha_token)
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        ha_settings = await get_homeassistant_settings(db)
+        homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         return homeassistant_service
         return homeassistant_service
     return tasmota_service
     return tasmota_service
 
 

+ 61 - 6
backend/app/api/routes/spoolman.py

@@ -217,6 +217,19 @@ async def sync_printer_ams(
             detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
             detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
         )
         )
 
 
+    # OPTIMIZATION: Fetch all spools once before processing trays
+    # This eliminates redundant API calls (one per tray) when syncing multiple trays
+    logger.debug("[Printer %s] Fetching spools cache for sync...", printer.name)
+    try:
+        cached_spools = await client.get_spools()
+        logger.debug("[Printer %s] Cached %d spools for batch sync", printer.name, len(cached_spools))
+    except Exception as e:
+        logger.error("[Printer %s] Failed to fetch spools cache after retries: %s", printer.name, e)
+        raise HTTPException(
+            status_code=503,
+            detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
+        )
+
     for ams_unit in ams_units:
     for ams_unit in ams_units:
         if not isinstance(ams_unit, dict):
         if not isinstance(ams_unit, dict):
             continue
             continue
@@ -257,9 +270,20 @@ async def sync_printer_ams(
                 current_tray_uuids.add(spool_tag.upper())
                 current_tray_uuids.add(spool_tag.upper())
 
 
             try:
             try:
-                sync_result = await client.sync_ams_tray(tray, printer.name, disable_weight_sync=disable_weight_sync)
+                sync_result = await client.sync_ams_tray(
+                    tray,
+                    printer.name,
+                    disable_weight_sync=disable_weight_sync,
+                    cached_spools=cached_spools,
+                )
                 if sync_result:
                 if sync_result:
                     synced += 1
                     synced += 1
+                    # Add newly created spool to cache
+                    if sync_result.get("id"):
+                        spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
+                        if not spool_exists:
+                            cached_spools.append(sync_result)
+                            logger.debug("Added newly created spool %s to cache", sync_result["id"])
                     logger.info(
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
                     )
@@ -273,7 +297,9 @@ async def sync_printer_ams(
 
 
     # Clear location for spools that were removed from this printer's AMS
     # Clear location for spools that were removed from this printer's AMS
     try:
     try:
-        cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
+        cleared = await client.clear_location_for_removed_spools(
+            printer.name, current_tray_uuids, cached_spools=cached_spools
+        )
         if cleared > 0:
         if cleared > 0:
             logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
             logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
     except Exception as e:
     except Exception as e:
@@ -320,6 +346,19 @@ async def sync_all_printers(
     # Track tray UUIDs per printer (for clearing removed spools)
     # Track tray UUIDs per printer (for clearing removed spools)
     printer_tray_uuids: dict[str, set[str]] = {}
     printer_tray_uuids: dict[str, set[str]] = {}
 
 
+    # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
+    # This eliminates redundant API calls across all printers
+    logger.debug("Fetching spools cache for sync-all operation...")
+    try:
+        cached_spools = await client.get_spools()
+        logger.debug("Cached %d spools for batch sync across %d printers", len(cached_spools), len(printers))
+    except Exception as e:
+        logger.error("Failed to fetch spools cache after retries: %s", e)
+        raise HTTPException(
+            status_code=503,
+            detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
+        )
+
     for printer in printers:
     for printer in printers:
         state = printer_manager.get_status(printer.id)
         state = printer_manager.get_status(printer.id)
         if not state or not state.raw_data:
         if not state or not state.raw_data:
@@ -394,17 +433,28 @@ async def sync_all_printers(
 
 
                 try:
                 try:
                     sync_result = await client.sync_ams_tray(
                     sync_result = await client.sync_ams_tray(
-                        tray, printer.name, disable_weight_sync=disable_weight_sync
+                        tray,
+                        printer.name,
+                        disable_weight_sync=disable_weight_sync,
+                        cached_spools=cached_spools,
                     )
                     )
                     if sync_result:
                     if sync_result:
                         total_synced += 1
                         total_synced += 1
+                        # Add newly created spool to cache
+                        if sync_result.get("id"):
+                            spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
+                            if not spool_exists:
+                                cached_spools.append(sync_result)
+                                logger.debug("Added newly created spool %s to cache", sync_result["id"])
                 except Exception as e:
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
 
     # Clear location for spools that were removed from each printer's AMS
     # Clear location for spools that were removed from each printer's AMS
     for printer_name, current_tray_uuids in printer_tray_uuids.items():
     for printer_name, current_tray_uuids in printer_tray_uuids.items():
         try:
         try:
-            cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
+            cleared = await client.clear_location_for_removed_spools(
+                printer_name, current_tray_uuids, cached_spools=cached_spools
+            )
             if cleared > 0:
             if cleared > 0:
                 logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
                 logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
         except Exception as e:
         except Exception as e:
@@ -548,7 +598,7 @@ async def get_linked_spools(
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
 
     spools = await client.get_spools()
     spools = await client.get_spools()
-    linked: dict[str, int] = {}
+    linked: dict[str, dict] = {}
 
 
     for spool in spools:
     for spool in spools:
         # Check if spool has a tag in extra field
         # Check if spool has a tag in extra field
@@ -558,7 +608,12 @@ async def get_linked_spools(
             # Remove quotes if present (JSON encoded string)
             # Remove quotes if present (JSON encoded string)
             clean_tag = tag.strip('"').upper()
             clean_tag = tag.strip('"').upper()
             if clean_tag:
             if clean_tag:
-                linked[clean_tag] = spool["id"]
+                filament = spool.get("filament") or {}
+                linked[clean_tag] = {
+                    "id": spool["id"],
+                    "remaining_weight": spool.get("remaining_weight"),
+                    "filament_weight": filament.get("weight"),
+                }
 
 
     return {"linked": linked}
     return {"linked": linked}
 
 

+ 261 - 3
backend/app/api/routes/support.py

@@ -1,6 +1,9 @@
 """Support endpoints for debug logging and support bundle generation."""
 """Support endpoints for debug logging and support bundle generation."""
 
 
+import asyncio
+import importlib.metadata
 import io
 import io
+import ipaddress
 import json
 import json
 import logging
 import logging
 import os
 import os
@@ -8,24 +11,30 @@ import platform
 import re
 import re
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
+from pathlib import Path
 
 
 from fastapi import APIRouter, HTTPException, Query
 from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
 from pydantic import BaseModel
-from sqlalchemy import func, select
+from sqlalchemy import func, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.notification import NotificationProvider
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.network_utils import get_network_interfaces
+from backend.app.services.printer_manager import printer_manager
 
 
 router = APIRouter(prefix="/support", tags=["support"])
 router = APIRouter(prefix="/support", tags=["support"])
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -313,8 +322,71 @@ def _sanitize_path(path: str) -> str:
     return path
     return path
 
 
 
 
+def _anonymize_mqtt_broker(broker: str) -> str:
+    """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
+    if not broker:
+        return ""
+    try:
+        ipaddress.ip_address(broker)
+        return "[IP]"
+    except ValueError:
+        # It's a hostname — show *.domain pattern
+        parts = broker.split(".")
+        if len(parts) >= 2:
+            return "*." + ".".join(parts[-2:])
+        return broker
+
+
+async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
+    """Test TCP connectivity to ip:port. Returns True if reachable."""
+    try:
+        _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+        writer.close()
+        await writer.wait_closed()
+        return True
+    except Exception:
+        return False
+
+
+def _get_container_memory_limit() -> int | None:
+    """Read cgroup memory limit. Returns bytes or None."""
+    # cgroup v2
+    v2 = Path("/sys/fs/cgroup/memory.max")
+    if v2.exists():
+        try:
+            val = v2.read_text().strip()
+            if val != "max":
+                return int(val)
+        except Exception:
+            pass
+    # cgroup v1
+    v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
+    if v1.exists():
+        try:
+            val = int(v1.read_text().strip())
+            # Values near page-aligned max (2^63-4096) mean unlimited
+            if val < 2**62:
+                return val
+        except Exception:
+            pass
+    return None
+
+
+def _format_bytes(size_bytes: int) -> str:
+    """Format bytes into human-readable string."""
+    if size_bytes < 1024:
+        return f"{size_bytes} B"
+    if size_bytes < 1024 * 1024:
+        return f"{size_bytes / 1024:.1f} KB"
+    if size_bytes < 1024 * 1024 * 1024:
+        return f"{size_bytes / (1024 * 1024):.1f} MB"
+    return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
+
+
 async def _collect_support_info() -> dict:
 async def _collect_support_info() -> dict:
     """Collect all support information."""
     """Collect all support information."""
+    in_docker = is_running_in_docker()
+
     info = {
     info = {
         "generated_at": datetime.now().isoformat(),
         "generated_at": datetime.now().isoformat(),
         "app": {
         "app": {
@@ -329,15 +401,29 @@ async def _collect_support_info() -> dict:
             "python_version": platform.python_version(),
             "python_version": platform.python_version(),
         },
         },
         "environment": {
         "environment": {
-            "docker": os.path.exists("/.dockerenv"),
+            "docker": in_docker,
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
+            "timezone": os.environ.get("TZ", ""),
         },
         },
         "database": {},
         "database": {},
         "printers": [],
         "printers": [],
         "settings": {},
         "settings": {},
     }
     }
 
 
+    # Docker-specific info
+    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",
+            }
+        except Exception:
+            logger.debug("Failed to collect Docker info", exc_info=True)
+
     async with async_session() as db:
     async with async_session() as db:
         # Database stats
         # Database stats
         result = await db.execute(select(func.count(PrintArchive.id)))
         result = await db.execute(select(func.count(PrintArchive.id)))
@@ -358,15 +444,55 @@ async def _collect_support_info() -> dict:
         result = await db.execute(select(func.count(SmartPlug.id)))
         result = await db.execute(select(func.count(SmartPlug.id)))
         info["database"]["smart_plugs_total"] = result.scalar() or 0
         info["database"]["smart_plugs_total"] = result.scalar() or 0
 
 
-        # Printer info (anonymized - just models and connection status)
+        # Printer info (anonymized - no names, IPs, or serials)
         result = await db.execute(select(Printer))
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
         printers = result.scalars().all()
+        statuses = printer_manager.get_all_statuses()
+
+        # Check reachability in parallel
+        reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
+        reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
+
         for i, printer in enumerate(printers):
         for i, printer in enumerate(printers):
+            state = statuses.get(printer.id)
+            reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
+
+            # Count AMS units and trays from raw_data
+            ams_unit_count = 0
+            ams_tray_count = 0
+            has_vt_tray = False
+            if state:
+                ams_data = state.raw_data.get("ams")
+                if isinstance(ams_data, list):
+                    ams_units = ams_data
+                elif isinstance(ams_data, dict) and "ams" in ams_data:
+                    ams_units = ams_data["ams"] if isinstance(ams_data["ams"], list) else []
+                else:
+                    ams_units = []
+                ams_unit_count = len(ams_units)
+                for unit in ams_units:
+                    trays = unit.get("tray", [])
+                    ams_tray_count += len([t for t in trays if t.get("tray_type")])
+                has_vt_tray = state.raw_data.get("vt_tray") is not None
+
             info["printers"].append(
             info["printers"].append(
                 {
                 {
                     "index": i + 1,
                     "index": i + 1,
                     "model": printer.model or "Unknown",
                     "model": printer.model or "Unknown",
                     "nozzle_count": printer.nozzle_count,
                     "nozzle_count": printer.nozzle_count,
+                    "is_active": printer.is_active,
+                    "mqtt_connected": state.connected if state else False,
+                    "state": state.state if state else "unknown",
+                    "firmware_version": state.firmware_version if state else None,
+                    "wifi_signal": state.wifi_signal if state else None,
+                    "reachable": bool(reachable),
+                    "ams_unit_count": ams_unit_count,
+                    "ams_tray_count": ams_tray_count,
+                    "has_vt_tray": has_vt_tray,
+                    "external_camera_configured": bool(printer.external_camera_url),
+                    "plate_detection_enabled": printer.plate_detection_enabled,
+                    "hms_error_count": len(state.hms_errors) if state else 0,
+                    "nozzle_rack_count": len(state.nozzle_rack) if state else 0,
                 }
                 }
             )
             )
 
 
@@ -396,6 +522,138 @@ async def _collect_support_info() -> dict:
                 continue
                 continue
             info["settings"][s.key] = s.value
             info["settings"][s.key] = s.value
 
 
+        # Notification providers (anonymized — type/enabled/error status only)
+        try:
+            result = await db.execute(select(NotificationProvider))
+            providers = result.scalars().all()
+            info["integrations"] = info.get("integrations", {})
+            info["integrations"]["notification_providers"] = [
+                {
+                    "type": p.provider_type,
+                    "enabled": p.enabled,
+                    "has_last_error": bool(p.last_error),
+                }
+                for p in providers
+            ]
+        except Exception:
+            logger.debug("Failed to collect notification provider info", exc_info=True)
+
+        # Database health
+        try:
+            result = await db.execute(text("PRAGMA journal_mode"))
+            journal_mode = result.scalar()
+            result = await db.execute(text("PRAGMA quick_check"))
+            quick_check = result.scalar()
+
+            db_path = settings.base_dir / "bambuddy.db"
+            db_size = db_path.stat().st_size if db_path.exists() else 0
+            wal_path = settings.base_dir / "bambuddy.db-wal"
+            wal_size = wal_path.stat().st_size if wal_path.exists() else 0
+
+            info["database_health"] = {
+                "journal_mode": journal_mode,
+                "quick_check": quick_check,
+                "db_size_bytes": db_size,
+                "wal_size_bytes": wal_size,
+            }
+        except Exception:
+            logger.debug("Failed to collect database health info", exc_info=True)
+
+    # Integrations (lazy imports to avoid circular dependencies)
+    info.setdefault("integrations", {})
+
+    # Spoolman
+    try:
+        from backend.app.services.spoolman import get_spoolman_client
+
+        client = await get_spoolman_client()
+        if client:
+            reachable = await client.health_check()
+            info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
+        else:
+            info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
+    except Exception:
+        logger.debug("Failed to collect Spoolman info", exc_info=True)
+
+    # MQTT relay
+    try:
+        from backend.app.services.mqtt_relay import mqtt_relay
+
+        status = mqtt_relay.get_status()
+        info["integrations"]["mqtt_relay"] = {
+            "enabled": status.get("enabled", False),
+            "connected": status.get("connected", False),
+            "broker": _anonymize_mqtt_broker(status.get("broker", "")),
+            "port": status.get("port", 0),
+            "topic_prefix": status.get("topic_prefix", ""),
+        }
+    except Exception:
+        logger.debug("Failed to collect MQTT relay info", exc_info=True)
+
+    # Home Assistant (check ha_enabled setting)
+    try:
+        info["integrations"]["homeassistant"] = {
+            "enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
+        }
+    except Exception:
+        logger.debug("Failed to collect Home Assistant info", exc_info=True)
+
+    # Dependencies
+    try:
+        dep_packages = [
+            "fastapi",
+            "uvicorn",
+            "pydantic",
+            "sqlalchemy",
+            "paho-mqtt",
+            "psutil",
+            "httpx",
+            "aiofiles",
+            "cryptography",
+            "opencv-python-headless",
+            "numpy",
+        ]
+        info["dependencies"] = {}
+        for pkg in dep_packages:
+            try:
+                info["dependencies"][pkg] = importlib.metadata.version(pkg)
+            except importlib.metadata.PackageNotFoundError:
+                info["dependencies"][pkg] = None
+    except Exception:
+        logger.debug("Failed to collect dependency info", exc_info=True)
+
+    # Log file info
+    try:
+        log_file = settings.log_dir / "bambuddy.log"
+        if log_file.exists():
+            size = log_file.stat().st_size
+            info["log_file"] = {
+                "size_bytes": size,
+                "size_formatted": _format_bytes(size),
+            }
+        else:
+            info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
+    except Exception:
+        logger.debug("Failed to collect log file info", exc_info=True)
+
+    # Network interfaces (subnets only — already anonymized)
+    try:
+        interfaces = get_network_interfaces()
+        info["network"] = {
+            "interface_count": len(interfaces),
+            "interfaces": [{"name": iface["name"], "subnet": iface["subnet"]} for iface in interfaces],
+        }
+    except Exception:
+        logger.debug("Failed to collect network info", exc_info=True)
+
+    # WebSocket connections
+    try:
+        info["websockets"] = {
+            "active_connections": len(ws_manager.active_connections),
+        }
+    except Exception:
+        logger.debug("Failed to collect WebSocket info", exc_info=True)
+
     return info
     return info
 
 
 
 

+ 88 - 6
backend/app/api/routes/users.py

@@ -3,6 +3,7 @@ from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_current_user_optional,
@@ -15,8 +16,15 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
+from backend.app.services.email_service import (
+    create_welcome_email_from_template,
+    generate_secure_password,
+    get_smtp_settings,
+    send_email,
+)
 
 
 router = APIRouter(prefix="/users", tags=["users"])
 router = APIRouter(prefix="/users", tags=["users"])
 
 
@@ -26,6 +34,7 @@ def _user_to_response(user: User) -> UserResponse:
     return UserResponse(
     return UserResponse(
         id=user.id,
         id=user.id,
         username=user.username,
         username=user.username,
+        email=user.email,
         role=user.role,
         role=user.role,
         is_active=user.is_active,
         is_active=user.is_active,
         is_admin=user.is_admin,
         is_admin=user.is_admin,
@@ -54,9 +63,24 @@ async def create_user(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Create a new user."""
-    # Check if username already exists
-    existing_user = await db.execute(select(User).where(User.username == user_data.username))
+    """Create a new user.
+
+    When advanced authentication is enabled:
+    - Email is required
+    - Password is auto-generated and emailed to user
+    - Admin cannot set or see the password
+    """
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    # Check if advanced auth is enabled
+    result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
+    advanced_auth_setting = result.scalar_one_or_none()
+    advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
+
+    # Check if username already exists (case-insensitive)
+    existing_user = await db.execute(select(User).where(func.lower(User.username) == func.lower(user_data.username)))
     if existing_user.scalar_one_or_none():
     if existing_user.scalar_one_or_none():
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,
@@ -70,9 +94,36 @@ async def create_user(
             detail="Role must be 'admin' or 'user'",
             detail="Role must be 'admin' or 'user'",
         )
         )
 
 
+    # Advanced auth validation
+    if advanced_auth_enabled:
+        if not user_data.email:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Email is required when advanced authentication is enabled",
+            )
+        # Check if email already exists (case-insensitive)
+        existing_email = await db.execute(select(User).where(func.lower(User.email) == func.lower(user_data.email)))
+        if existing_email.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Email already exists",
+            )
+
+    # Generate password if advanced auth enabled, otherwise require password
+    if advanced_auth_enabled:
+        password = generate_secure_password()
+    else:
+        if not user_data.password:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Password is required when advanced authentication is disabled",
+            )
+        password = user_data.password
+
     new_user = User(
     new_user = User(
         username=user_data.username,
         username=user_data.username,
-        password_hash=get_password_hash(user_data.password),
+        email=user_data.email,
+        password_hash=get_password_hash(password),
         role=user_data.role,
         role=user_data.role,
         is_active=True,
         is_active=True,
     )
     )
@@ -92,6 +143,23 @@ async def create_user(
     await db.commit()
     await db.commit()
     await db.refresh(new_user)
     await db.refresh(new_user)
 
 
+    # Send welcome email if advanced auth enabled
+    if advanced_auth_enabled and new_user.email:
+        try:
+            smtp_settings = await get_smtp_settings(db)
+            if smtp_settings:
+                login_url = await get_external_login_url(db)
+                subject, text_body, html_body = await create_welcome_email_from_template(
+                    db, new_user.username, password, login_url
+                )
+                send_email(smtp_settings, new_user.email, subject, text_body, html_body)
+                logger.info(f"Welcome email sent to {new_user.email}")
+            else:
+                logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
+        except Exception as e:
+            logger.error(f"Failed to send welcome email: {e}")
+            # Don't fail user creation if email fails
+
     return _user_to_response(new_user)
     return _user_to_response(new_user)
 
 
 
 
@@ -161,8 +229,10 @@ async def update_user(
             )
             )
 
 
     if user_data.username is not None:
     if user_data.username is not None:
-        # Check if new username already exists
-        existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
+        # Check if new username already exists (case-insensitive)
+        existing_user = await db.execute(
+            select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
+        )
         if existing_user.scalar_one_or_none():
         if existing_user.scalar_one_or_none():
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
@@ -170,6 +240,18 @@ async def update_user(
             )
             )
         user.username = user_data.username
         user.username = user_data.username
 
 
+    if user_data.email is not None:
+        # Check if new email already exists (case-insensitive)
+        existing_email = await db.execute(
+            select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
+        )
+        if existing_email.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Email already exists",
+            )
+        user.email = user_data.email
+
     if user_data.password is not None:
     if user_data.password is not None:
         user.password_hash = get_password_hash(user_data.password)
         user.password_hash = get_password_hash(user_data.password)
 
 

+ 32 - 4
backend/app/core/auth.py

@@ -12,7 +12,7 @@ from fastapi import Depends, Header, HTTPException, status
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from jwt.exceptions import PyJWTError as JWTError
 from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
 from passlib.context import CryptContext
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -128,13 +128,26 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
 
 
 
 
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
-    """Get a user by username with groups loaded for permission checks."""
-    result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups)))
+    """Get a user by username (case-insensitive) with groups loaded for permission checks."""
+    result = await db.execute(
+        select(User).where(func.lower(User.username) == func.lower(username)).options(selectinload(User.groups))
+    )
+    return result.scalar_one_or_none()
+
+
+async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
+    """Get a user by email (case-insensitive) with groups loaded for permission checks."""
+    result = await db.execute(
+        select(User).where(func.lower(User.email) == func.lower(email)).options(selectinload(User.groups))
+    )
     return result.scalar_one_or_none()
     return result.scalar_one_or_none()
 
 
 
 
 async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
 async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
-    """Authenticate a user by username and password."""
+    """Authenticate a user by username and password.
+
+    Username lookup is case-insensitive. Password is case-sensitive.
+    """
     user = await get_user_by_username(db, username)
     user = await get_user_by_username(db, username)
     if not user:
     if not user:
         return None
         return None
@@ -145,6 +158,21 @@ async def authenticate_user(db: AsyncSession, username: str, password: str) -> U
     return user
     return user
 
 
 
 
+async def authenticate_user_by_email(db: AsyncSession, email: str, password: str) -> User | None:
+    """Authenticate a user by email and password.
+
+    Email lookup is case-insensitive. Password is case-sensitive.
+    """
+    user = await get_user_by_email(db, email)
+    if not user:
+        return None
+    if not verify_password(password, user.password_hash):
+        return None
+    if not user.is_active:
+        return None
+    return user
+
+
 async def is_auth_enabled(db: AsyncSession) -> bool:
 async def is_auth_enabled(db: AsyncSession) -> bool:
     """Check if authentication is enabled."""
     """Check if authentication is enabled."""
     try:
     try:

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

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

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

@@ -65,9 +65,11 @@ async def init_db():
         group,
         group,
         kprofile_note,
         kprofile_note,
         library,
         library,
+        local_preset,
         maintenance,
         maintenance,
         notification,
         notification,
         notification_template,
         notification_template,
+        orca_base_cache,
         pending_upload,
         pending_upload,
         print_queue,
         print_queue,
         printer,
         printer,
@@ -1102,6 +1104,20 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add preset_source column to slot_preset_mappings for local preset support
+    try:
+        await conn.execute(
+            text("ALTER TABLE slot_preset_mappings ADD COLUMN preset_source VARCHAR(20) DEFAULT 'cloud'")
+        )
+    except OperationalError:
+        pass  # Already applied
+
+    # Migration: Add email column to users for Advanced Auth (PR #322)
+    try:
+        await conn.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
+    except OperationalError:
+        pass  # Already applied
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 206 - 75
backend/app/main.py

@@ -186,6 +186,7 @@ from backend.app.api.routes import (
     groups,
     groups,
     kprofiles,
     kprofiles,
     library,
     library,
+    local_presets,
     maintenance,
     maintenance,
     metrics,
     metrics,
     notification_templates,
     notification_templates,
@@ -260,11 +261,10 @@ async def _get_plug_energy(plug, db) -> dict | None:
     For MQTT plugs, returns data from the subscription service.
     For MQTT plugs, returns data from the subscription service.
     """
     """
     if plug.plug_type == "homeassistant":
     if plug.plug_type == "homeassistant":
-        from backend.app.api.routes.settings import get_setting
+        from backend.app.api.routes.settings import get_homeassistant_settings
 
 
-        ha_url = await get_setting(db, "ha_url") or ""
-        ha_token = await get_setting(db, "ha_token") or ""
-        homeassistant_service.configure(ha_url, ha_token)
+        ha_settings = await get_homeassistant_settings(db)
+        homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         return await homeassistant_service.get_energy(plug)
         return await homeassistant_service.get_energy(plug)
     elif plug.plug_type == "mqtt":
     elif plug.plug_type == "mqtt":
         # MQTT plugs report "today" energy, not lifetime total
         # MQTT plugs report "today" energy, not lifetime total
@@ -557,6 +557,20 @@ async def on_ams_change(printer_id: int, ams_data: list):
             printer = result.scalar_one_or_none()
             printer = result.scalar_one_or_none()
             printer_name = printer.name if printer else f"Printer {printer_id}"
             printer_name = printer.name if printer else f"Printer {printer_id}"
 
 
+            # OPTIMIZATION: Fetch all spools once before processing trays
+            # This eliminates redundant API calls (one per tray) when syncing multiple trays
+            logger.debug("[Printer %s] Fetching spools cache for AMS sync...", printer_id)
+            try:
+                cached_spools = await client.get_spools()
+                logger.debug("[Printer %s] Cached %d spools for batch sync", printer_id, len(cached_spools))
+            except Exception as e:
+                logger.error(
+                    "[Printer %s] Failed to fetch spools cache after retries, aborting AMS sync: %s",
+                    printer_id,
+                    e,
+                )
+                return
+
             # Sync each AMS tray
             # Sync each AMS tray
             synced = 0
             synced = 0
             for ams_unit in ams_data:
             for ams_unit in ams_data:
@@ -569,9 +583,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         continue  # Empty tray
                         continue  # Empty tray
 
 
                     try:
                     try:
-                        result = await client.sync_ams_tray(tray, printer_name, disable_weight_sync=disable_weight_sync)
+                        result = await client.sync_ams_tray(
+                            tray,
+                            printer_name,
+                            disable_weight_sync=disable_weight_sync,
+                            cached_spools=cached_spools,
+                        )
                         if result:
                         if result:
                             synced += 1
                             synced += 1
+                            # If a new spool was created, add it to the cache
+                            # so subsequent trays can find it if they reference the same tag
+                            if result.get("id"):
+                                # Check if this spool already exists in cache
+                                spool_exists = any(s.get("id") == result["id"] for s in cached_spools)
+                                if not spool_exists:
+                                    cached_spools.append(result)
+                                    logger.debug(
+                                        "[Printer %s] Added newly created spool %s to cache",
+                                        printer_id,
+                                        result["id"],
+                                    )
                     except Exception as e:
                     except Exception as e:
                         logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
                         logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
 
 
@@ -697,6 +728,11 @@ async def on_print_start(printer_id: int, data: dict):
 
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
 
+    # Clear cached cover images so the new print's thumbnail is fetched fresh
+    from backend.app.api.routes.printers import clear_cover_cache
+
+    clear_cover_cache(printer_id)
+
     await ws_manager.send_print_start(printer_id, data)
     await ws_manager.send_print_start(printer_id, data)
 
 
     # MQTT relay - publish print start
     # MQTT relay - publish print start
@@ -837,6 +873,14 @@ async def on_print_start(printer_id: int, data: dict):
 
 
         logger.info("[CALLBACK] Print start detected - filename: %s, subtask: %s", filename, subtask_name)
         logger.info("[CALLBACK] Print start detected - filename: %s, subtask: %s", filename, subtask_name)
 
 
+        # Skip calibration prints — internal printer files should not be archived
+        # Bambu calibration gcode lives under /usr/ (e.g. /usr/etc/print/auto_cali_for_user.gcode)
+        if filename and filename.startswith("/usr/"):
+            logger.info("[CALLBACK] Skipping archive — internal printer file detected: %s", filename)
+            if not notification_sent:
+                await _send_print_start_notification(printer_id, data, logger=logger)
+            return
+
         if not filename and not subtask_name:
         if not filename and not subtask_name:
             # Send notification without archive data (no filename)
             # Send notification without archive data (no filename)
             logger.info("[CALLBACK] Skipping archive - no filename or subtask_name")
             logger.info("[CALLBACK] Skipping archive - no filename or subtask_name")
@@ -1365,33 +1409,105 @@ async def on_print_start(printer_id: int, data: dict):
                 temp_path.unlink()
                 temp_path.unlink()
 
 
 
 
+async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
+    """List MP4 files from printer's timelapse directory.
+
+    Returns (mp4_files, found_path) where mp4_files is a list of file dicts
+    and found_path is the directory where they were found, or ([], None).
+    """
+    from backend.app.services.bambu_ftp import list_files_async
+
+    logger = logging.getLogger(__name__)
+
+    for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
+        try:
+            found_files = await list_files_async(
+                printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
+            )
+            if found_files:
+                mp4_files = [f for f in found_files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
+                if mp4_files:
+                    return mp4_files, timelapse_path
+        except Exception as e:
+            logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
+            continue
+
+    return [], None
+
+
 async def _scan_for_timelapse_with_retries(archive_id: int):
 async def _scan_for_timelapse_with_retries(archive_id: int):
     """
     """
-    Scan for timelapse with retries.
+    Scan for timelapse with retries using a snapshot-diff approach.
 
 
-    The printer encodes the timelapse quickly after print completion.
-    We just need a short delay then grab the most recent file.
+    Instead of picking the "most recent by mtime" (unreliable when the printer
+    clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
+    waiting, then look for any NEW filename that appears after each delay.
 
 
-    Since we KNOW timelapse was active (from MQTT ipcam data), the most recent
-    file in /timelapse is our target. Retries handle FTP connection issues.
+    Falls back to name-matching (print name contained in MP4 filename) if no
+    new file appears after all retries.
     """
     """
+    from pathlib import Path
+
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
-    # Short delays - printer usually finishes encoding within seconds
-    retry_delays = [5, 10, 20]
+    # --- Phase 1: Take baseline snapshot of existing timelapse files ---
+    try:
+        async with async_session() as db:
+            from backend.app.models.printer import Printer
+
+            service = ArchiveService(db)
+            archive = await service.get_archive(archive_id)
+
+            if not archive:
+                logger.warning("[TIMELAPSE] Archive %s not found, aborting", archive_id)
+                return
+            if archive.timelapse_path:
+                logger.info("[TIMELAPSE] Archive %s already has timelapse attached", archive_id)
+                return
+            if not archive.printer_id:
+                logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
+                return
+
+            result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+            printer = result.scalar_one_or_none()
+            if not printer:
+                logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
+                return
+
+            # Snapshot current MP4 filenames as baseline
+            baseline_files, _ = await _list_timelapse_mp4s(printer)
+            baseline_names: set[str] = {f.get("name", "") for f in baseline_files}
+            logger.info(
+                "[TIMELAPSE] Baseline snapshot: %s existing MP4 files for archive %s", len(baseline_names), archive_id
+            )
+
+            # Derive base_name for name-matching fallback
+            base_name = Path(archive.filename).stem if archive.filename else ""
+            if base_name.endswith(".gcode"):
+                base_name = base_name[:-6]
+
+    except Exception as e:
+        logger.warning("[TIMELAPSE] Failed to take baseline snapshot for archive %s: %s", archive_id, e)
+        return
+
+    # --- Phase 2: Retry loop — look for NEW files that weren't in baseline ---
+    retry_delays = [5, 10, 20, 30]
 
 
     for attempt, delay in enumerate(retry_delays, 1):
     for attempt, delay in enumerate(retry_delays, 1):
         logger.info(
         logger.info(
-            f"[TIMELAPSE] Attempt {attempt}/{len(retry_delays)}: waiting {delay}s before scanning for archive {archive_id}"
+            "[TIMELAPSE] Attempt %s/%s: waiting %ss before scanning for archive %s",
+            attempt,
+            len(retry_delays),
+            delay,
+            archive_id,
         )
         )
         await asyncio.sleep(delay)
         await asyncio.sleep(delay)
 
 
         try:
         try:
             async with async_session() as db:
             async with async_session() as db:
                 from backend.app.models.printer import Printer
                 from backend.app.models.printer import Printer
-                from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
+                from backend.app.services.bambu_ftp import download_file_bytes_async
 
 
-                # Get archive (ArchiveService from module-level import)
                 service = ArchiveService(db)
                 service = ArchiveService(db)
                 archive = await service.get_archive(archive_id)
                 archive = await service.get_archive(archive_id)
 
 
@@ -1401,87 +1517,99 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
                 if archive.timelapse_path:
                 if archive.timelapse_path:
                     logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping retries", archive_id)
                     logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping retries", archive_id)
                     return
                     return
-                if not archive.printer_id:
-                    logger.warning("[TIMELAPSE] Archive %s has no printer, stopping retries", archive_id)
-                    return
 
 
-                # Get printer
                 result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
                 result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
                 printer = result.scalar_one_or_none()
                 printer = result.scalar_one_or_none()
-
                 if not printer:
                 if not printer:
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
                     return
                     return
 
 
-                # Scan timelapse directory on printer
-                # H2D may store in different locations than X1C
-                files = []
-                found_path = None
-                for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
-                    try:
-                        found_files = await list_files_async(
-                            printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
-                        )
-                        if found_files:
-                            files = found_files
-                            found_path = timelapse_path
-                            logger.info(
-                                "[TIMELAPSE] Attempt %s: Found %s files in %s", attempt, len(files), timelapse_path
-                            )
-                            break
-                    except Exception as e:
-                        logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
-                        continue
-
-                if not files:
-                    logger.info("[TIMELAPSE] Attempt %s: No timelapse files found on printer, will retry", attempt)
-                    continue
-
-                mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
-
-                # Log ALL mp4 files found for debugging
-                logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path)
-                for f in mp4_files[:5]:  # Log first 5
-                    logger.info("[TIMELAPSE]   - %s, mtime=%s", f.get("name"), f.get("mtime"))
+                mp4_files, found_path = await _list_timelapse_mp4s(printer)
 
 
                 if not mp4_files:
                 if not mp4_files:
                     logger.info("[TIMELAPSE] Attempt %s: No MP4 files found, will retry", attempt)
                     logger.info("[TIMELAPSE] Attempt %s: No MP4 files found, will retry", attempt)
                     continue
                     continue
 
 
-                # Sort by mtime descending to get most recent file
-                mp4_files_with_mtime = [f for f in mp4_files if f.get("mtime")]
-                if not mp4_files_with_mtime:
-                    logger.info("[TIMELAPSE] Attempt %s: No MP4 files with mtime found, will retry", attempt)
-                    continue
-
-                mp4_files_with_mtime.sort(key=lambda x: x.get("mtime"), reverse=True)
-                most_recent = mp4_files_with_mtime[0]
+                logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path)
+                for f in mp4_files[:5]:
+                    logger.info("[TIMELAPSE]   - %s", f.get("name"))
 
 
-                file_name = most_recent.get("name")
-                logger.info("[TIMELAPSE] Attempt %s: Most recent file: %s", attempt, file_name)
+                # Find files that are NEW (not in baseline snapshot)
+                new_files = [f for f in mp4_files if f.get("name", "") not in baseline_names]
 
 
-                # Since we KNOW timelapse was active (from MQTT), just grab the most recent file
-                remote_path = most_recent.get("path") or f"/timelapse/{file_name}"
-                logger.info("[TIMELAPSE] Downloading %s for archive %s", file_name, archive_id)
-                timelapse_data = await download_file_bytes_async(
-                    printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
-                )
+                if new_files:
+                    # Pick the first new file (there should typically be exactly one)
+                    target = new_files[0]
+                    file_name = target.get("name")
+                    remote_path = target.get("path") or f"/timelapse/{file_name}"
+                    logger.info(
+                        "[TIMELAPSE] Attempt %s: New file detected: %s (downloading for archive %s)",
+                        attempt,
+                        file_name,
+                        archive_id,
+                    )
 
 
-                if timelapse_data:
-                    success = await service.attach_timelapse(archive_id, timelapse_data, file_name)
-                    if success:
-                        logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
-                        await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
-                        return  # Success!
+                    timelapse_data = await download_file_bytes_async(
+                        printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
+                    )
+                    if timelapse_data:
+                        success = await service.attach_timelapse(archive_id, timelapse_data, file_name)
+                        if success:
+                            logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
+                            await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
+                            return
+                        else:
+                            logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
                     else:
                     else:
-                        logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
+                        logger.warning("[TIMELAPSE] Attempt %s: Failed to download new file, will retry", attempt)
                 else:
                 else:
-                    logger.warning("[TIMELAPSE] Attempt %s: Failed to download, will retry", attempt)
+                    logger.info("[TIMELAPSE] Attempt %s: No new files since baseline, will retry", attempt)
 
 
         except Exception as e:
         except Exception as e:
             logger.warning("[TIMELAPSE] Attempt %s failed with error: %s", attempt, e)
             logger.warning("[TIMELAPSE] Attempt %s failed with error: %s", attempt, e)
 
 
-    logger.warning("[TIMELAPSE] All %s attempts exhausted for archive %s, giving up", len(retry_delays), archive_id)
+    # --- Phase 3: Fallback — try name matching against all files ---
+    if base_name:
+        logger.info("[TIMELAPSE] Retries exhausted, trying name-match fallback for '%s'", base_name)
+        try:
+            async with async_session() as db:
+                from backend.app.models.printer import Printer
+                from backend.app.services.bambu_ftp import download_file_bytes_async
+
+                service = ArchiveService(db)
+                archive = await service.get_archive(archive_id)
+                if not archive or archive.timelapse_path:
+                    return
+
+                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+                printer = result.scalar_one_or_none()
+                if not printer:
+                    return
+
+                mp4_files, found_path = await _list_timelapse_mp4s(printer)
+                for f in mp4_files:
+                    fname = f.get("name", "")
+                    if base_name.lower() in fname.lower():
+                        remote_path = f.get("path") or f"/timelapse/{fname}"
+                        logger.info("[TIMELAPSE] Name-match fallback: '%s' matches '%s'", base_name, fname)
+
+                        timelapse_data = await download_file_bytes_async(
+                            printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
+                        )
+                        if timelapse_data:
+                            success = await service.attach_timelapse(archive_id, timelapse_data, fname)
+                            if success:
+                                logger.info(
+                                    "[TIMELAPSE] Name-match fallback attached timelapse to archive %s", archive_id
+                                )
+                                await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
+                                return
+                        break  # Only try the first name match
+
+        except Exception as e:
+            logger.warning("[TIMELAPSE] Name-match fallback failed: %s", e)
+
+    logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
 
 
 
 
 async def on_print_complete(printer_id: int, data: dict):
 async def on_print_complete(printer_id: int, data: dict):
@@ -2636,6 +2764,8 @@ PUBLIC_API_ROUTES = {
     "/api/v1/auth/status",
     "/api/v1/auth/status",
     "/api/v1/auth/login",
     "/api/v1/auth/login",
     "/api/v1/auth/setup",  # Needed for initial setup and recovery
     "/api/v1/auth/setup",  # Needed for initial setup and recovery
+    "/api/v1/auth/advanced-auth/status",  # Advanced auth status needed for login page
+    "/api/v1/auth/forgot-password",  # Password reset for advanced auth
     # Version check for updates (no sensitive data)
     # Version check for updates (no sensitive data)
     "/api/v1/updates/version",
     "/api/v1/updates/version",
     # Metrics endpoint handles its own prometheus_token authentication
     # Metrics endpoint handles its own prometheus_token authentication
@@ -2777,6 +2907,7 @@ app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
+app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)

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

@@ -6,9 +6,11 @@ from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
 from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.local_preset import LocalPreset
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
@@ -39,4 +41,6 @@ __all__ = [
     "user_groups",
     "user_groups",
     "GitHubBackupConfig",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "GitHubBackupLog",
+    "LocalPreset",
+    "OrcaBaseProfile",
 ]
 ]

+ 40 - 0
backend/app/models/local_preset.py

@@ -0,0 +1,40 @@
+"""Model for locally stored slicer presets (imported from OrcaSlicer, etc.)."""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class LocalPreset(Base):
+    """A locally stored slicer preset, typically imported from OrcaSlicer."""
+
+    __tablename__ = "local_presets"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(300))
+    preset_type: Mapped[str] = mapped_column(String(20))  # filament, printer, process
+    source: Mapped[str] = mapped_column(String(50), default="orcaslicer")  # orcaslicer, manual
+
+    # Core fields extracted for filtering / AMS config
+    filament_type: Mapped[str | None] = mapped_column(String(50))
+    filament_vendor: Mapped[str | None] = mapped_column(String(200))
+    nozzle_temp_min: Mapped[int | None] = mapped_column(Integer)
+    nozzle_temp_max: Mapped[int | None] = mapped_column(Integer)
+    pressure_advance: Mapped[str | None] = mapped_column(String(50))
+    default_filament_colour: Mapped[str | None] = mapped_column(String(50))
+    filament_cost: Mapped[str | None] = mapped_column(String(50))
+    filament_density: Mapped[str | None] = mapped_column(String(50))
+    compatible_printers: Mapped[str | None] = mapped_column(Text)  # JSON array
+
+    # Full resolved JSON blob
+    setting: Mapped[str] = mapped_column(Text)
+
+    # Inheritance info
+    inherits: Mapped[str | None] = mapped_column(String(300))
+    version: Mapped[str | None] = mapped_column(String(50))
+
+    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())

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

@@ -146,4 +146,16 @@ DEFAULT_TEMPLATES = [
         "title_template": "Queue Complete",
         "title_template": "Queue Complete",
         "body_template": "All {completed_count} queued jobs have finished",
         "body_template": "All {completed_count} queued jobs have finished",
     },
     },
+    {
+        "event_type": "user_created",
+        "name": "Welcome Email",
+        "title_template": "Welcome to {app_name}",
+        "body_template": "Welcome {username}!\n\nYour account has been created.\nUsername: {username}\nPassword: {password}\n\nLogin at: {login_url}",
+    },
+    {
+        "event_type": "password_reset",
+        "name": "Password Reset",
+        "title_template": "{app_name} - Password Reset",
+        "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
+    },
 ]
 ]

+ 22 - 0
backend/app/models/orca_base_cache.py

@@ -0,0 +1,22 @@
+"""Cache model for OrcaSlicer base profiles fetched from GitHub."""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Index, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class OrcaBaseProfile(Base):
+    """Cached OrcaSlicer base profile from GitHub for inheritance resolution."""
+
+    __tablename__ = "orca_base_profiles"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(300))
+    profile_type: Mapped[str] = mapped_column(String(20))  # filament, machine, process
+    setting: Mapped[str] = mapped_column(Text)  # Full JSON
+    fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    __table_args__ = (Index("ix_orca_base_profiles_name", "name", unique=True),)

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

@@ -12,7 +12,7 @@ class Printer(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))
     name: Mapped[str] = mapped_column(String(100))
     serial_number: Mapped[str] = mapped_column(String(50), unique=True)
     serial_number: Mapped[str] = mapped_column(String(50), unique=True)
-    ip_address: Mapped[str] = mapped_column(String(45))
+    ip_address: Mapped[str] = mapped_column(String(253))
     access_code: Mapped[str] = mapped_column(String(20))
     access_code: Mapped[str] = mapped_column(String(20))
     model: Mapped[str | None] = mapped_column(String(50))
     model: Mapped[str | None] = mapped_column(String(50))
     location: Mapped[str | None] = mapped_column(String(100))  # Group/location name
     location: Mapped[str | None] = mapped_column(String(100))  # Group/location name

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

@@ -24,6 +24,7 @@ class SlotPresetMapping(Base):
     tray_id: Mapped[int] = mapped_column(Integer)  # Tray ID within AMS (0-3)
     tray_id: Mapped[int] = mapped_column(Integer)  # Tray ID within AMS (0-3)
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
+    preset_source: Mapped[str] = mapped_column(String(20), default="cloud")  # cloud or local
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 

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

@@ -24,6 +24,7 @@ class User(Base):
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
+    email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
     password_hash: Mapped[str] = mapped_column(String(255))
     password_hash: Mapped[str] = mapped_column(String(255))
     role: Mapped[str] = mapped_column(
     role: Mapped[str] = mapped_column(
         String(20), default="user"
         String(20), default="user"

+ 51 - 1
backend/app/schemas/auth.py

@@ -24,7 +24,8 @@ class LoginResponse(BaseModel):
 
 
 class UserCreate(BaseModel):
 class UserCreate(BaseModel):
     username: str
     username: str
-    password: str
+    password: str | None = None  # Optional when advanced auth is enabled
+    email: str | None = None
     role: str = "user"
     role: str = "user"
     group_ids: list[int] | None = None
     group_ids: list[int] | None = None
 
 
@@ -32,6 +33,7 @@ class UserCreate(BaseModel):
 class UserUpdate(BaseModel):
 class UserUpdate(BaseModel):
     username: str | None = None
     username: str | None = None
     password: str | None = None
     password: str | None = None
+    email: str | None = None
     role: str | None = None
     role: str | None = None
     is_active: bool | None = None
     is_active: bool | None = None
     group_ids: list[int] | None = None
     group_ids: list[int] | None = None
@@ -40,6 +42,7 @@ class UserUpdate(BaseModel):
 class UserResponse(BaseModel):
 class UserResponse(BaseModel):
     id: int
     id: int
     username: str
     username: str
+    email: str | None = None
     role: str  # Deprecated, kept for backward compatibility
     role: str  # Deprecated, kept for backward compatibility
     is_active: bool
     is_active: bool
     is_admin: bool  # Computed from role and group membership
     is_admin: bool  # Computed from role and group membership
@@ -65,3 +68,50 @@ class SetupRequest(BaseModel):
 class SetupResponse(BaseModel):
 class SetupResponse(BaseModel):
     auth_enabled: bool
     auth_enabled: bool
     admin_created: bool | None = None
     admin_created: bool | None = None
+
+
+class ForgotPasswordRequest(BaseModel):
+    email: str
+
+
+class ForgotPasswordResponse(BaseModel):
+    message: str
+
+
+class ResetPasswordRequest(BaseModel):
+    user_id: int
+
+
+class ResetPasswordResponse(BaseModel):
+    message: str
+
+
+class SMTPSettings(BaseModel):
+    smtp_host: str
+    smtp_port: int
+    smtp_username: str | None = None  # Optional when auth is disabled
+    smtp_password: str | None = None  # Optional for read operations or when auth is disabled
+    smtp_security: str = "starttls"  # 'starttls', 'ssl', 'none'
+    smtp_auth_enabled: bool = True
+    smtp_from_email: str
+    smtp_from_name: str = "BamBuddy"
+    # Deprecated field for backward compatibility
+    smtp_use_tls: bool | None = None
+
+
+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):
+    success: bool
+    message: str

+ 67 - 0
backend/app/schemas/local_preset.py

@@ -0,0 +1,67 @@
+"""Pydantic schemas for local preset API."""
+
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class LocalPresetResponse(BaseModel):
+    """Local preset summary (without full setting blob)."""
+
+    id: int
+    name: str
+    preset_type: str
+    source: str
+    filament_type: str | None = None
+    filament_vendor: str | None = None
+    nozzle_temp_min: int | None = None
+    nozzle_temp_max: int | None = None
+    pressure_advance: str | None = None
+    default_filament_colour: str | None = None
+    filament_cost: str | None = None
+    filament_density: str | None = None
+    compatible_printers: str | None = None
+    inherits: str | None = None
+    version: str | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    model_config = {"from_attributes": True}
+
+
+class LocalPresetDetail(LocalPresetResponse):
+    """Full preset detail including the resolved setting JSON."""
+
+    setting: dict
+
+
+class LocalPresetCreate(BaseModel):
+    """Schema for manually creating a local preset."""
+
+    name: str
+    preset_type: str  # filament, printer, process
+    setting: dict
+
+
+class LocalPresetUpdate(BaseModel):
+    """Schema for updating a local preset."""
+
+    name: str | None = None
+    setting: dict | None = None
+
+
+class LocalPresetsResponse(BaseModel):
+    """Grouped local presets by type."""
+
+    filament: list[LocalPresetResponse] = []
+    printer: list[LocalPresetResponse] = []
+    process: list[LocalPresetResponse] = []
+
+
+class ImportResponse(BaseModel):
+    """Result of an import operation."""
+
+    success: bool
+    imported: int
+    skipped: int
+    errors: list[str] = []

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

@@ -53,6 +53,9 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
     "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
     "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
     "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
     "queue_completed": ["completed_count", "timestamp", "app_name"],
     "queue_completed": ["completed_count", "timestamp", "app_name"],
+    # User management notifications
+    "user_created": ["username", "password", "login_url", "app_name", "timestamp"],
+    "password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
 }
 }
 
 
 # Sample data for previewing templates
 # Sample data for previewing templates
@@ -191,6 +194,21 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 18:30",
         "timestamp": "2024-01-15 18:30",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    # User management notifications
+    "user_created": {
+        "username": "john_doe",
+        "password": "TempPass123!",
+        "login_url": "https://bambuddy.example.com/login",
+        "app_name": "Bambuddy",
+        "timestamp": "2024-01-15 14:30",
+    },
+    "password_reset": {
+        "username": "john_doe",
+        "password": "NewPass456!",
+        "login_url": "https://bambuddy.example.com/login",
+        "app_name": "Bambuddy",
+        "timestamp": "2024-01-15 14:30",
+    },
 }
 }
 
 
 
 

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

@@ -6,7 +6,11 @@ from pydantic import BaseModel, Field
 class PrinterBase(BaseModel):
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
     serial_number: str = Field(..., min_length=1, max_length=50)
-    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    ip_address: str = Field(
+        ...,
+        max_length=253,
+        pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
+    )
     access_code: str = Field(..., min_length=1, max_length=20)
     access_code: str = Field(..., min_length=1, max_length=20)
     model: str | None = None
     model: str | None = None
     location: str | None = None  # Group/location name
     location: str | None = None  # Group/location name
@@ -31,7 +35,11 @@ class PlateDetectionROI(BaseModel):
 
 
 class PrinterUpdate(BaseModel):
 class PrinterUpdate(BaseModel):
     name: str | None = None
     name: str | None = None
-    ip_address: str | None = None
+    ip_address: str | None = Field(
+        default=None,
+        max_length=253,
+        pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
+    )
     access_code: str | None = None
     access_code: str | None = None
     model: str | None = None
     model: str | None = None
     location: str | None = None
     location: str | None = None
@@ -137,6 +145,21 @@ class NozzleInfoResponse(BaseModel):
     nozzle_diameter: str = ""  # e.g., "0.4"
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
 
 
+class NozzleRackSlot(BaseModel):
+    """H2C nozzle rack slot (6-position tool-changer dock)."""
+
+    id: int = 0
+    nozzle_type: str = ""
+    nozzle_diameter: str = ""
+    wear: int | None = None
+    stat: int | None = None  # Nozzle status (e.g. mounted/docked)
+    max_temp: int = 0  # Max temperature rating °C (0 = not set)
+    serial_number: str = ""  # Nozzle serial number
+    filament_color: str = ""  # RGBA hex ("00000000" = no filament)
+    filament_id: str = ""  # Bambu filament ID
+    filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
+
+
 class PrintOptionsResponse(BaseModel):
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
     """AI detection and print options from xcam data."""
 
 
@@ -183,6 +206,7 @@ class PrinterStatus(BaseModel):
     ipcam: bool = False  # Live view enabled
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     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
     print_options: PrintOptionsResponse | None = None  # AI detection and print options
     # Calibration stage tracking
     # Calibration stage tracking
     stg_cur: int = -1  # Current stage number (-1 = not calibrating)
     stg_cur: int = -1  # Current stage number (-1 = not calibrating)

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

@@ -106,6 +106,13 @@ class AppSettings(BaseModel):
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
     ha_token: str = Field(default="", description="Home Assistant Long-Lived Access Token")
     ha_token: str = Field(default="", description="Home Assistant Long-Lived Access Token")
+    ha_url_from_env: bool = Field(default=False, description="Whether HA URL is set via HA_URL environment variable")
+    ha_token_from_env: bool = Field(
+        default=False, description="Whether HA token is set via HA_TOKEN environment variable"
+    )
+    ha_env_managed: bool = Field(
+        default=False, description="Whether HA integration is fully managed by environment variables"
+    )
 
 
     # File Manager / Library settings
     # File Manager / Library settings
     library_archive_mode: str = Field(
     library_archive_mode: str = Field(
@@ -123,6 +130,12 @@ class AppSettings(BaseModel):
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
     )
     )
 
 
+    # Preferred slicer application
+    preferred_slicer: str = Field(
+        default="bambu_studio",
+        description="Preferred slicer: 'bambu_studio' or 'orcaslicer'",
+    )
+
     # Prometheus metrics endpoint
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_token: str = Field(
     prometheus_token: str = Field(
@@ -184,5 +197,6 @@ class AppSettingsUpdate(BaseModel):
     library_archive_mode: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     camera_view_mode: str | None = None
+    preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     prometheus_token: str | None = None

+ 50 - 5
backend/app/services/bambu_mqtt.py

@@ -147,6 +147,8 @@ class PrinterState:
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # 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
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     h2d_extruder_snow: dict = field(default_factory=dict)
     h2d_extruder_snow: dict = field(default_factory=dict)
+    # H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)
+    nozzle_rack: list = field(default_factory=list)
     # Timestamp of last AMS data update (for RFID refresh detection)
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
     last_ams_update: float = 0.0
     # Printable objects for skip object functionality: {identify_id: object_name}
     # Printable objects for skip object functionality: {identify_id: object_name}
@@ -1740,12 +1742,47 @@ class BambuMQTTClient:
         if "nozzle_diameter_2" in data:
         if "nozzle_diameter_2" in data:
             self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
             self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
 
 
-        # H2D series: Nozzle hardware info is in device.nozzle.info array
+        # H2D/H2C series: Nozzle hardware info is in device.nozzle.info array
         if "device" in data and isinstance(data["device"], dict):
         if "device" in data and isinstance(data["device"], dict):
             device = data["device"]
             device = data["device"]
             nozzle_data = device.get("nozzle", {})
             nozzle_data = device.get("nozzle", {})
             nozzle_info = nozzle_data.get("info", [])
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
             if isinstance(nozzle_info, list):
+                # H2 series: nozzle_info contains extended nozzle data (wear, serial,
+                # max_temp, etc.) for all nozzles: L/R hotend (IDs 0,1) and rack slots
+                # (IDs 16-21 on H2C). Store ALL entries so the frontend can use them
+                # for hover cards on both the L/R indicator and the nozzle rack card.
+                if nozzle_info:
+                    self.state.nozzle_rack = sorted(
+                        [
+                            {
+                                "id": n.get("id", i),
+                                "type": str(n.get("type", "")),
+                                "diameter": str(n.get("diameter", "")),
+                                "wear": n.get("wear"),
+                                "stat": n.get("stat"),
+                                # H2C uses "tm", H2D uses "max_temp"
+                                "max_temp": n.get("max_temp") or n.get("tm", 0),
+                                # H2C uses "sn", H2D uses "serial_number"
+                                "serial_number": str(n.get("serial_number") or n.get("sn", "")),
+                                # H2C uses "color_m", H2D uses "filament_colour"
+                                "filament_color": str(n.get("filament_colour") or n.get("color_m", "")),
+                                # H2C uses "fila_id", H2D uses "filament_id"
+                                "filament_id": str(n.get("filament_id") or n.get("fila_id", "")),
+                                "filament_type": str(n.get("tray_type", "") or n.get("filament_type", "")),
+                            }
+                            for i, n in enumerate(nozzle_info)
+                        ],
+                        key=lambda x: x["id"],
+                    )
+                    if not hasattr(self, "_nozzle_rack_logged") and nozzle_info:
+                        self._nozzle_rack_logged = True
+                        logger.info(
+                            "[%s] Nozzle info: %d entries, IDs: %s",
+                            self.serial_number,
+                            len(nozzle_info),
+                            [n.get("id") for n in nozzle_info],
+                        )
                 for nozzle in nozzle_info:
                 for nozzle in nozzle_info:
                     idx = nozzle.get("id", 0)
                     idx = nozzle.get("id", 0)
                     if idx < len(self.state.nozzles):
                     if idx < len(self.state.nozzles):
@@ -2043,14 +2080,19 @@ class BambuMQTTClient:
                         # For ams_mapping2, slot_id is 0 (main) or 1 (deputy), not the tray_id
                         # For ams_mapping2, slot_id is 0 (main) or 1 (deputy), not the tray_id
                         external_slot = 0 if tray_id == 254 else 1
                         external_slot = 0 if tray_id == 254 else 1
                         ams_mapping2.append({"ams_id": 255, "slot_id": external_slot})
                         ams_mapping2.append({"ams_id": 255, "slot_id": external_slot})
+                    elif tray_id >= 128:
+                        # AMS-HT: global tray ID IS the ams_id (single tray per unit)
+                        ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                     else:
                     else:
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         ams_id = tray_id // 4
                         ams_id = tray_id // 4
                         slot_id = tray_id % 4
                         slot_id = tray_id % 4
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
 
-            # H2D series requires integer values (0/1) for boolean fields
-            # Other printers (X1C, P1S, A1, etc.) require actual booleans
+            # H2D series requires integer values (0/1) for calibration/leveling fields
+            # but use_ams MUST remain boolean — H2D Pro firmware interprets integer
+            # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
+            # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
             is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S")
             is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S")
 
 
             command = {
             command = {
@@ -2068,7 +2110,7 @@ class BambuMQTTClient:
                     "flow_cali": (1 if flow_cali else 0) if is_h2d else flow_cali,
                     "flow_cali": (1 if flow_cali else 0) if is_h2d else flow_cali,
                     "vibration_cali": (1 if vibration_cali else 0) if is_h2d else vibration_cali,
                     "vibration_cali": (1 if vibration_cali else 0) if is_h2d else vibration_cali,
                     "layer_inspect": (1 if layer_inspect else 0) if is_h2d else layer_inspect,
                     "layer_inspect": (1 if layer_inspect else 0) if is_h2d else layer_inspect,
-                    "use_ams": (1 if use_ams else 0) if is_h2d else use_ams,
+                    "use_ams": use_ams,
                     "cfg": "0",
                     "cfg": "0",
                     "extrude_cali_flag": 0,
                     "extrude_cali_flag": 0,
                     "extrude_cali_manual_mode": 0,
                     "extrude_cali_manual_mode": 0,
@@ -2082,7 +2124,10 @@ class BambuMQTTClient:
             }
             }
 
 
             if is_h2d:
             if is_h2d:
-                logger.info("[%s] H2D series detected: using integer format for boolean fields", self.serial_number)
+                logger.info(
+                    "[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)",
+                    self.serial_number,
+                )
 
 
             # P2S-specific parameter adjustments
             # P2S-specific parameter adjustments
             # P2S printer doesn't support vibration calibration like X1/P1 series
             # P2S printer doesn't support vibration calibration like X1/P1 series

+ 516 - 0
backend/app/services/email_service.py

@@ -0,0 +1,516 @@
+"""Email service for sending authentication-related emails."""
+
+from __future__ import annotations
+
+import html
+import logging
+import re
+import secrets
+import smtplib
+import string
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.settings import Settings
+from backend.app.schemas.auth import SMTPSettings
+
+logger = logging.getLogger(__name__)
+
+
+def generate_secure_password(length: int = 16) -> str:
+    """Generate a secure random password.
+
+    Args:
+        length: Length of the password (default: 16)
+
+    Returns:
+        A secure random password containing uppercase, lowercase, digits, and special characters
+    """
+    import random
+
+    # Define character sets
+    lowercase = string.ascii_lowercase
+    uppercase = string.ascii_uppercase
+    digits = string.digits
+    special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
+
+    # Ensure at least one character from each set
+    password_chars = [
+        secrets.choice(lowercase),
+        secrets.choice(uppercase),
+        secrets.choice(digits),
+        secrets.choice(special),
+    ]
+
+    # Fill the rest with random characters from all sets
+    all_chars = lowercase + uppercase + digits + special
+    password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
+
+    # Shuffle to avoid predictable patterns
+    random.shuffle(password_chars)
+
+    return "".join(password_chars)
+
+
+async def get_notification_template(db: AsyncSession, event_type: str) -> NotificationTemplate | None:
+    """Get a notification template by event type from database.
+
+    Args:
+        db: Database session
+        event_type: Type of event (e.g., 'user_created', 'password_reset')
+
+    Returns:
+        NotificationTemplate object or None if not found
+    """
+    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
+    return result.scalar_one_or_none()
+
+
+def render_template(template_str: str, variables: dict[str, Any]) -> str:
+    """Render a template string with variables.
+
+    Args:
+        template_str: Template string with {variable} placeholders
+        variables: Dictionary of variables to substitute
+
+    Returns:
+        Rendered template string
+    """
+    result = template_str
+    for key, value in variables.items():
+        result = result.replace("{" + key + "}", str(value) if value is not None else "")
+    # Remove any remaining unreplaced placeholders (case-insensitive, alphanumeric + underscore)
+    result = re.sub(r"\{[a-zA-Z0-9_]+\}", "", result)
+    return result
+
+
+async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
+    """Get SMTP settings from database.
+
+    Args:
+        db: Database session
+
+    Returns:
+        SMTPSettings object or None if not configured
+    """
+    # Fetch all SMTP-related settings
+    result = await db.execute(
+        select(Settings).where(
+            Settings.key.in_(
+                [
+                    "smtp_host",
+                    "smtp_port",
+                    "smtp_username",
+                    "smtp_password",
+                    "smtp_use_tls",
+                    "smtp_security",
+                    "smtp_auth_enabled",
+                    "smtp_from_email",
+                    "smtp_from_name",
+                ]
+            )
+        )
+    )
+    settings_dict = {s.key: s.value for s in result.scalars().all()}
+
+    # Check if minimum required settings are present
+    required_keys = ["smtp_host", "smtp_port", "smtp_from_email"]
+    if not all(key in settings_dict for key in required_keys):
+        return None
+
+    # Handle migration: convert old smtp_use_tls to smtp_security if needed
+    smtp_security = settings_dict.get("smtp_security")
+    if not smtp_security:
+        # Migrate from old smtp_use_tls format
+        smtp_use_tls = settings_dict.get("smtp_use_tls", "true").lower() == "true"
+        smtp_security = "starttls" if smtp_use_tls else "ssl"
+
+    smtp_auth_enabled = settings_dict.get("smtp_auth_enabled", "true").lower() == "true"
+
+    return SMTPSettings(
+        smtp_host=settings_dict["smtp_host"],
+        smtp_port=int(settings_dict["smtp_port"]),
+        smtp_username=settings_dict.get("smtp_username"),
+        smtp_password=settings_dict.get("smtp_password"),
+        smtp_security=smtp_security,
+        smtp_auth_enabled=smtp_auth_enabled,
+        smtp_from_email=settings_dict["smtp_from_email"],
+        smtp_from_name=settings_dict.get("smtp_from_name", "BamBuddy"),
+    )
+
+
+async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:
+    """Save SMTP settings to database.
+
+    Args:
+        db: Database session
+        smtp_settings: SMTP settings to save
+    """
+    from sqlalchemy import func
+    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+    settings_data = {
+        "smtp_host": smtp_settings.smtp_host,
+        "smtp_port": str(smtp_settings.smtp_port),
+        "smtp_security": smtp_settings.smtp_security,
+        "smtp_auth_enabled": "true" if smtp_settings.smtp_auth_enabled else "false",
+        "smtp_from_email": smtp_settings.smtp_from_email,
+        "smtp_from_name": smtp_settings.smtp_from_name,
+    }
+
+    # Only save username if auth is enabled or if provided
+    if smtp_settings.smtp_username:
+        settings_data["smtp_username"] = smtp_settings.smtp_username
+
+    # Only save password if provided
+    if smtp_settings.smtp_password:
+        settings_data["smtp_password"] = smtp_settings.smtp_password
+
+    for key, value in settings_data.items():
+        stmt = sqlite_insert(Settings).values(key=key, value=value)
+        stmt = stmt.on_conflict_do_update(
+            index_elements=["key"],
+            set_={"value": value, "updated_at": func.now()},
+        )
+        await db.execute(stmt)
+
+
+def send_email(
+    smtp_settings: SMTPSettings,
+    to_email: str,
+    subject: str,
+    body_text: str,
+    body_html: str | None = None,
+) -> None:
+    """Send an email using SMTP.
+
+    Args:
+        smtp_settings: SMTP configuration
+        to_email: Recipient email address
+        subject: Email subject
+        body_text: Plain text body
+        body_html: Optional HTML body
+
+    Raises:
+        Exception: If email sending fails
+    """
+    msg = MIMEMultipart("alternative")
+    msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
+    msg["To"] = to_email
+    msg["Subject"] = subject
+
+    # Attach plain text part
+    msg.attach(MIMEText(body_text, "plain"))
+
+    # Attach HTML part if provided
+    if body_html:
+        msg.attach(MIMEText(body_html, "html"))
+
+    # Send email
+    try:
+        security = smtp_settings.smtp_security
+        auth_enabled = smtp_settings.smtp_auth_enabled
+
+        # Validate username is provided when authentication is enabled
+        if auth_enabled and smtp_settings.smtp_password:
+            if not smtp_settings.smtp_username:
+                raise ValueError("SMTP username is required when authentication is enabled")
+
+        if security == "ssl":
+            # Direct SSL connection (typically port 465)
+            with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
+                if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
+                    server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
+                server.send_message(msg)
+        elif security == "starttls":
+            # STARTTLS upgrade (typically port 587)
+            with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
+                server.starttls()
+                if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
+                    server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
+                server.send_message(msg)
+        else:
+            # No encryption (typically port 25) - use with caution
+            with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
+                if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
+                    server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
+                server.send_message(msg)
+        logger.info(f"Email sent successfully to {to_email}")
+    except Exception as e:
+        logger.error(f"Failed to send email to {to_email}: {e}")
+        raise
+
+
+def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
+    """Create welcome email content for new user.
+
+    Args:
+        username: Username of the new user
+        password: Auto-generated password
+        login_url: URL to login page
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
+    """
+    subject = "Welcome to BamBuddy - Your Account Details"
+
+    text_body = f"""Welcome to BamBuddy!
+
+Your account has been created. Here are your login details:
+
+Username: {username}
+Password: {password}
+
+You can login at: {login_url}
+
+For security reasons, please change your password after your first login.
+
+Best regards,
+BamBuddy Team
+"""
+
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">Welcome to BamBuddy!</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <p style="font-size: 16px;">Your account has been created. Here are your login details:</p>
+
+        <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
+            <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
+            <p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
+        </div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
+        </div>
+
+        <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
+            <strong>Security Note:</strong> For security reasons, please change your password after your first login.
+        </p>
+
+        <p style="font-size: 14px; color: #999; margin-top: 30px;">
+            Best regards,<br>
+            BamBuddy Team
+        </p>
+    </div>
+</body>
+</html>
+"""
+
+    return subject, text_body, html_body
+
+
+def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
+    """Create password reset email content.
+
+    Args:
+        username: Username of the user
+        password: New auto-generated password
+        login_url: URL to login page
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
+    """
+    subject = "BamBuddy - Your Password Has Been Reset"
+
+    text_body = f"""Your BamBuddy password has been reset.
+
+Your login details:
+
+Username: {username}
+New Password: {password}
+
+You can login at: {login_url}
+
+For security reasons, please change your password after logging in.
+
+If you did not request this password reset, please contact your administrator immediately.
+
+Best regards,
+BamBuddy Team
+"""
+
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">Password Reset</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
+
+        <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
+            <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
+            <p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
+        </div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
+        </div>
+
+        <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
+            <p style="margin: 0; font-size: 14px; color: #856404;">
+                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
+            </p>
+        </div>
+
+        <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
+            <strong>Security Note:</strong> For security reasons, please change your password after logging in.
+        </p>
+
+        <p style="font-size: 14px; color: #999; margin-top: 30px;">
+            Best regards,<br>
+            BamBuddy Team
+        </p>
+    </div>
+</body>
+</html>
+"""
+
+    return subject, text_body, html_body
+
+
+async def create_welcome_email_from_template(
+    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
+) -> tuple[str, str, str]:
+    """Create welcome email content using notification template from database.
+
+    Args:
+        db: Database session
+        username: Username of the new user
+        password: Auto-generated password
+        login_url: URL to login page
+        app_name: Application name (default: BamBuddy)
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
+    """
+    # Try to get template from database
+    template = await get_notification_template(db, "user_created")
+
+    if template:
+        # Render template with variables
+        variables = {
+            "app_name": app_name,
+            "username": username,
+            "password": password,
+            "login_url": login_url,
+        }
+
+        subject = render_template(template.title_template, variables)
+        text_body = render_template(template.body_template, variables)
+
+        # Create HTML version with embedded login button
+        # Escape text_body to prevent XSS vulnerabilities and convert newlines to <br> tags
+        escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
+        html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="font-size: 16px;">{escaped_text_body}</div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
+        </div>
+    </div>
+</body>
+</html>
+"""
+
+        logger.info("Using custom welcome email template from database")
+        return subject, text_body, html_body
+    else:
+        # Fallback to hardcoded template
+        logger.warning("No welcome email template found in database, using default")
+        return create_welcome_email(username, password, login_url)
+
+
+async def create_password_reset_email_from_template(
+    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
+) -> tuple[str, str, str]:
+    """Create password reset email content using notification template from database.
+
+    Args:
+        db: Database session
+        username: Username of the user
+        password: New auto-generated password
+        login_url: URL to login page
+        app_name: Application name (default: BamBuddy)
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
+    """
+    # Try to get template from database
+    template = await get_notification_template(db, "password_reset")
+
+    if template:
+        # Render template with variables
+        variables = {
+            "app_name": app_name,
+            "username": username,
+            "password": password,
+            "login_url": login_url,
+        }
+
+        subject = render_template(template.title_template, variables)
+        text_body = render_template(template.body_template, variables)
+
+        # Create HTML version with embedded login button
+        # Escape text_body to prevent XSS vulnerabilities and convert newlines to <br> tags
+        escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
+        html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="font-size: 16px;">{escaped_text_body}</div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
+        </div>
+
+        <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
+            <p style="margin: 0; font-size: 14px; color: #856404;">
+                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
+            </p>
+        </div>
+    </div>
+</body>
+</html>
+"""
+
+        logger.info("Using custom password reset email template from database")
+        return subject, text_body, html_body
+    else:
+        # Fallback to hardcoded template
+        logger.warning("No password reset email template found in database, using default")
+        return create_password_reset_email(username, password, login_url)

+ 2 - 1
backend/app/services/firmware_check.py

@@ -38,7 +38,7 @@ MODEL_TO_API_KEY = {
     "A1-Mini": "a1-mini",
     "A1-Mini": "a1-mini",
     "A1mini": "a1-mini",
     "A1mini": "a1-mini",
     "H2D": "h2d",
     "H2D": "h2d",
-    "H2C": "h2d",  # H2C uses same firmware as H2D
+    "H2C": "h2c",
     "H2S": "h2s",
     "H2S": "h2s",
     "P2S": "p2s",
     "P2S": "p2s",
     "X1E": "x1e",
     "X1E": "x1e",
@@ -54,6 +54,7 @@ API_KEY_TO_DEV_MODEL = {
     "a1": "N2S",
     "a1": "N2S",
     "a1-mini": "N1",
     "a1-mini": "N1",
     "h2d": "O1D",
     "h2d": "O1D",
+    "h2c": "O1C",
     "h2s": "O1S",
     "h2s": "O1S",
     "p2s": "N7",
     "p2s": "N7",
     "x1e": "C13",
     "x1e": "C13",

+ 511 - 0
backend/app/services/orca_profiles.py

@@ -0,0 +1,511 @@
+"""Service for importing and resolving OrcaSlicer profiles.
+
+Handles:
+- Parsing .json, .orca_filament, .zip exports
+- Fetching base Bambu profiles from OrcaSlicer GitHub for inheritance resolution
+- Caching base profiles in the database with TTL
+- Extracting core fields for quick access
+"""
+
+import io
+import json
+import logging
+import zipfile
+from datetime import datetime, timedelta, timezone
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.orca_base_cache import OrcaBaseProfile
+
+logger = logging.getLogger(__name__)
+
+ORCA_BASE_URL = "https://raw.githubusercontent.com/SoftFever/OrcaSlicer/main/resources/profiles/BBL"
+CACHE_TTL_DAYS = 7
+MAX_INHERITANCE_DEPTH = 10
+
+
+async def get_cached_base_profile(name: str, db: AsyncSession) -> dict | None:
+    """Get a base profile from cache if still fresh."""
+    result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))
+    profile = result.scalar_one_or_none()
+    if not profile:
+        return None
+
+    # Check TTL
+    cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)
+    fetched = profile.fetched_at
+    if fetched.tzinfo is None:
+        fetched = fetched.replace(tzinfo=timezone.utc)
+    if fetched < cutoff:
+        return None
+
+    try:
+        return json.loads(profile.setting)
+    except Exception:
+        return None
+
+
+async def fetch_and_cache_base_profile(name: str, profile_type: str, db: AsyncSession) -> dict | None:
+    """Fetch a base profile from OrcaSlicer GitHub and cache it."""
+    # Check cache first
+    cached = await get_cached_base_profile(name, db)
+    if cached is not None:
+        return cached
+
+    # Map profile_type to GitHub subdirectory
+    type_dirs = {
+        "filament": "filament",
+        "machine": "machine",
+        "printer": "machine",
+        "process": "process",
+    }
+    subdir = type_dirs.get(profile_type, "filament")
+
+    # Try fetching from GitHub
+    urls_to_try = [
+        f"{ORCA_BASE_URL}/{subdir}/{name}.json",
+    ]
+    # Also try filament dir as fallback for any type
+    if subdir != "filament":
+        urls_to_try.append(f"{ORCA_BASE_URL}/filament/{name}.json")
+
+    data = None
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        for url in urls_to_try:
+            try:
+                resp = await client.get(url)
+                if resp.status_code == 200:
+                    data = resp.json()
+                    break
+            except Exception as e:
+                logger.debug("Failed to fetch %s: %s", url, e)
+
+    if data is None:
+        logger.warning("Could not fetch base profile '%s' from GitHub", name)
+        return None
+
+    # Cache in DB
+    setting_json = json.dumps(data)
+    result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))
+    existing = result.scalar_one_or_none()
+    if existing:
+        existing.setting = setting_json
+        existing.profile_type = profile_type
+        existing.fetched_at = datetime.now(timezone.utc)
+    else:
+        cache_entry = OrcaBaseProfile(
+            name=name,
+            profile_type=profile_type,
+            setting=setting_json,
+            fetched_at=datetime.now(timezone.utc),
+        )
+        db.add(cache_entry)
+
+    return data
+
+
+async def resolve_preset(preset_data: dict, profile_type: str, db: AsyncSession, depth: int = 0) -> dict:
+    """Recursively resolve inheritance chain, merging parent into child.
+
+    OrcaSlicer uses shallow merge: child keys fully replace parent keys.
+    """
+    if depth >= MAX_INHERITANCE_DEPTH:
+        logger.warning("Inheritance depth limit reached for preset")
+        return preset_data
+
+    inherits = preset_data.get("inherits")
+    if not inherits:
+        return preset_data
+
+    # Fetch the base profile
+    base = await fetch_and_cache_base_profile(inherits, profile_type, db)
+    if base is None:
+        logger.warning("Cannot resolve inherits='%s' — base profile not found", inherits)
+        return preset_data
+
+    # Recursively resolve the base first
+    resolved_base = await resolve_preset(base, profile_type, db, depth + 1)
+
+    # Shallow merge: start with base, override with child
+    merged = {**resolved_base, **preset_data}
+    return merged
+
+
+def extract_core_fields(data: dict) -> dict:
+    """Extract commonly needed fields from a resolved preset for quick access."""
+    fields: dict = {}
+
+    # filament_type — often a single-element array like ["PLA"]
+    ft = data.get("filament_type")
+    if isinstance(ft, list) and ft:
+        fields["filament_type"] = str(ft[0])
+    elif isinstance(ft, str):
+        fields["filament_type"] = ft
+
+    # filament_vendor
+    fv = data.get("filament_vendor")
+    if isinstance(fv, list) and fv:
+        fields["filament_vendor"] = str(fv[0])
+    elif isinstance(fv, str):
+        fields["filament_vendor"] = fv
+
+    # nozzle_temp_min / max — from nozzle_temperature array or range fields
+    nozzle_temp = data.get("nozzle_temperature")
+    if isinstance(nozzle_temp, list) and nozzle_temp:
+        try:
+            temps = [int(t) for t in nozzle_temp if str(t).isdigit()]
+            if temps:
+                fields["nozzle_temp_min"] = min(temps)
+                fields["nozzle_temp_max"] = max(temps)
+        except (ValueError, TypeError):
+            pass
+
+    # Override with explicit range fields if present
+    range_low = data.get("nozzle_temperature_range_low")
+    range_high = data.get("nozzle_temperature_range_high")
+    if isinstance(range_low, list) and range_low:
+        try:
+            fields["nozzle_temp_min"] = int(range_low[0])
+        except (ValueError, TypeError):
+            pass
+    if isinstance(range_high, list) and range_high:
+        try:
+            fields["nozzle_temp_max"] = int(range_high[0])
+        except (ValueError, TypeError):
+            pass
+
+    # pressure_advance — store as JSON string if it's an array
+    pa = data.get("pressure_advance")
+    if pa is not None:
+        fields["pressure_advance"] = json.dumps(pa) if isinstance(pa, list) else str(pa)
+
+    # default_filament_colour
+    colour = data.get("default_filament_colour")
+    if colour is not None:
+        fields["default_filament_colour"] = json.dumps(colour) if isinstance(colour, list) else str(colour)
+
+    # filament_cost
+    cost = data.get("filament_cost")
+    if isinstance(cost, list) and cost:
+        fields["filament_cost"] = str(cost[0])
+    elif cost is not None:
+        fields["filament_cost"] = str(cost)
+
+    # filament_density
+    density = data.get("filament_density")
+    if isinstance(density, list) and density:
+        fields["filament_density"] = str(density[0])
+    elif density is not None:
+        fields["filament_density"] = str(density)
+
+    # compatible_printers
+    compat = data.get("compatible_printers")
+    if isinstance(compat, list):
+        fields["compatible_printers"] = json.dumps(compat)
+
+    return fields
+
+
+MATERIAL_TYPES = [
+    "PLA",
+    "ABS",
+    "ASA",
+    "PETG",
+    "TPU",
+    "PA",
+    "PC",
+    "PVA",
+    "HIPS",
+    "PET",
+    "PP",
+    "PEI",
+    "PEEK",
+    "PCTG",
+    "PPA",
+    "POM",
+]
+
+
+def _parse_material_from_name(name: str) -> str | None:
+    """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'."""
+    import re
+
+    upper = name.upper()
+    for mat in MATERIAL_TYPES:
+        if re.search(rf"\b{mat}\b", upper):
+            return mat
+    return None
+
+
+def _parse_vendor_from_name(name: str) -> str | None:
+    """Extract vendor from preset name, e.g. 'Overture PLA Matte @BBL X1C' -> 'Overture'."""
+    import re
+
+    # Strip @printer suffix
+    clean = re.sub(r"@.+$", "", name).strip()
+    upper = clean.upper()
+    for mat in MATERIAL_TYPES:
+        idx = upper.find(mat)
+        if idx > 0:
+            vendor = clean[:idx].strip()
+            if vendor and len(vendor) > 1:
+                return vendor
+    return None
+
+
+def _type_from_path(zip_entry: str) -> str | None:
+    """Infer profile type from the ZIP directory path."""
+    parts = zip_entry.lower().replace("\\", "/").split("/")
+    for part in parts:
+        if part in ("filament",):
+            return "filament"
+        if part in ("machine", "printer"):
+            return "printer"
+        if part in ("process", "print"):
+            return "process"
+    return None
+
+
+def _guess_profile_type(data: dict, path_hint: str | None = None) -> str:
+    """Determine the profile type from JSON data and optional ZIP path hint."""
+    import re
+
+    # 1. Explicit "type" field set by OrcaSlicer
+    explicit = data.get("type", "").lower()
+    if explicit in ("filament",):
+        return "filament"
+    if explicit in ("machine", "printer"):
+        return "printer"
+    if explicit in ("process", "print"):
+        return "process"
+
+    # 2. ZIP directory path hint (e.g. "filament/MyPreset.json")
+    if path_hint:
+        from_path = _type_from_path(path_hint)
+        if from_path:
+            return from_path
+
+    # 3. Strong ID-based heuristics — *_settings_id is definitive
+    if "print_settings_id" in data:
+        return "process"
+    if "filament_settings_id" in data:
+        return "filament"
+    if "printer_settings_id" in data:
+        return "printer"
+
+    # 4. Content-based heuristics — check process BEFORE filament because
+    #    resolved process presets can inherit filament_type from their base
+    process_keys = {
+        "layer_height",
+        "first_layer_height",
+        "wall_loops",
+        "prime_tower_width",
+        "prime_tower_max_speed",
+        "prime_tower_rib_wall",
+        "outer_wall_speed",
+        "inner_wall_speed",
+        "interlocking_depth",
+        "bottom_shell_layers",
+        "top_shell_layers",
+        "sparse_infill_density",
+    }
+    if process_keys & data.keys():
+        return "process"
+    if "machine_max_speed_x" in data or "printer_model" in data or "bed_shape" in data:
+        return "printer"
+    if "filament_type" in data or "filament_vendor" in data:
+        return "filament"
+
+    # 5. Name-based heuristics as last resort
+    name = data.get("name", "")
+    if re.search(r"\d+\.\d+mm\s", name):
+        return "process"
+    if name.lower().endswith("process"):
+        return "process"
+
+    return "filament"
+
+
+async def import_orca_file(filename: str, content: bytes, db: AsyncSession) -> dict:
+    """Import presets from a file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip).
+
+    Returns dict with keys: success, imported, skipped, errors.
+    """
+    imported = 0
+    skipped = 0
+    errors: list[str] = []
+
+    # Determine file type
+    lower_name = filename.lower()
+
+    if lower_name.endswith(".json"):
+        # Single JSON preset
+        try:
+            data = json.loads(content)
+            result = await _import_single_preset(data, db, path_hint=filename)
+            if result == "imported":
+                imported += 1
+            elif result == "skipped":
+                skipped += 1
+            else:
+                errors.append(result)
+        except json.JSONDecodeError as e:
+            errors.append(f"Invalid JSON: {e}")
+    elif lower_name.endswith((".orca_filament", ".zip", ".bbscfg", ".bbsflmt")):
+        # ZIP archive — extract and parse each JSON
+        try:
+            with zipfile.ZipFile(io.BytesIO(content)) as zf:
+                for entry in zf.namelist():
+                    if entry.endswith(".json") and "bundle_structure" not in entry:
+                        try:
+                            raw = zf.read(entry)
+                            data = json.loads(raw)
+                            result = await _import_single_preset(data, db, path_hint=entry)
+                            if result == "imported":
+                                imported += 1
+                            elif result == "skipped":
+                                skipped += 1
+                            else:
+                                errors.append(f"{entry}: {result}")
+                        except json.JSONDecodeError:
+                            errors.append(f"{entry}: Invalid JSON")
+                        except Exception as e:
+                            errors.append(f"{entry}: {e}")
+        except zipfile.BadZipFile:
+            errors.append("Invalid ZIP/orca_filament archive")
+    else:
+        errors.append(f"Unsupported file type: {filename}")
+
+    return {
+        "success": imported > 0 or (imported == 0 and skipped > 0 and not errors),
+        "imported": imported,
+        "skipped": skipped,
+        "errors": errors,
+    }
+
+
+async def _import_single_preset(data: dict, db: AsyncSession, path_hint: str | None = None) -> str:
+    """Import a single preset dict. Returns 'imported', 'skipped', or error string."""
+    name = data.get("name")
+    if not name:
+        return "Preset has no name"
+
+    # Check for duplicate by name
+    result = await db.execute(select(LocalPreset).where(LocalPreset.name == name))
+    if result.scalar_one_or_none():
+        return "skipped"
+
+    profile_type = _guess_profile_type(data, path_hint)
+    inherits_value = data.get("inherits")
+
+    # Resolve inheritance
+    try:
+        resolved = await resolve_preset(data, profile_type, db)
+    except Exception as e:
+        logger.warning("Failed to resolve inheritance for '%s': %s", name, e)
+        resolved = data
+
+    # Extract core fields
+    core = extract_core_fields(resolved)
+
+    # Fallback: parse material/vendor from preset name if not found in data
+    filament_type = core.get("filament_type") or _parse_material_from_name(name)
+    filament_vendor = core.get("filament_vendor") or _parse_vendor_from_name(name)
+
+    preset = LocalPreset(
+        name=name,
+        preset_type=profile_type,
+        source="orcaslicer",
+        filament_type=filament_type,
+        filament_vendor=filament_vendor,
+        nozzle_temp_min=core.get("nozzle_temp_min"),
+        nozzle_temp_max=core.get("nozzle_temp_max"),
+        pressure_advance=core.get("pressure_advance"),
+        default_filament_colour=core.get("default_filament_colour"),
+        filament_cost=core.get("filament_cost"),
+        filament_density=core.get("filament_density"),
+        compatible_printers=core.get("compatible_printers"),
+        setting=json.dumps(resolved),
+        inherits=inherits_value,
+        version=data.get("version"),
+    )
+    db.add(preset)
+    return "imported"
+
+
+async def refresh_base_cache(db: AsyncSession) -> dict:
+    """Force refresh all cached base profiles."""
+    result = await db.execute(select(OrcaBaseProfile))
+    profiles = result.scalars().all()
+
+    refreshed = 0
+    failed = 0
+
+    for profile in profiles:
+        # Clear fetched_at to force re-fetch
+        try:
+            profile.fetched_at = datetime.min
+            data = await fetch_and_cache_base_profile(profile.name, profile.profile_type, db)
+            if data:
+                refreshed += 1
+            else:
+                failed += 1
+        except Exception:
+            failed += 1
+
+    return {"refreshed": refreshed, "failed": failed, "total": len(profiles)}
+
+
+async def get_cache_status(db: AsyncSession) -> dict:
+    """Get the status of the base profile cache."""
+    result = await db.execute(select(OrcaBaseProfile))
+    profiles = result.scalars().all()
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)
+    fresh = 0
+    stale = 0
+
+    for p in profiles:
+        fetched = p.fetched_at
+        if fetched.tzinfo is None:
+            fetched = fetched.replace(tzinfo=timezone.utc)
+        if fetched >= cutoff:
+            fresh += 1
+        else:
+            stale += 1
+
+    return {
+        "total": len(profiles),
+        "fresh": fresh,
+        "stale": stale,
+        "ttl_days": CACHE_TTL_DAYS,
+    }
+
+
+async def reclassify_presets(db: AsyncSession) -> dict:
+    """Re-evaluate preset_type for all local presets using the improved heuristic."""
+    result = await db.execute(select(LocalPreset))
+    presets = result.scalars().all()
+
+    reclassified = 0
+    for preset in presets:
+        try:
+            data = json.loads(preset.setting)
+        except Exception:
+            continue
+
+        new_type = _guess_profile_type(data)
+        if new_type != preset.preset_type:
+            logger.info(
+                "Reclassifying '%s' from '%s' to '%s'",
+                preset.name,
+                preset.preset_type,
+                new_type,
+            )
+            preset.preset_type = new_type
+            reclassified += 1
+
+    return {"total": len(presets), "reclassified": reclassified}

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

@@ -511,7 +511,8 @@ class PrintScheduler:
                     # Normalize color: remove alpha, add hash
                     # Normalize color: remove alpha, add hash
                     color = self._normalize_color(tray_color)
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
                     # Calculate global tray ID
-                    global_tray_id = ams_id * 4 + tray_id
+                    # 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
 
 
                     filaments.append(
                     filaments.append(
                         {
                         {

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

@@ -647,6 +647,22 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "chamber_light": state.chamber_light,
         "chamber_light": state.chamber_light,
         # Active extruder for dual-nozzle printers (0=right, 1=left)
         # Active extruder for dual-nozzle printers (0=right, 1=left)
         "active_extruder": state.active_extruder,
         "active_extruder": state.active_extruder,
+        # H2C nozzle rack (tool-changer dock positions)
+        # Map raw MQTT field names (type/diameter) to schema names (nozzle_type/nozzle_diameter)
+        "nozzle_rack": [
+            {
+                "id": n.get("id", 0),
+                "nozzle_type": n.get("type", ""),
+                "nozzle_diameter": n.get("diameter", ""),
+                "wear": n.get("wear"),
+                "stat": n.get("stat"),
+                "max_temp": n.get("max_temp", 0),
+                "serial_number": n.get("serial_number", ""),
+                "filament_color": n.get("filament_color", ""),
+                "filament_id": n.get("filament_id", ""),
+            }
+            for n in (state.nozzle_rack or [])
+        ],
     }
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE/PAUSED states so skip objects modal can show cover
     # Include PAUSE/PAUSED states so skip objects modal can show cover

+ 5 - 13
backend/app/services/smart_plug_manager.py

@@ -40,28 +40,20 @@ class SmartPlugManager:
 
 
     async def _configure_ha_service(self, db: AsyncSession | None = None):
     async def _configure_ha_service(self, db: AsyncSession | None = None):
         """Configure the HA service with URL and token from settings."""
         """Configure the HA service with URL and token from settings."""
-        from backend.app.models.settings import Settings
+        from backend.app.api.routes.settings import get_homeassistant_settings
 
 
         try:
         try:
             if db:
             if db:
                 # Use provided session
                 # Use provided session
-                result = await db.execute(select(Settings).where(Settings.key == "ha_url"))
-                ha_url_setting = result.scalar_one_or_none()
-                result = await db.execute(select(Settings).where(Settings.key == "ha_token"))
-                ha_token_setting = result.scalar_one_or_none()
+                ha_settings = await get_homeassistant_settings(db)
             else:
             else:
                 # Create new session
                 # Create new session
                 from backend.app.core.database import async_session
                 from backend.app.core.database import async_session
 
 
                 async with async_session() as session:
                 async with async_session() as session:
-                    result = await session.execute(select(Settings).where(Settings.key == "ha_url"))
-                    ha_url_setting = result.scalar_one_or_none()
-                    result = await session.execute(select(Settings).where(Settings.key == "ha_token"))
-                    ha_token_setting = result.scalar_one_or_none()
-
-            ha_url = ha_url_setting.value if ha_url_setting else ""
-            ha_token = ha_token_setting.value if ha_token_setting else ""
-            homeassistant_service.configure(ha_url, ha_token)
+                    ha_settings = await get_homeassistant_settings(session)
+
+            homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to configure HA service: %s", e)
             logger.warning("Failed to configure HA service: %s", e)
 
 

+ 89 - 18
backend/app/services/spoolman.py

@@ -1,5 +1,6 @@
 """Spoolman integration service for syncing AMS filament data."""
 """Spoolman integration service for syncing AMS filament data."""
 
 
+import asyncio
 import logging
 import logging
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
@@ -68,9 +69,22 @@ class SpoolmanClient:
         self._connected = False
         self._connected = False
 
 
     async def _get_client(self) -> httpx.AsyncClient:
     async def _get_client(self) -> httpx.AsyncClient:
-        """Get or create the HTTP client."""
+        """Get or create the HTTP client with connection pooling limits.
+
+        Configures the client to prevent idle connection issues:
+        - max_keepalive_connections=5: Limit number of persistent connections
+        - keepalive_expiry=30: Close idle connections after 30 seconds
+        - max_connections=10: Limit total connections to prevent resource exhaustion
+        """
         if self._client is None:
         if self._client is None:
-            self._client = httpx.AsyncClient(timeout=10.0)
+            self._client = httpx.AsyncClient(
+                timeout=10.0,
+                limits=httpx.Limits(
+                    max_keepalive_connections=5,
+                    max_connections=10,
+                    keepalive_expiry=30.0,
+                ),
+            )
         return self._client
         return self._client
 
 
     async def close(self):
     async def close(self):
@@ -101,19 +115,59 @@ class SpoolmanClient:
         return self._connected
         return self._connected
 
 
     async def get_spools(self) -> list[dict]:
     async def get_spools(self) -> list[dict]:
-        """Get all spools from Spoolman.
+        """Get all spools from Spoolman with retry logic.
+
+        Attempts to fetch spools up to 3 times with 500ms delay between attempts.
+        This handles transient network errors like closed connections.
 
 
         Returns:
         Returns:
             List of spool dictionaries.
             List of spool dictionaries.
+
+        Raises:
+            Exception: If all 3 retry attempts fail.
         """
         """
-        try:
-            client = await self._get_client()
-            response = await client.get(f"{self.api_url}/spool")
-            response.raise_for_status()
-            return response.json()
-        except Exception as e:
-            logger.error("Failed to get spools from Spoolman: %s", e)
-            return []
+        max_attempts = 3
+        retry_delay = 0.5  # 500ms
+
+        for attempt in range(1, max_attempts + 1):
+            try:
+                client = await self._get_client()
+                response = await client.get(f"{self.api_url}/spool")
+                response.raise_for_status()
+                spools = response.json()
+                if attempt > 1:
+                    logger.info("Successfully fetched %d spools on attempt %d", len(spools), attempt)
+                return spools
+            except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
+                # Connection-related errors - close and recreate client for next attempt
+                if attempt < max_attempts:
+                    logger.warning(
+                        "Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...",
+                        attempt,
+                        max_attempts,
+                        e,
+                        int(retry_delay * 1000),
+                    )
+                    # Close the stale client and recreate it
+                    await self.close()
+                    await asyncio.sleep(retry_delay)
+                else:
+                    logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
+                    raise
+            except Exception as e:
+                # Other errors (HTTP errors, JSON decode errors, etc.)
+                if attempt < max_attempts:
+                    logger.warning(
+                        "Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
+                        attempt,
+                        max_attempts,
+                        e,
+                        int(retry_delay * 1000),
+                    )
+                    await asyncio.sleep(retry_delay)
+                else:
+                    logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
+                    raise
 
 
     async def get_filaments(self) -> list[dict]:
     async def get_filaments(self) -> list[dict]:
         """Get all internal filaments from Spoolman.
         """Get all internal filaments from Spoolman.
@@ -387,16 +441,18 @@ class SpoolmanClient:
             logger.error("Failed to record spool usage in Spoolman: %s", e)
             logger.error("Failed to record spool usage in Spoolman: %s", e)
             return None
             return None
 
 
-    async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
+    async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
         """Find a spool by its RFID tag UID.
         """Find a spool by its RFID tag UID.
 
 
         Args:
         Args:
             tag_uid: The RFID tag UID to search for
             tag_uid: The RFID tag UID to search for
+            cached_spools: Optional pre-fetched list of spools to search (avoids API call)
 
 
         Returns:
         Returns:
             Spool dictionary or None if not found.
             Spool dictionary or None if not found.
         """
         """
-        spools = await self.get_spools()
+        # Use cached spools if provided, otherwise fetch from API
+        spools = cached_spools if cached_spools is not None else await self.get_spools()
         # Normalize tag_uid for comparison (uppercase, strip quotes)
         # Normalize tag_uid for comparison (uppercase, strip quotes)
         search_tag = tag_uid.strip('"').upper()
         search_tag = tag_uid.strip('"').upper()
 
 
@@ -412,16 +468,20 @@ class SpoolmanClient:
                         return spool
                         return spool
         return None
         return None
 
 
-    async def find_spools_by_location_prefix(self, location_prefix: str) -> list[dict]:
+    async def find_spools_by_location_prefix(
+        self, location_prefix: str, cached_spools: list[dict] | None = None
+    ) -> list[dict]:
         """Find all spools with locations starting with a given prefix.
         """Find all spools with locations starting with a given prefix.
 
 
         Args:
         Args:
             location_prefix: The location prefix to search for (e.g., "PrinterName - ")
             location_prefix: The location prefix to search for (e.g., "PrinterName - ")
+            cached_spools: Optional pre-fetched list of spools to search (avoids API call)
 
 
         Returns:
         Returns:
             List of spool dictionaries with matching locations.
             List of spool dictionaries with matching locations.
         """
         """
-        spools = await self.get_spools()
+        # Use cached spools if provided, otherwise fetch from API
+        spools = cached_spools if cached_spools is not None else await self.get_spools()
         matching = []
         matching = []
         for spool in spools:
         for spool in spools:
             location = spool.get("location", "")
             location = spool.get("location", "")
@@ -433,6 +493,7 @@ class SpoolmanClient:
         self,
         self,
         printer_name: str,
         printer_name: str,
         current_tray_uuids: set[str],
         current_tray_uuids: set[str],
+        cached_spools: list[dict] | None = None,
     ) -> int:
     ) -> int:
         """Clear location for spools that are no longer in the AMS.
         """Clear location for spools that are no longer in the AMS.
 
 
@@ -443,12 +504,13 @@ class SpoolmanClient:
         Args:
         Args:
             printer_name: The printer name used as location prefix
             printer_name: The printer name used as location prefix
             current_tray_uuids: Set of tray_uuids currently in the AMS
             current_tray_uuids: Set of tray_uuids currently in the AMS
+            cached_spools: Optional pre-fetched list of spools to search (avoids API call)
 
 
         Returns:
         Returns:
             Number of spools whose location was cleared.
             Number of spools whose location was cleared.
         """
         """
         location_prefix = f"{printer_name} - "
         location_prefix = f"{printer_name} - "
-        spools_at_printer = await self.find_spools_by_location_prefix(location_prefix)
+        spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
         cleared_count = 0
         cleared_count = 0
 
 
         for spool in spools_at_printer:
         for spool in spools_at_printer:
@@ -662,7 +724,13 @@ class SpoolmanClient:
         """
         """
         return (remain_percent / 100.0) * spool_weight
         return (remain_percent / 100.0) * spool_weight
 
 
-    async def sync_ams_tray(self, tray: AMSTray, printer_name: str, disable_weight_sync: bool = False) -> dict | None:
+    async def sync_ams_tray(
+        self,
+        tray: AMSTray,
+        printer_name: str,
+        disable_weight_sync: bool = False,
+        cached_spools: list[dict] | None = None,
+    ) -> dict | None:
         """Sync a single AMS tray to Spoolman.
         """Sync a single AMS tray to Spoolman.
 
 
         Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
         Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
@@ -676,6 +744,9 @@ class SpoolmanClient:
             printer_name: Name of the printer for location
             printer_name: Name of the printer for location
             disable_weight_sync: If True, skip updating remaining_weight for existing spools.
             disable_weight_sync: If True, skip updating remaining_weight for existing spools.
                 This allows Spoolman's granular usage tracking to maintain accurate weights.
                 This allows Spoolman's granular usage tracking to maintain accurate weights.
+            cached_spools: Optional pre-fetched list of spools to search (avoids API calls).
+                When provided, this cache is passed to find_spool_by_tag to avoid redundant
+                API calls during batch sync operations.
 
 
         Returns:
         Returns:
             Synced spool dictionary or None if skipped or failed.
             Synced spool dictionary or None if skipped or failed.
@@ -716,7 +787,7 @@ class SpoolmanClient:
         location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
         location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
 
 
         # Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
         # Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
-        existing = await self.find_spool_by_tag(spool_tag)
+        existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
         if existing:
         if existing:
             # Update existing spool
             # Update existing spool
             logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
             logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])

+ 2 - 1
backend/app/services/spoolman_tracking.py

@@ -58,7 +58,8 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
         ams_id = ams_unit.get("id", 0)
         ams_id = ams_unit.get("id", 0)
         for tray in ams_unit.get("tray", []):
         for tray in ams_unit.get("tray", []):
             tray_id = tray.get("id", 0)
             tray_id = tray.get("id", 0)
-            global_tray_id = ams_id * 4 + tray_id
+            # 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] = {
             lookup[global_tray_id] = {
                 "tray_uuid": tray.get("tray_uuid", ""),
                 "tray_uuid": tray.get("tray_uuid", ""),
                 "tag_uid": tray.get("tag_uid", ""),
                 "tag_uid": tray.get("tag_uid", ""),

+ 28 - 10
backend/app/services/virtual_printer/certificate.py

@@ -193,13 +193,39 @@ class CertificateService:
 
 
         return ca_key, ca_cert
         return ca_key, ca_cert
 
 
-    def generate_certificates(self) -> tuple[Path, Path]:
+    def _build_san_entries(self, local_ip: str, additional_ips: list[str] | None) -> list[x509.GeneralName]:
+        """Build Subject Alternative Name entries for the printer certificate."""
+        entries: list[x509.GeneralName] = [
+            x509.DNSName("localhost"),
+            x509.DNSName("bambuddy"),
+            x509.DNSName(self.serial),
+            x509.IPAddress(IPv4Address(local_ip)),
+            x509.IPAddress(IPv4Address("127.0.0.1")),
+        ]
+        seen_ips = {local_ip, "127.0.0.1"}
+        if additional_ips:
+            for ip in additional_ips:
+                if ip and ip not in seen_ips:
+                    try:
+                        entries.append(x509.IPAddress(IPv4Address(ip)))
+                        seen_ips.add(ip)
+                        logger.info("Added additional SAN IP: %s", ip)
+                    except ValueError:
+                        logger.warning("Skipping invalid additional SAN IP: %s", ip)
+        return entries
+
+    def generate_certificates(self, additional_ips: list[str] | None = None) -> tuple[Path, Path]:
         """Generate printer certificate (reusing existing CA if available).
         """Generate printer certificate (reusing existing CA if available).
 
 
         Creates a certificate chain mimicking real Bambu printers:
         Creates a certificate chain mimicking real Bambu printers:
         - CA certificate (reused if exists and valid, otherwise generated)
         - CA certificate (reused if exists and valid, otherwise generated)
         - Printer certificate (CN=serial, signed by CA)
         - Printer certificate (CN=serial, signed by CA)
 
 
+        Args:
+            additional_ips: Extra IP addresses to include in certificate SAN.
+                Used in proxy mode to include the remote interface IP so the
+                slicer's TLS handshake succeeds when connecting to the proxy.
+
         Returns:
         Returns:
             Tuple of (cert_path, key_path)
             Tuple of (cert_path, key_path)
         """
         """
@@ -245,15 +271,7 @@ class CertificateService:
                 critical=True,
                 critical=True,
             )
             )
             .add_extension(
             .add_extension(
-                x509.SubjectAlternativeName(
-                    [
-                        x509.DNSName("localhost"),
-                        x509.DNSName("bambuddy"),
-                        x509.DNSName(self.serial),
-                        x509.IPAddress(IPv4Address(local_ip)),
-                        x509.IPAddress(IPv4Address("127.0.0.1")),
-                    ]
-                ),
+                x509.SubjectAlternativeName(self._build_san_entries(local_ip, additional_ips)),
                 critical=False,
                 critical=False,
             )
             )
             .add_extension(
             .add_extension(

+ 113 - 43
backend/app/services/virtual_printer/ftp_server.py

@@ -9,6 +9,7 @@ immediately upon connection, before any FTP commands are exchanged.
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import os
 import random
 import random
 import ssl
 import ssl
 from collections.abc import Callable
 from collections.abc import Callable
@@ -31,6 +32,8 @@ class FTPSession:
         access_code: str,
         access_code: str,
         ssl_context: ssl.SSLContext,
         ssl_context: ssl.SSLContext,
         on_file_received: Callable[[Path, str], None] | None,
         on_file_received: Callable[[Path, str], None] | None,
+        passive_port_range: tuple[int, int] = (50000, 50100),
+        pasv_address: str = "",
     ):
     ):
         self.reader = reader
         self.reader = reader
         self.writer = writer
         self.writer = writer
@@ -38,6 +41,8 @@ class FTPSession:
         self.access_code = access_code
         self.access_code = access_code
         self.ssl_context = ssl_context
         self.ssl_context = ssl_context
         self.on_file_received = on_file_received
         self.on_file_received = on_file_received
+        self.passive_port_range = passive_port_range
+        self.pasv_address = pasv_address
 
 
         self.authenticated = False
         self.authenticated = False
         self.username: str | None = None
         self.username: str | None = None
@@ -50,6 +55,7 @@ class FTPSession:
         self._data_reader: asyncio.StreamReader | None = None
         self._data_reader: asyncio.StreamReader | None = None
         self._data_writer: asyncio.StreamWriter | None = None
         self._data_writer: asyncio.StreamWriter | None = None
         self._data_connected = asyncio.Event()
         self._data_connected = asyncio.Event()
+        self._transfer_done = asyncio.Event()
 
 
         peername = writer.get_extra_info("peername")
         peername = writer.get_extra_info("peername")
         self.remote_ip = peername[0] if peername else "unknown"
         self.remote_ip = peername[0] if peername else "unknown"
@@ -113,6 +119,9 @@ class FTPSession:
 
 
     async def _cleanup(self) -> None:
     async def _cleanup(self) -> None:
         """Clean up session resources."""
         """Clean up session resources."""
+        # Release any waiting data connection callback
+        self._transfer_done.set()
+
         if self.data_server:
         if self.data_server:
             self.data_server.close()
             self.data_server.close()
             try:
             try:
@@ -159,6 +168,7 @@ class FTPSession:
         features = [
         features = [
             "211-Features:",
             "211-Features:",
             " PASV",
             " PASV",
+            " EPSV",
             " UTF8",
             " UTF8",
             " SIZE",
             " SIZE",
             "211 End",
             "211 End",
@@ -196,6 +206,28 @@ class FTPSession:
         else:
         else:
             await self.send(504, "Type not supported")
             await self.send(504, "Type not supported")
 
 
+    async def _bind_passive_port(self) -> bool:
+        """Try to bind a passive data port with retries.
+
+        Returns True if a port was successfully bound, False otherwise.
+        Sets self.data_server and self.data_port on success.
+        """
+        port_min, port_max = self.passive_port_range
+        for attempt in range(10):
+            port = random.randint(port_min, port_max)
+            try:
+                self.data_server = await asyncio.start_server(
+                    self._handle_data_connection,
+                    "0.0.0.0",  # nosec B104
+                    port,
+                    ssl=self.ssl_context,
+                )
+                self.data_port = port
+                return True
+            except OSError:
+                logger.debug("FTP passive port %s in use, retrying (%s/10)", port, attempt + 1)
+        return False
+
     async def cmd_EPSV(self, arg: str) -> None:
     async def cmd_EPSV(self, arg: str) -> None:
         """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
         """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
         if not self.authenticated:
         if not self.authenticated:
@@ -205,29 +237,18 @@ class FTPSession:
         # Close any existing data connection/server
         # Close any existing data connection/server
         await self._close_data_connection()
         await self._close_data_connection()
 
 
-        # Reset connection state
+        # Reset connection state for the new transfer
         self._data_connected.clear()
         self._data_connected.clear()
         self._data_reader = None
         self._data_reader = None
         self._data_writer = None
         self._data_writer = None
+        self._transfer_done = asyncio.Event()
 
 
-        # Find a free port for passive data connection
-        self.data_port = random.randint(50000, 60000)
-
-        try:
-            # Create data server with TLS - use same context for session reuse
-            self.data_server = await asyncio.start_server(
-                self._handle_data_connection,
-                "0.0.0.0",  # nosec B104
-                self.data_port,
-                ssl=self.ssl_context,
-            )
-
+        if await self._bind_passive_port():
             # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
             # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
             await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
             await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
             logger.info("FTP EPSV listening on port %s", self.data_port)
             logger.info("FTP EPSV listening on port %s", self.data_port)
-
-        except Exception as e:
-            logger.error("Failed to create EPSV data connection: %s", e)
+        else:
+            logger.error("Failed to bind any passive port for EPSV")
             await self.send(425, "Cannot open data connection")
             await self.send(425, "Cannot open data connection")
 
 
     async def cmd_PASV(self, arg: str) -> None:
     async def cmd_PASV(self, arg: str) -> None:
@@ -239,27 +260,24 @@ class FTPSession:
         # Close any existing data connection/server
         # Close any existing data connection/server
         await self._close_data_connection()
         await self._close_data_connection()
 
 
-        # Reset connection state
+        # Reset connection state for the new transfer
         self._data_connected.clear()
         self._data_connected.clear()
         self._data_reader = None
         self._data_reader = None
         self._data_writer = None
         self._data_writer = None
+        self._transfer_done = asyncio.Event()
 
 
-        # Find a free port for passive data connection
-        self.data_port = random.randint(50000, 60000)
-
-        try:
-            # Create data server with TLS
-            self.data_server = await asyncio.start_server(
-                self._handle_data_connection,
-                "0.0.0.0",  # nosec B104
-                self.data_port,
-                ssl=self.ssl_context,
-            )
-
-            # Get server's IP for response
-            # Use the IP the client connected to
-            sockname = self.writer.get_extra_info("sockname")
-            ip = sockname[0] if sockname else "127.0.0.1"
+        if await self._bind_passive_port():
+            # Determine the IP to advertise in PASV response
+            if self.pasv_address:
+                # Explicit override (e.g., for Docker bridge mode behind NAT)
+                ip = self.pasv_address
+            else:
+                # Use the local IP of the control connection
+                sockname = self.writer.get_extra_info("sockname")
+                ip = sockname[0] if sockname else "127.0.0.1"
+                # 0.0.0.0 is not routable — fall back to control connection IP
+                if ip == "0.0.0.0":  # nosec B104
+                    ip = "127.0.0.1"
 
 
             # Format IP and port for PASV response
             # Format IP and port for PASV response
             ip_parts = ip.split(".")
             ip_parts = ip.split(".")
@@ -270,14 +288,30 @@ class FTPSession:
                 227,
                 227,
                 f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
                 f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
             )
             )
-            logger.info("FTP PASV listening on port %s", self.data_port)
-
-        except Exception as e:
-            logger.error("Failed to create passive data connection: %s", e)
+            logger.info("FTP PASV listening on %s:%s", ip, self.data_port)
+        else:
+            logger.error("Failed to bind any passive port for PASV")
             await self.send(425, "Cannot open data connection")
             await self.send(425, "Cannot open data connection")
 
 
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
-        """Handle incoming data connection (used by PASV)."""
+        """Handle incoming data connection (used by PASV/EPSV).
+
+        This callback stays alive until the transfer completes to ensure the
+        asyncio task holds strong references to the reader/writer throughout
+        the data transfer.  If the callback returned immediately, the task
+        would complete and the StreamReaderProtocol could release its strong
+        reader reference, potentially destabilising the connection.
+        """
+        # Reject duplicate connections — only one data connection per transfer
+        if self._data_reader is not None:
+            logger.warning("FTP rejecting duplicate data connection from %s", self.remote_ip)
+            try:
+                writer.close()
+                await writer.wait_closed()
+            except OSError:
+                pass
+            return
+
         # Log TLS details for debugging
         # Log TLS details for debugging
         ssl_obj = writer.get_extra_info("ssl_object")
         ssl_obj = writer.get_extra_info("ssl_object")
         if ssl_obj:
         if ssl_obj:
@@ -291,13 +325,26 @@ class FTPSession:
         logger.info("FTP data connection established from %s", self.remote_ip)
         logger.info("FTP data connection established from %s", self.remote_ip)
         self._data_reader = reader
         self._data_reader = reader
         self._data_writer = writer
         self._data_writer = writer
+
+        # Stop accepting further connections on the passive port
+        if self.data_server:
+            self.data_server.close()
+
         self._data_connected.set()
         self._data_connected.set()
-        # Don't close - let the transfer command handle it
+
+        # Keep this callback alive until the transfer command (STOR/RETR)
+        # finishes. This ensures the asyncio server-handler task holds strong
+        # references to reader/writer for the entire transfer lifetime.
+        await self._transfer_done.wait()
 
 
     async def _close_data_connection(self) -> None:
     async def _close_data_connection(self) -> None:
         """Close the data connection and server."""
         """Close the data connection and server."""
         had_connection = self._data_writer is not None or self.data_server is not None
         had_connection = self._data_writer is not None or self.data_server is not None
 
 
+        # Signal the _handle_data_connection callback to return, allowing
+        # its asyncio task to complete cleanly.
+        self._transfer_done.set()
+
         if self._data_writer:
         if self._data_writer:
             try:
             try:
                 self._data_writer.close()
                 self._data_writer.close()
@@ -325,7 +372,7 @@ class FTPSession:
             await self.send(530, "Not logged in")
             await self.send(530, "Not logged in")
             return
             return
 
 
-        if not self.data_server:
+        if not self.data_server and not self._data_connected.is_set():
             await self.send(425, "Use PASV first")
             await self.send(425, "Use PASV first")
             return
             return
 
 
@@ -352,20 +399,28 @@ class FTPSession:
 
 
         # Receive data
         # Receive data
         data_content: list[bytes] = []
         data_content: list[bytes] = []
+        total_received = 0
         try:
         try:
             while True:
             while True:
                 chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
                 chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
                 if not chunk:
                 if not chunk:
                     break
                     break
                 data_content.append(chunk)
                 data_content.append(chunk)
-                logger.debug("FTP received chunk: %s bytes", len(chunk))
+                total_received += len(chunk)
+                logger.debug("FTP received chunk: %s bytes (total: %s)", len(chunk), total_received)
         except TimeoutError:
         except TimeoutError:
-            logger.error("FTP data transfer timeout")
+            logger.error("FTP data transfer timeout after %s bytes for %s", total_received, filename)
             await self.send(426, "Transfer timeout")
             await self.send(426, "Transfer timeout")
             await self._close_data_connection()
             await self._close_data_connection()
             return
             return
         except Exception as e:
         except Exception as e:
-            logger.error("FTP data transfer error: %s", e)
+            logger.error(
+                "FTP data transfer error after %s bytes for %s: %s(%s)",
+                total_received,
+                filename,
+                type(e).__name__,
+                e,
+            )
             await self.send(426, f"Transfer failed: {e}")
             await self.send(426, f"Transfer failed: {e}")
             await self._close_data_connection()
             await self._close_data_connection()
             return
             return
@@ -458,6 +513,9 @@ class FTPSession:
 class VirtualPrinterFTPServer:
 class VirtualPrinterFTPServer:
     """Implicit FTPS server that accepts uploads from slicers."""
     """Implicit FTPS server that accepts uploads from slicers."""
 
 
+    PASSIVE_PORT_MIN = 50000
+    PASSIVE_PORT_MAX = 50100
+
     def __init__(
     def __init__(
         self,
         self,
         upload_dir: Path,
         upload_dir: Path,
@@ -487,6 +545,8 @@ class VirtualPrinterFTPServer:
         self._running = False
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
         self._ssl_context: ssl.SSLContext | None = None
         self._active_sessions: list[asyncio.Task] = []
         self._active_sessions: list[asyncio.Task] = []
+        # Override PASV response IP for Docker bridge mode / NAT environments
+        self._pasv_address = os.environ.get("VIRTUAL_PRINTER_PASV_ADDRESS", "")
 
 
     async def start(self) -> None:
     async def start(self) -> None:
         """Start the implicit FTPS server."""
         """Start the implicit FTPS server."""
@@ -504,6 +564,7 @@ class VirtualPrinterFTPServer:
         self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
         self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
         self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
         self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
         self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
         self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+        self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
 
         # Use standard TLS settings for compatibility
         # Use standard TLS settings for compatibility
         self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
         self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
@@ -521,6 +582,13 @@ class VirtualPrinterFTPServer:
             self._running = True
             self._running = True
 
 
             logger.info("Implicit FTPS server started on port %s", self.port)
             logger.info("Implicit FTPS server started on port %s", self.port)
+            logger.info(
+                "FTP passive data port range: %s-%s",
+                self.PASSIVE_PORT_MIN,
+                self.PASSIVE_PORT_MAX,
+            )
+            if self._pasv_address:
+                logger.info("FTP PASV address override: %s", self._pasv_address)
 
 
             async with self._server:
             async with self._server:
                 await self._server.serve_forever()
                 await self._server.serve_forever()
@@ -549,6 +617,8 @@ class VirtualPrinterFTPServer:
             access_code=self.access_code,
             access_code=self.access_code,
             ssl_context=self._ssl_context,
             ssl_context=self._ssl_context,
             on_file_received=self.on_file_received,
             on_file_received=self.on_file_received,
+            passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
+            pasv_address=self._pasv_address,
         )
         )
 
 
         # Track the session task so we can cancel it on stop
         # Track the session task so we can cancel it on stop

+ 29 - 7
backend/app/services/virtual_printer/manager.py

@@ -249,7 +249,8 @@ class VirtualPrinterManager:
         needs_restart = (
         needs_restart = (
             model_changed
             model_changed
             or mode_changed
             or mode_changed
-            or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
+            or remote_iface_changed
+            or (mode == "proxy" and (target_changed or serial_changed))
         )
         )
 
 
         if enabled and not self._enabled:
         if enabled and not self._enabled:
@@ -296,8 +297,12 @@ class VirtualPrinterManager:
         self._cert_service.serial = proxy_serial
         self._cert_service.serial = proxy_serial
 
 
         # Regenerate printer cert if needed (CA is preserved)
         # Regenerate printer cert if needed (CA is preserved)
+        # Include remote interface IP in SAN so slicer TLS succeeds
+        additional_ips = []
+        if self._remote_interface_ip:
+            additional_ips.append(self._remote_interface_ip)
         self._cert_service.delete_printer_certificate()
         self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates()
+        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
         logger.info("Generated certificate for proxy serial: %s", proxy_serial)
         logger.info("Generated certificate for proxy serial: %s", proxy_serial)
 
 
         # Initialize TLS proxy with our certificates
         # Initialize TLS proxy with our certificates
@@ -359,9 +364,11 @@ class VirtualPrinterManager:
         )
         )
 
 
         logger.info(
         logger.info(
-            f"Virtual printer proxy started: "
-            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
-            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+            "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d",
+            self._target_printer_ip,
+            SlicerProxyManager.PRINTER_FTP_PORT,
+            self._target_printer_ip,
+            SlicerProxyManager.PRINTER_MQTT_PORT,
         )
         )
 
 
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
@@ -386,8 +393,12 @@ class VirtualPrinterManager:
         self._cert_service.serial = current_serial
         self._cert_service.serial = current_serial
 
 
         # Regenerate printer cert if serial changed (CA is preserved)
         # Regenerate printer cert if serial changed (CA is preserved)
+        # Include remote interface IP in SAN so slicer TLS succeeds on that interface
+        additional_ips = []
+        if self._remote_interface_ip:
+            additional_ips.append(self._remote_interface_ip)
         self._cert_service.delete_printer_certificate()
         self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates()
+        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
         logger.info("Generated certificate for serial: %s", current_serial)
         logger.info("Generated certificate for serial: %s", current_serial)
 
 
         # Create directories
         # Create directories
@@ -399,6 +410,7 @@ class VirtualPrinterManager:
             name=self.PRINTER_NAME,
             name=self.PRINTER_NAME,
             serial=self.printer_serial,
             serial=self.printer_serial,
             model=self._model,
             model=self._model,
+            advertise_ip=self._remote_interface_ip,
         )
         )
 
 
         self._ftp = VirtualPrinterFTPServer(
         self._ftp = VirtualPrinterFTPServer(
@@ -500,6 +512,11 @@ class VirtualPrinterManager:
             # "review" mode (or legacy "queue" mode)
             # "review" mode (or legacy "queue" mode)
             await self._queue_file(file_path, source_ip)
             await self._queue_file(file_path, source_ip)
 
 
+        # Reset MQTT status back to IDLE after file processing
+        # This tells the slicer the printer is done with the file
+        if self._mqtt and file_path.suffix.lower() == ".3mf":
+            self._mqtt.set_gcode_state("IDLE")
+
     async def _on_print_command(self, filename: str, data: dict) -> None:
     async def _on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT.
         """Handle print command from MQTT.
 
 
@@ -584,7 +601,12 @@ class VirtualPrinterManager:
 
 
         # Only queue 3MF files
         # Only queue 3MF files
         if file_path.suffix.lower() != ".3mf":
         if file_path.suffix.lower() != ".3mf":
-            logger.warning("Skipping non-3MF file: %s", file_path.name)
+            logger.debug("Skipping non-3MF file: %s", file_path.name)
+            self._pending_files.pop(file_path.name, None)
+            try:
+                file_path.unlink()
+            except OSError:
+                pass  # Best-effort removal of non-3MF file; may already be gone
             return
             return
 
 
         try:
         try:

+ 85 - 67
backend/app/services/virtual_printer/mqtt_server.py

@@ -181,6 +181,11 @@ class SimpleMQTTServer:
         self._status_push_task: asyncio.Task | None = None
         self._status_push_task: asyncio.Task | None = None
         self._sequence_id = 0
         self._sequence_id = 0
 
 
+        # Dynamic state for status reports
+        self._gcode_state = "IDLE"
+        self._current_file = ""
+        self._prepare_percent = "0"
+
     async def start(self) -> None:
     async def start(self) -> None:
         """Start the MQTT server."""
         """Start the MQTT server."""
         if self._running:
         if self._running:
@@ -521,10 +526,10 @@ class SimpleMQTTServer:
                     "sequence_id": str(self._sequence_id),
                     "sequence_id": str(self._sequence_id),
                     "command": "push_status",
                     "command": "push_status",
                     "msg": 0,
                     "msg": 0,
-                    "gcode_state": "IDLE",
-                    "gcode_file": "",
-                    "gcode_file_prepare_percent": "0",
-                    "subtask_name": "",
+                    "gcode_state": self._gcode_state,
+                    "gcode_file": self._current_file,
+                    "gcode_file_prepare_percent": self._prepare_percent,
+                    "subtask_name": self._current_file.replace(".3mf", "") if self._current_file else "",
                     "mc_print_stage": "",
                     "mc_print_stage": "",
                     "mc_percent": 0,
                     "mc_percent": 0,
                     "mc_remaining_time": 0,
                     "mc_remaining_time": 0,
@@ -589,38 +594,7 @@ class SimpleMQTTServer:
                 }
                 }
             }
             }
 
 
-            topic = f"device/{self.serial}/report"
-            message = json.dumps(status)
-
-            # Build MQTT PUBLISH packet
-            topic_bytes = topic.encode("utf-8")
-            message_bytes = message.encode("utf-8")
-
-            # Calculate remaining length
-            remaining = 2 + len(topic_bytes) + len(message_bytes)
-
-            # Build packet
-            packet = bytes([0x30])  # PUBLISH, QoS 0
-
-            # Encode remaining length
-            while remaining > 0:
-                byte = remaining % 128
-                remaining //= 128
-                if remaining > 0:
-                    byte |= 0x80
-                packet += bytes([byte])
-
-            # Topic length and topic
-            packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
-            packet += topic_bytes
-
-            # Message payload
-            packet += message_bytes
-
-            writer.write(packet)
-            await writer.drain()
-
-            logger.info("Sent initial status report on %s", topic)
+            await self._publish_to_report(writer, status)
 
 
         except OSError as e:
         except OSError as e:
             logger.error("Failed to send status report: %s", e)
             logger.error("Failed to send status report: %s", e)
@@ -684,41 +658,79 @@ class SimpleMQTTServer:
                 }
                 }
             }
             }
 
 
-            topic = f"device/{self.serial}/report"
-            message = json.dumps(version_info)
-
-            # Build MQTT PUBLISH packet
-            topic_bytes = topic.encode("utf-8")
-            message_bytes = message.encode("utf-8")
-
-            # Calculate remaining length
-            remaining = 2 + len(topic_bytes) + len(message_bytes)
-
-            # Build packet
-            packet = bytes([0x30])  # PUBLISH, QoS 0
-
-            # Encode remaining length
-            while remaining > 0:
-                byte = remaining % 128
-                remaining //= 128
-                if remaining > 0:
-                    byte |= 0x80
-                packet += bytes([byte])
+            await self._publish_to_report(writer, version_info)
+            logger.info("Sent version response")
 
 
-            # Topic length and topic
-            packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
-            packet += topic_bytes
+        except OSError as e:
+            logger.error("Failed to send version response: %s", e)
 
 
-            # Message payload
-            packet += message_bytes
+    def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None:
+        """Update the gcode state reported to connected slicers.
 
 
-            writer.write(packet)
-            await writer.drain()
+        Called by the manager to reflect FTP upload progress/completion.
+        """
+        self._gcode_state = state
+        self._current_file = filename
+        self._prepare_percent = prepare_percent
+
+    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None:
+        """Publish a message on the device report topic."""
+        topic = f"device/{self.serial}/report"
+        message = json.dumps(payload)
+
+        topic_bytes = topic.encode("utf-8")
+        message_bytes = message.encode("utf-8")
+
+        remaining = 2 + len(topic_bytes) + len(message_bytes)
+        packet = bytes([0x30])  # PUBLISH, QoS 0
+
+        while remaining > 0:
+            byte = remaining % 128
+            remaining //= 128
+            if remaining > 0:
+                byte |= 0x80
+            packet += bytes([byte])
+
+        packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
+        packet += topic_bytes
+        packet += message_bytes
+
+        writer.write(packet)
+        # Timeout the drain to prevent blocking the event loop if the
+        # MQTT client stops reading (e.g. slicer busy with FTP upload).
+        try:
+            await asyncio.wait_for(writer.drain(), timeout=5)
+        except TimeoutError:
+            logger.debug("MQTT drain timeout for %s — client may be busy", topic)
 
 
-            logger.info("Sent version response on %s", topic)
+    async def _send_print_response(self, writer: asyncio.StreamWriter, sequence_id: str, filename: str) -> None:
+        """Send project_file acknowledgment matching real Bambu printer behavior."""
+        # Update state so periodic status pushes reflect preparation
+        self._gcode_state = "PREPARE"
+        self._current_file = filename
+        self._prepare_percent = "0"
 
 
+        try:
+            # Send command acknowledgment — slicer expects to see
+            # command: "project_file" echoed back before starting FTP upload
+            subtask_name = filename.replace(".3mf", "") if filename else ""
+            response = {
+                "print": {
+                    "command": "project_file",
+                    "sequence_id": sequence_id,
+                    "param": "Metadata/plate_1.gcode",
+                    "subtask_name": subtask_name,
+                    "gcode_state": "PREPARE",
+                    "gcode_file": filename,
+                    "gcode_file_prepare_percent": "0",
+                    "result": "SUCCESS",
+                    "msg": 0,
+                }
+            }
+            await self._publish_to_report(writer, response)
+            logger.info("Sent project_file acknowledgment for %s", filename)
         except OSError as e:
         except OSError as e:
-            logger.error("Failed to send version response: %s", e)
+            logger.error("Failed to send print response: %s", e)
 
 
     async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
     async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
         """Handle MQTT PUBLISH packet."""
         """Handle MQTT PUBLISH packet."""
@@ -776,11 +788,17 @@ class SimpleMQTTServer:
                         print_data = data["print"]
                         print_data = data["print"]
                         command = print_data.get("command", "")
                         command = print_data.get("command", "")
                         filename = print_data.get("subtask_name", "")
                         filename = print_data.get("subtask_name", "")
+                        sequence_id = print_data.get("sequence_id", "0")
 
 
                         logger.info("MQTT print command: %s for %s", command, filename)
                         logger.info("MQTT print command: %s for %s", command, filename)
 
 
-                        if self.on_print_command and command == "project_file":
-                            await self._notify_print_command(filename, print_data)
+                        if command == "project_file":
+                            # Respond with PREPARE status so slicer proceeds with FTP upload
+                            file_3mf = print_data.get("file", filename)
+                            await self._send_print_response(writer, sequence_id, file_3mf)
+
+                            if self.on_print_command:
+                                await self._notify_print_command(filename, print_data)
 
 
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass  # Non-JSON payloads on request topic are safely ignored
                     pass  # Non-JSON payloads on request topic are safely ignored

+ 81 - 9
backend/app/services/virtual_printer/ssdp_server.py

@@ -33,6 +33,7 @@ class VirtualPrinterSSDPServer:
         name: str = "Bambuddy",
         name: str = "Bambuddy",
         serial: str = "00M09A391800001",  # X1C serial format for compatibility
         serial: str = "00M09A391800001",  # X1C serial format for compatibility
         model: str = "BL-P001",  # X1C model code for best compatibility
         model: str = "BL-P001",  # X1C model code for best compatibility
+        advertise_ip: str = "",
     ):
     ):
         """Initialize the SSDP server.
         """Initialize the SSDP server.
 
 
@@ -40,13 +41,14 @@ class VirtualPrinterSSDPServer:
             name: Display name shown in slicer discovery
             name: Display name shown in slicer discovery
             serial: Unique serial number for this virtual printer (must match cert CN)
             serial: Unique serial number for this virtual printer (must match cert CN)
             model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
             model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
+            advertise_ip: Override IP to advertise instead of auto-detecting
         """
         """
         self.name = name
         self.name = name
         self.serial = serial
         self.serial = serial
         self.model = model
         self.model = model
         self._running = False
         self._running = False
         self._socket: socket.socket | None = None
         self._socket: socket.socket | None = None
-        self._local_ip: str | None = None
+        self._local_ip: str | None = advertise_ip or None
 
 
     def _get_local_ip(self) -> str:
     def _get_local_ip(self) -> str:
         """Get the local IP address to advertise."""
         """Get the local IP address to advertise."""
@@ -328,8 +330,13 @@ class SSDPProxy:
             pass  # Return partial headers if parsing fails; malformed packets are common
             pass  # Return partial headers if parsing fails; malformed packets are common
         return headers
         return headers
 
 
-    def _rewrite_ssdp_location(self, data: bytes) -> bytes:
-        """Rewrite SSDP message with Bambuddy's remote IP as Location."""
+    def _rewrite_ssdp(self, data: bytes) -> bytes:
+        """Rewrite SSDP message for proxy re-broadcast.
+
+        - Location: changed to Bambuddy's remote interface IP
+        - DevBind: forced to 'free' so the slicer treats the proxy as a
+          LAN-only printer (avoids cloud auth requirement for sending prints)
+        """
         try:
         try:
             text = data.decode("utf-8", errors="ignore")
             text = data.decode("utf-8", errors="ignore")
             original = text
             original = text
@@ -340,11 +347,25 @@ class SSDPProxy:
                 text,
                 text,
                 flags=re.IGNORECASE,
                 flags=re.IGNORECASE,
             )
             )
+            # Force DevBind to 'free' - ensures slicer uses LAN mode for
+            # both monitoring AND sending prints through the proxy
+            text = re.sub(
+                r"(DevBind\.bambu\.com:\s*)\S+",
+                r"\g<1>free",
+                text,
+                flags=re.IGNORECASE,
+            )
+            # Append " - Proxy" to printer name so it's distinguishable
+            text = re.sub(
+                r"(DevName\.bambu\.com:\s*)(.+)",
+                r"\g<1>\g<2> - Proxy",
+                text,
+                flags=re.IGNORECASE,
+            )
             if text != original:
             if text != original:
-                logger.debug("Rewrote SSDP Location to %s", self.remote_interface_ip)
-                logger.debug("Rewritten SSDP packet:\n%s", text)
+                logger.debug("Rewrote SSDP for proxy:\n%s", text)
             else:
             else:
-                logger.warning("SSDP Location rewrite had no effect. Packet:\n%s", original)
+                logger.warning("SSDP rewrite had no effect. Packet:\n%s", original)
             return text.encode("utf-8")
             return text.encode("utf-8")
         except Exception as e:
         except Exception as e:
             logger.error("Failed to rewrite SSDP: %s", e)
             logger.error("Failed to rewrite SSDP: %s", e)
@@ -453,13 +474,26 @@ class SSDPProxy:
         self._remote_socket = None
         self._remote_socket = None
 
 
     async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
     async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
-        """Handle SSDP packet received on local interface (LAN A)."""
+        """Handle SSDP packet received on local interface (LAN A).
+
+        Processes two types of traffic:
+        - NOTIFY from the real printer → cache and re-broadcast on LAN B
+        - M-SEARCH from slicers on LAN B → respond with cached printer info
+        """
         sender_ip = addr[0]
         sender_ip = addr[0]
 
 
-        # Only process packets from the target printer
+        # Ignore packets from our own interfaces (prevent loops)
+        if sender_ip in (self.local_interface_ip, self.remote_interface_ip):
+            return
+
+        # Handle M-SEARCH from slicers (any IP that's not the target printer)
         if sender_ip != self.target_printer_ip:
         if sender_ip != self.target_printer_ip:
+            if b"M-SEARCH" in data:
+                await self._respond_to_msearch(data, addr)
             return
             return
 
 
+        # Below: NOTIFY handling from the real printer
+
         # Check if it's a NOTIFY message
         # Check if it's a NOTIFY message
         if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
         if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
             return
             return
@@ -478,6 +512,44 @@ class SSDPProxy:
         self._last_printer_ssdp = data
         self._last_printer_ssdp = data
         await self._broadcast_to_remote()
         await self._broadcast_to_remote()
 
 
+    async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:
+        """Respond to M-SEARCH from a slicer with cached, rewritten printer info.
+
+        When Bambu Studio sends an M-SEARCH (e.g., before sending a print),
+        we respond with the cached printer info, rewritten to point to the
+        proxy's LAN B IP. Without this, the slicer thinks the printer is
+        offline and shows a 'connect to printer' modal.
+        """
+        # Check if it's a relevant M-SEARCH
+        if b"bambulab-com:device:3dprinter" not in data and b"ssdp:all" not in data.lower():
+            return
+
+        if not self._last_printer_ssdp:
+            logger.debug("M-SEARCH from %s but no cached printer SSDP yet", addr[0])
+            return
+
+        logger.debug("Received M-SEARCH from slicer %s", addr[0])
+
+        # Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)
+        rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
+        text = rewritten.decode("utf-8", errors="ignore")
+
+        # Convert NOTIFY format to M-SEARCH response format:
+        #   "NOTIFY * HTTP/1.1" → "HTTP/1.1 200 OK"
+        #   NT: → ST: (Notification Type → Search Target)
+        #   Remove NTS: header (only in NOTIFY)
+        text = re.sub(r"^NOTIFY \* HTTP/1\.1", "HTTP/1.1 200 OK", text)
+        text = re.sub(r"^NT:", "ST:", text, flags=re.MULTILINE)
+        text = re.sub(r"^NTS:.*\r\n", "", text, flags=re.MULTILINE)
+
+        # Send unicast response directly to the slicer via remote socket
+        if self._remote_socket:
+            try:
+                self._remote_socket.sendto(text.encode("utf-8"), addr)
+                logger.info("Sent SSDP M-SEARCH response to %s", addr[0])
+            except OSError as e:
+                logger.debug("Failed to send M-SEARCH response to %s: %s", addr[0], e)
+
     async def _broadcast_to_remote(self) -> None:
     async def _broadcast_to_remote(self) -> None:
         """Broadcast cached printer SSDP on remote interface (LAN B)."""
         """Broadcast cached printer SSDP on remote interface (LAN B)."""
         if not self._remote_socket or not self._last_printer_ssdp:
         if not self._remote_socket or not self._last_printer_ssdp:
@@ -485,7 +557,7 @@ class SSDPProxy:
 
 
         try:
         try:
             # Rewrite Location to point to Bambuddy's remote interface
             # Rewrite Location to point to Bambuddy's remote interface
-            rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
+            rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
 
 
             # Calculate broadcast address for remote network
             # Calculate broadcast address for remote network
             # Use 255.255.255.255 for simplicity (works across subnets)
             # Use 255.255.255.255 for simplicity (works across subnets)

+ 570 - 3
backend/app/services/virtual_printer/tcp_proxy.py

@@ -12,13 +12,58 @@ Unlike a transparent TCP proxy, this terminates TLS on both ends:
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import random
+import re
 import ssl
 import ssl
+import subprocess
 from collections.abc import Callable
 from collections.abc import Callable
 from pathlib import Path
 from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+def detect_port_redirect(port: int) -> int | None:
+    """Detect if iptables redirects a port to another port.
+
+    When iptables NAT REDIRECT rules exist (e.g. 990→9990), connections
+    to the original port never reach our socket because iptables intercepts
+    them in PREROUTING. We must listen on the redirect target instead.
+
+    Returns the redirect target port, or None if no redirect is active.
+    """
+    # Method 1: Read persistent rules file (doesn't require root)
+    for rules_path in ("/etc/iptables/rules.v4", "/etc/iptables.rules"):
+        try:
+            with open(rules_path) as f:
+                content = f.read()
+            match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", content)
+            if match:
+                target = int(match.group(1))
+                if target != port:
+                    return target
+        except (FileNotFoundError, PermissionError, OSError):
+            continue
+
+    # Method 2: Query live iptables rules (may require root)
+    try:
+        result = subprocess.run(  # noqa: S603, S607
+            ["iptables-save", "-t", "nat"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        if result.returncode == 0:
+            match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", result.stdout)
+            if match:
+                target = int(match.group(1))
+                if target != port:
+                    return target
+    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
+        pass
+
+    return None
+
+
 class TLSProxy:
 class TLSProxy:
     """TLS terminating proxy that forwards data between client and target.
     """TLS terminating proxy that forwards data between client and target.
 
 
@@ -115,6 +160,17 @@ class TLSProxy:
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
                 logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
                 logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
+            elif e.errno == 13:  # Permission denied
+                logger.error(
+                    "%s proxy: cannot bind to port %s (permission denied). "
+                    "Port %s requires root or CAP_NET_BIND_SERVICE. "
+                    "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
+                    "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
+                    "or redirect with iptables.",
+                    self.name,
+                    self.listen_port,
+                    self.listen_port,
+                )
             else:
             else:
                 logger.error("%s proxy error: %s", self.name, e)
                 logger.error("%s proxy error: %s", self.name, e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
@@ -284,6 +340,503 @@ class TLSProxy:
         logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
         logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
 
 
 
 
+class FTPTLSProxy(TLSProxy):
+    """FTP-aware TLS proxy that handles passive data connections.
+
+    Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
+    channel, dynamically create TLS data proxies on local ports, and rewrite
+    the responses so the slicer connects to the proxy instead of the printer.
+
+    Without this, FTP passive data connections bypass the proxy and go directly
+    to the printer, which fails when the slicer can't reach the printer's IP.
+    """
+
+    PASV_PORT_MIN = 50000
+    PASV_PORT_MAX = 50100
+
+    async def stop(self) -> None:
+        """Stop proxy and clean up data connection servers."""
+        # Close all data servers first
+        for server in list(self._data_servers):
+            try:
+                server.close()
+                await server.wait_closed()
+            except OSError:
+                pass  # Best-effort cleanup of data proxy servers
+        self._data_servers.clear()
+        await super().stop()
+
+    async def start(self) -> None:
+        """Start the FTP TLS proxy."""
+        self._data_servers: list[asyncio.Server] = []
+        await super().start()
+
+    async def _handle_client(
+        self,
+        client_reader: asyncio.StreamReader,
+        client_writer: asyncio.StreamWriter,
+    ) -> None:
+        """Handle FTP client with PASV/EPSV-aware response forwarding."""
+        peername = client_writer.get_extra_info("peername")
+        client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+
+        logger.info("%s proxy: client connected from %s", self.name, client_id)
+
+        if self.on_connect:
+            try:
+                self.on_connect(client_id)
+            except Exception:
+                pass  # Ignore connect callback errors; connection proceeds regardless
+
+        # Determine our local IP from the control connection socket
+        sockname = client_writer.get_extra_info("sockname")
+        local_ip = sockname[0] if sockname else "0.0.0.0"  # nosec B104
+        if local_ip in ("0.0.0.0", "::"):  # nosec B104
+            local_ip = "127.0.0.1"
+
+        # Connect to target printer with TLS
+        try:
+            printer_reader, printer_writer = await asyncio.wait_for(
+                asyncio.open_connection(
+                    self.target_host,
+                    self.target_port,
+                    ssl=self._client_ssl_context,
+                ),
+                timeout=10.0,
+            )
+            logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
+        except TimeoutError:
+            logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except ssl.SSLError as e:
+            logger.error(
+                "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
+            )
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except OSError as e:
+            logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+
+        # Track data channel protection level per session.
+        # PROT C = cleartext data, PROT P = TLS data.
+        # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
+        # If the slicer sends PROT P, we switch to TLS for data connections.
+        session_state: dict[str, str] = {"prot": "C"}
+
+        # Client→Printer: intercept EPSV and replace with PASV
+        # EPSV responses only contain a port (no IP), so the slicer reuses
+        # the control connection IP. If that IP is the real printer (via
+        # iptables REDIRECT), the data connection bypasses the proxy.
+        # PASV responses include an explicit IP that we can rewrite.
+        client_to_printer = asyncio.create_task(
+            self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
+            name=f"{self.name}_c2p_{client_id}",
+        )
+        # Printer→Client: intercept PASV/EPSV responses
+        printer_to_client = asyncio.create_task(
+            self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
+            name=f"{self.name}_p2c_{client_id}",
+        )
+
+        self._active_connections[client_id] = (client_to_printer, printer_to_client)
+
+        try:
+            done, pending = await asyncio.wait(
+                [client_to_printer, printer_to_client],
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+            for task in pending:
+                task.cancel()
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass  # Expected when cancelling the other forwarding direction
+
+        except Exception as e:
+            logger.debug("%s proxy connection error: %s", self.name, e)
+        finally:
+            self._active_connections.pop(client_id, None)
+
+            for writer in [client_writer, printer_writer]:
+                try:
+                    writer.close()
+                    await writer.wait_closed()
+                except OSError:
+                    pass  # Best-effort connection cleanup; peer may have disconnected
+
+            logger.info("%s proxy: client %s disconnected", self.name, client_id)
+
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass  # Ignore disconnect callback errors; cleanup continues
+
+    async def _forward_ftp_commands(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        direction: str,
+        session_state: dict[str, str],
+    ) -> None:
+        """Forward FTP client commands, replacing EPSV with PASV.
+
+        EPSV responses only contain a port number — the client reuses the
+        control connection IP for data.  When the control IP is the real
+        printer (due to iptables REDIRECT), EPSV data connections bypass
+        the proxy.  PASV responses include an explicit IP that the proxy
+        can rewrite to its own address.
+
+        Also tracks PROT P/C commands to know whether data connections
+        should use TLS or cleartext.
+        """
+        buffer = b""
+        total_bytes = 0
+        try:
+            while self._running:
+                data = await reader.read(65536)
+                if not data:
+                    break
+
+                total_bytes += len(data)
+                buffer += data
+                output = b""
+
+                while b"\r\n" in buffer:
+                    idx = buffer.index(b"\r\n")
+                    line = buffer[:idx]
+                    buffer = buffer[idx + 2 :]
+
+                    cmd_upper = line.strip().upper()
+
+                    # Replace EPSV with PASV so response includes an IP
+                    if cmd_upper == b"EPSV":
+                        line = b"PASV"
+                        logger.info("FTP command rewrite: EPSV → PASV")
+
+                    # Track PROT level for data channel encryption
+                    elif cmd_upper == b"PROT P":
+                        session_state["prot"] = "P"
+                        logger.info("FTP data protection: PROT P (TLS)")
+                    elif cmd_upper == b"PROT C":
+                        session_state["prot"] = "C"
+                        logger.info("FTP data protection: PROT C (cleartext)")
+
+                    output += line + b"\r\n"
+
+                if output:
+                    writer.write(output)
+                    await writer.drain()
+
+                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
+
+        except asyncio.CancelledError:
+            pass  # Expected when the other forwarding direction closes first
+        except ConnectionResetError:
+            logger.debug("%s proxy %s: connection reset", self.name, direction)
+        except BrokenPipeError:
+            logger.debug("%s proxy %s: broken pipe", self.name, direction)
+        except OSError as e:
+            logger.debug("%s proxy %s error: %s", self.name, direction, e)
+
+        if buffer:
+            try:
+                writer.write(buffer)
+                await writer.drain()
+            except OSError:
+                pass  # Best-effort flush of remaining FTP command data
+
+        logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
+
+    async def _forward_ftp_control(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        direction: str,
+        local_ip: str,
+        session_state: dict[str, str],
+    ) -> None:
+        """Forward FTP control channel responses, rewriting PASV/EPSV.
+
+        FTP control channel is line-based (\\r\\n terminated). We buffer data
+        and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
+        responses to create local data proxies.
+        """
+        buffer = b""
+        total_bytes = 0
+
+        try:
+            while self._running:
+                data = await reader.read(65536)
+                if not data:
+                    break
+
+                total_bytes += len(data)
+                buffer += data
+                output = b""
+
+                # Process all complete lines
+                while b"\r\n" in buffer:
+                    idx = buffer.index(b"\r\n")
+                    line = buffer[:idx]
+                    buffer = buffer[idx + 2 :]
+
+                    rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
+                    output += rewritten + b"\r\n"
+
+                if output:
+                    writer.write(output)
+                    await writer.drain()
+
+                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
+
+        except asyncio.CancelledError:
+            pass  # Expected when the other forwarding direction closes first
+        except ConnectionResetError:
+            logger.debug("%s proxy %s: connection reset", self.name, direction)
+        except BrokenPipeError:
+            logger.debug("%s proxy %s: broken pipe", self.name, direction)
+        except OSError as e:
+            logger.debug("%s proxy %s error: %s", self.name, direction, e)
+
+        # Flush any remaining buffered data
+        if buffer:
+            try:
+                writer.write(buffer)
+                await writer.drain()
+            except OSError:
+                pass  # Best-effort flush of remaining FTP control data
+
+        logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
+
+    async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes:
+        """Rewrite PASV/EPSV response to point to a local data proxy."""
+        try:
+            text = line.decode("utf-8")
+        except UnicodeDecodeError:
+            return line
+
+        # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
+        if text.startswith("227 "):
+            match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
+            if match:
+                h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
+                printer_ip = f"{h1}.{h2}.{h3}.{h4}"
+                printer_port = p1 * 256 + p2
+
+                local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
+                if local_port:
+                    ip_parts = local_ip.split(".")
+                    lp1 = local_port // 256
+                    lp2 = local_port % 256
+                    rewritten = (
+                        f"227 Entering Passive Mode "
+                        f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
+                    )
+                    logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
+                    return rewritten.encode("utf-8")
+                else:
+                    logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
+            else:
+                logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
+
+        # 229 Entering Extended Passive Mode (|||port|)
+        elif text.startswith("229 "):
+            match = re.search(r"\(\|\|\|(\d+)\|\)", text)
+            if match:
+                printer_port = int(match.group(1))
+
+                local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
+                if local_port:
+                    rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
+                    logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
+                    return rewritten.encode("utf-8")
+                else:
+                    logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
+            else:
+                logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
+
+        return line
+
+    async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
+        """Create a one-shot proxy for an FTP data connection.
+
+        Prefers the printer's original passive port so the port number stays
+        the same in the rewritten PASV/EPSV response.  This is critical when
+        the slicer's FTP bounce-attack protection overrides the IP in the PASV
+        response: the slicer connects to <control_IP>:<port>, and if iptables
+        REDIRECT maps that port to the local machine, the data proxy must be
+        listening on the *same* port number.
+
+        Falls back to a random port if the original is unavailable.
+
+        Uses TLS or cleartext based on the session's PROT level:
+        - PROT P: TLS on both slicer and printer data connections
+        - PROT C: cleartext on both sides (common for A1/H2D printers)
+
+        Returns the local port number, or None if binding failed.
+        """
+        use_tls = session_state.get("prot") == "P"
+        logger.info(
+            "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
+            printer_ip,
+            printer_port,
+            "TLS" if use_tls else "cleartext",
+        )
+
+        # Try the printer's original port first — this ensures the port
+        # matches even when bounce protection or iptables REDIRECT is in play.
+        try:
+            await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls)
+            logger.info("FTP data proxy: using printer's port %s", printer_port)
+            return printer_port
+        except OSError as e:
+            logger.debug(
+                "FTP data proxy: printer port %s unavailable (%s), trying random",
+                printer_port,
+                e,
+            )
+
+        for _attempt in range(10):
+            port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
+            try:
+                await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls)
+                logger.info("FTP data proxy: using random port %s", port)
+                return port
+            except OSError:
+                continue
+
+        logger.error("Failed to bind FTP data proxy port after 10 attempts")
+        return None
+
+    async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
+        """Start a one-shot server for one FTP data connection.
+
+        The slicer-side listener is ALWAYS cleartext.  Even when the slicer
+        sends PROT P on the control channel, Bambu Studio does not perform
+        a TLS handshake on the data connection — it relies on the implicit
+        FTPS control channel for authentication and sends data unencrypted.
+
+        The printer-side outbound connection follows the PROT level:
+        - PROT P (use_tls=True): TLS to the printer's data port
+        - PROT C (use_tls=False): cleartext to the printer's data port
+
+        This mirrors the control channel's TLS-termination architecture.
+
+        Raises OSError if the port is already in use.
+        """
+        connected = asyncio.Event()
+        server_holder: list[asyncio.Server] = []
+
+        # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
+        # the data channel even after sending PROT P.
+        # Printer side: TLS if PROT P, cleartext if PROT C.
+        client_ssl = self._client_ssl_context if use_tls else None
+        printer_mode = "TLS" if use_tls else "cleartext"
+
+        async def handle_data(
+            client_reader: asyncio.StreamReader,
+            client_writer: asyncio.StreamWriter,
+        ) -> None:
+            """Handle one FTP data connection, then close the server."""
+            peername = client_writer.get_extra_info("peername")
+            data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+            logger.info(
+                "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
+                port,
+                printer_mode,
+                data_client,
+                printer_ip,
+                printer_port,
+            )
+            connected.set()
+            # One-shot: close server after accepting first connection
+            if server_holder:
+                server_holder[0].close()
+
+            printer_writer = None
+            try:
+                # Connect to printer's data port
+                printer_reader, printer_writer = await asyncio.wait_for(
+                    asyncio.open_connection(
+                        printer_ip,
+                        printer_port,
+                        ssl=client_ssl,
+                    ),
+                    timeout=10.0,
+                )
+                logger.info(
+                    "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
+                    port,
+                    printer_mode,
+                    printer_ip,
+                    printer_port,
+                )
+
+                # Bidirectional data forwarding
+                c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
+                p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
+
+                done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
+                for task in pending:
+                    task.cancel()
+                    try:
+                        await task
+                    except asyncio.CancelledError:
+                        pass  # Expected when other data direction closes
+            except TimeoutError:
+                logger.error("FTP data proxy port %s: timeout connecting to printer", port)
+            except ssl.SSLError as e:
+                logger.error("FTP data proxy port %s: SSL error to printer: %s", port, e)
+            except Exception as e:
+                logger.error("FTP data proxy port %s: error: %s", port, e)
+            finally:
+                for w in [client_writer, printer_writer]:
+                    if w:
+                        try:
+                            w.close()
+                            await w.wait_closed()
+                        except OSError:
+                            pass  # Best-effort data connection cleanup
+                logger.info("FTP data proxy port %s: connection closed", port)
+
+        server = await asyncio.start_server(
+            handle_data,
+            "0.0.0.0",  # nosec B104
+            port,
+            # No TLS on slicer side — Bambu Studio doesn't do TLS on data
+            # channel even after PROT P. The proxy terminates TLS only on
+            # the printer side (inside handle_data).
+        )
+        server_holder.append(server)
+        self._data_servers.append(server)
+
+        # Auto-close after 60s if no connection arrives
+        async def auto_close() -> None:
+            try:
+                await asyncio.wait_for(connected.wait(), timeout=60.0)
+            except TimeoutError:
+                logger.debug("FTP data proxy on port %s timed out, closing", port)
+                try:
+                    server.close()
+                    await server.wait_closed()
+                except OSError:
+                    pass  # Best-effort timeout cleanup
+            finally:
+                if server in self._data_servers:
+                    self._data_servers.remove(server)
+
+        asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
+
+        logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
+
+
 class SlicerProxyManager:
 class SlicerProxyManager:
     """Manages FTP and MQTT TLS proxies for a single printer target."""
     """Manages FTP and MQTT TLS proxies for a single printer target."""
 
 
@@ -324,10 +877,24 @@ class SlicerProxyManager:
         """Start FTP and MQTT TLS proxies."""
         """Start FTP and MQTT TLS proxies."""
         logger.info("Starting slicer TLS proxy to %s", self.target_host)
         logger.info("Starting slicer TLS proxy to %s", self.target_host)
 
 
-        # Create proxies with TLS
-        self._ftp_proxy = TLSProxy(
+        # Detect iptables port redirect (e.g. 990→9990 for non-root installs).
+        # If active, connections to port 990 get intercepted by iptables PREROUTING
+        # and sent to the redirect target — our socket on 990 never sees them.
+        ftp_listen_port = self.LOCAL_FTP_PORT
+        redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
+        if redirect_target:
+            logger.info(
+                "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
+                self.LOCAL_FTP_PORT,
+                redirect_target,
+                redirect_target,
+            )
+            ftp_listen_port = redirect_target
+
+        # Create FTP proxy with PASV/EPSV awareness for data connections
+        self._ftp_proxy = FTPTLSProxy(
             name="FTP",
             name="FTP",
-            listen_port=self.LOCAL_FTP_PORT,
+            listen_port=ftp_listen_port,
             target_host=self.target_host,
             target_host=self.target_host,
             target_port=self.PRINTER_FTP_PORT,
             target_port=self.PRINTER_FTP_PORT,
             server_cert_path=self.cert_path,
             server_cert_path=self.cert_path,

+ 5 - 1
backend/app/utils/printer_models.py

@@ -17,6 +17,8 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab A1 mini": "A1 Mini",
     "Bambu Lab A1 mini": "A1 Mini",
     "Bambu Lab H2D": "H2D",
     "Bambu Lab H2D": "H2D",
     "Bambu Lab H2D Pro": "H2D Pro",
     "Bambu Lab H2D Pro": "H2D Pro",
+    "Bambu Lab H2C": "H2C",
+    "Bambu Lab H2S": "H2S",
 }
 }
 
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -37,10 +39,12 @@ PRINTER_MODEL_ID_MAP = {
     "N1": "A1",
     "N1": "A1",
     "N2S": "A1 Mini",
     "N2S": "A1 Mini",
     "A04": "A1 Mini",
     "A04": "A1 Mini",
-    # H2D series (Office/H series)
+    # H2 series (Office/H series)
     "O1D": "H2D",
     "O1D": "H2D",
     "O1E": "H2D Pro",  # Some devices report O1E
     "O1E": "H2D Pro",  # Some devices report O1E
     "O2D": "H2D Pro",  # Some devices report O2D
     "O2D": "H2D Pro",  # Some devices report O2D
+    "O1C": "H2C",
+    "O1S": "H2S",
 }
 }
 
 
 
 

+ 625 - 0
backend/tests/integration/test_advanced_auth_api.py

@@ -0,0 +1,625 @@
+"""Integration tests for Advanced Authentication API endpoints.
+
+Tests the full request/response cycle for SMTP configuration, advanced auth toggle,
+email-based login, forgot password, admin password reset, and user creation
+with advanced authentication enabled.
+"""
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+# Shared SMTP settings data used across test classes
+SMTP_DATA = {
+    "smtp_host": "smtp.test.com",
+    "smtp_port": 587,
+    "smtp_username": "test@test.com",
+    "smtp_password": "testpass",
+    "smtp_security": "starttls",
+    "smtp_auth_enabled": True,
+    "smtp_from_email": "noreply@test.com",
+}
+
+
+async def _setup_admin(async_client: AsyncClient, username: str = "admin", password: str = "adminpass123"):
+    """Enable auth and create admin user, return admin token."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": password,
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": password},
+    )
+    return login.json()["access_token"]
+
+
+async def _setup_smtp_and_advanced_auth(async_client: AsyncClient, token: str):
+    """Configure SMTP and enable advanced auth. Must mock send_email externally."""
+    headers = {"Authorization": f"Bearer {token}"}
+    await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+    await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+
+async def _create_regular_user(
+    async_client: AsyncClient, token: str, username: str = "regular", password: str = "regularpass123"
+):
+    """Create a regular (non-admin) user and return their token."""
+    headers = {"Authorization": f"Bearer {token}"}
+    await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": username, "password": password, "role": "user"},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": password},
+    )
+    return login.json()["access_token"]
+
+
+class TestSMTPConfigAPI:
+    """Integration tests for SMTP configuration endpoints."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "smtpadmin", "adminpass123")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_smtp_settings(self, async_client: AsyncClient, admin_token: str):
+        """POST /auth/smtp with valid settings returns 200."""
+        response = await async_client.post(
+            "/api/v1/auth/smtp",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json=SMTP_DATA,
+        )
+        assert response.status_code == 200
+        assert "saved" in response.json()["message"].lower() or "success" in response.json()["message"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_smtp_settings_masks_password(self, async_client: AsyncClient, admin_token: str):
+        """GET /auth/smtp returns settings with password masked (None)."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        # Save settings first
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+
+        response = await async_client.get("/api/v1/auth/smtp", headers=headers)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["smtp_host"] == "smtp.test.com"
+        assert result["smtp_password"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):
+        """Non-admin user gets 403 on SMTP endpoints."""
+        user_token = await _create_regular_user(async_client, admin_token, "smtpregular", "pass123456")
+        headers = {"Authorization": f"Bearer {user_token}"}
+
+        response = await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        assert response.status_code == 403
+
+        response = await async_client.get("/api/v1/auth/smtp", headers=headers)
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_smtp_settings_no_auth(self, async_client: AsyncClient, admin_token: str):
+        """No token on SMTP save returns 401."""
+        response = await async_client.post("/api/v1/auth/smtp", json=SMTP_DATA)
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @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."""
+        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",
+                },
+            )
+        assert response.status_code == 200
+        assert response.json()["success"] is True
+
+
+class TestAdvancedAuthToggleAPI:
+    """Integration tests for enabling/disabling advanced authentication."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "toggleadmin", "adminpass123")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_advanced_auth(self, async_client: AsyncClient, admin_token: str):
+        """Enable advanced auth after SMTP is configured returns 200."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        # Configure SMTP first
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+
+        response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+        assert response.status_code == 200
+        assert response.json()["advanced_auth_enabled"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_advanced_auth_without_smtp(self, async_client: AsyncClient, admin_token: str):
+        """Enable advanced auth without SMTP configured returns 400."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+        assert response.status_code == 400
+        assert "SMTP" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_advanced_auth(self, async_client: AsyncClient, admin_token: str):
+        """Disable advanced auth returns 200."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        # Enable first
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        response = await async_client.post("/api/v1/auth/advanced-auth/disable", headers=headers)
+        assert response.status_code == 200
+        assert response.json()["advanced_auth_enabled"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_advanced_auth_status_public(self, async_client: AsyncClient, admin_token: str):
+        """GET /auth/advanced-auth/status is accessible without token."""
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        result = response.json()
+        assert "advanced_auth_enabled" in result
+        assert "smtp_configured" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):
+        """Non-admin user gets 403 on enable/disable."""
+        user_token = await _create_regular_user(async_client, admin_token, "toggleregular", "pass123456")
+        headers = {"Authorization": f"Bearer {user_token}"}
+
+        response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+        assert response.status_code == 403
+
+        response = await async_client.post("/api/v1/auth/advanced-auth/disable", headers=headers)
+        assert response.status_code == 403
+
+
+class TestEmailLoginAPI:
+    """Integration tests for email-based login."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "emailadmin", "adminpass123")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_with_email(self, async_client: AsyncClient, admin_token: str):
+        """Login with email address when advanced auth is enabled returns token."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        with patch("backend.app.api.routes.users.send_email"):
+            # Configure SMTP + advanced auth
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+            # Create user with email (password auto-generated, so we set one explicitly via update)
+            create_resp = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "emailuser", "email": "emailuser@test.com", "role": "user"},
+            )
+            assert create_resp.status_code == 201
+            user_id = create_resp.json()["id"]
+
+            # Set a known password via admin update
+            await async_client.patch(
+                f"/api/v1/users/{user_id}",
+                headers=headers,
+                json={"password": "knownpassword123"},
+            )
+
+        # Login with email
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "emailuser@test.com", "password": "knownpassword123"},
+        )
+        assert response.status_code == 200
+        assert "access_token" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_with_email_case_insensitive(self, async_client: AsyncClient, admin_token: str):
+        """Login with uppercase email matches case-insensitively."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        with patch("backend.app.api.routes.users.send_email"):
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+            create_resp = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "caseuser", "email": "caseuser@test.com", "role": "user"},
+            )
+            user_id = create_resp.json()["id"]
+            await async_client.patch(
+                f"/api/v1/users/{user_id}",
+                headers=headers,
+                json={"password": "casepassword123"},
+            )
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "CASEUSER@TEST.COM", "password": "casepassword123"},
+        )
+        assert response.status_code == 200
+        assert "access_token" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_with_email_advanced_auth_disabled(self, async_client: AsyncClient, admin_token: str):
+        """Email login fails when advanced auth is disabled."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        # Create user with email but no advanced auth
+        await async_client.post(
+            "/api/v1/users/",
+            headers=headers,
+            json={"username": "noemail", "password": "noEmailPass1", "email": "noemail@test.com", "role": "user"},
+        )
+
+        # Try to login with email — should fail since advanced auth is off
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "noemail@test.com", "password": "noEmailPass1"},
+        )
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_with_username_still_works(self, async_client: AsyncClient, admin_token: str):
+        """Username-based login still works when advanced auth is enabled."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        with patch("backend.app.api.routes.users.send_email"):
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+            create_resp = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "usernameuser", "email": "usernameuser@test.com", "role": "user"},
+            )
+            user_id = create_resp.json()["id"]
+            await async_client.patch(
+                f"/api/v1/users/{user_id}",
+                headers=headers,
+                json={"password": "usernamepass123"},
+            )
+
+        # Login with username (not email)
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "usernameuser", "password": "usernamepass123"},
+        )
+        assert response.status_code == 200
+        assert "access_token" in response.json()
+
+
+class TestForgotPasswordAPI:
+    """Integration tests for forgot-password flow."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "forgotadmin", "adminpass123")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_sends_email(self, async_client: AsyncClient, admin_token: str):
+        """POST /auth/forgot-password with valid email sends reset email."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        with patch("backend.app.api.routes.users.send_email"):
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+            # Create a user with email
+            create_resp = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "forgotuser", "email": "forgot@test.com", "role": "user"},
+            )
+            assert create_resp.status_code == 201
+
+        with patch("backend.app.api.routes.auth.send_email") as mock_send:
+            response = await async_client.post(
+                "/api/v1/auth/forgot-password",
+                json={"email": "forgot@test.com"},
+            )
+
+        assert response.status_code == 200
+        mock_send.assert_called_once()
+        # Verify the email was sent to the right address
+        assert mock_send.call_args[0][1] == "forgot@test.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_unknown_email(self, async_client: AsyncClient, admin_token: str):
+        """Unknown email still returns 200 (anti-enumeration) but send_email not called."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        with patch("backend.app.api.routes.auth.send_email") as mock_send:
+            response = await async_client.post(
+                "/api/v1/auth/forgot-password",
+                json={"email": "unknown@test.com"},
+            )
+
+        assert response.status_code == 200
+        mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_requires_advanced_auth(self, async_client: AsyncClient, admin_token: str):
+        """Forgot password returns 400 when advanced auth is disabled."""
+        response = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "test@test.com"},
+        )
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_changes_password(self, async_client: AsyncClient, admin_token: str):
+        """After forgot-password, old password stops working."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        with patch("backend.app.api.routes.users.send_email"):
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+            create_resp = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "resetme", "email": "resetme@test.com", "role": "user"},
+            )
+            user_id = create_resp.json()["id"]
+            await async_client.patch(
+                f"/api/v1/users/{user_id}",
+                headers=headers,
+                json={"password": "originalpass123"},
+            )
+
+        # Verify login works with original password
+        login_resp = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "resetme", "password": "originalpass123"},
+        )
+        assert login_resp.status_code == 200
+
+        # Trigger forgot password
+        with patch("backend.app.api.routes.auth.send_email"):
+            await async_client.post(
+                "/api/v1/auth/forgot-password",
+                json={"email": "resetme@test.com"},
+            )
+
+        # Old password should no longer work
+        login_resp = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "resetme", "password": "originalpass123"},
+        )
+        assert login_resp.status_code == 401
+
+
+class TestAdminResetPasswordAPI:
+    """Integration tests for admin password reset endpoint."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "resetadmin", "adminpass123")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_password_sends_email(self, async_client: AsyncClient, admin_token: str):
+        """POST /auth/reset-password sends email to user."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        with patch("backend.app.api.routes.users.send_email"):
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+            create_resp = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "resetuser", "email": "resetuser@test.com", "role": "user"},
+            )
+            user_id = create_resp.json()["id"]
+
+        with patch("backend.app.api.routes.auth.send_email") as mock_send:
+            response = await async_client.post(
+                "/api/v1/auth/reset-password",
+                headers=headers,
+                json={"user_id": user_id},
+            )
+
+        assert response.status_code == 200
+        mock_send.assert_called_once()
+        assert mock_send.call_args[0][1] == "resetuser@test.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):
+        """Non-admin user gets 403 on reset-password."""
+        # Create regular user before enabling advanced auth (no email required)
+        user_token = await _create_regular_user(async_client, admin_token, "resetregular", "pass123456")
+
+        with patch("backend.app.api.routes.users.send_email"):
+            await _setup_smtp_and_advanced_auth(async_client, admin_token)
+
+        response = await async_client.post(
+            "/api/v1/auth/reset-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"user_id": 1},
+        )
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_password_requires_advanced_auth(self, async_client: AsyncClient, admin_token: str):
+        """Reset password returns 400 when advanced auth is disabled."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        response = await async_client.post(
+            "/api/v1/auth/reset-password",
+            headers=headers,
+            json={"user_id": 999},
+        )
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_password_user_not_found(self, async_client: AsyncClient, admin_token: str):
+        """Reset password with invalid user_id returns 404."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        response = await async_client.post(
+            "/api/v1/auth/reset-password",
+            headers=headers,
+            json={"user_id": 99999},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_password_user_no_email(self, async_client: AsyncClient, admin_token: str):
+        """Reset password for user without email returns 400."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        # Save SMTP and enable advanced auth
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        # Disable advanced auth temporarily to create a user without email
+        await async_client.post("/api/v1/auth/advanced-auth/disable", headers=headers)
+        create_resp = await async_client.post(
+            "/api/v1/users/",
+            headers=headers,
+            json={"username": "noemailuser", "password": "noemail123456", "role": "user"},
+        )
+        user_id = create_resp.json()["id"]
+
+        # Re-enable advanced auth
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        response = await async_client.post(
+            "/api/v1/auth/reset-password",
+            headers=headers,
+            json={"user_id": user_id},
+        )
+        assert response.status_code == 400
+        assert "email" in response.json()["detail"].lower()
+
+
+class TestUserCreationAdvancedAuth:
+    """Integration tests for user creation with advanced auth enabled."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "createadmin", "adminpass123")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_advanced_auth_requires_email(self, async_client: AsyncClient, admin_token: str):
+        """Creating user without email when advanced auth is on returns 400."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers=headers,
+            json={"username": "noemailcreate", "role": "user"},
+        )
+        assert response.status_code == 400
+        assert "email" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_advanced_auth_auto_password(self, async_client: AsyncClient, admin_token: str):
+        """Creating user with email auto-generates password and sends welcome email."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        with patch("backend.app.api.routes.users.send_email") as mock_send:
+            response = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "autopassuser", "email": "autopass@test.com", "role": "user"},
+            )
+
+        assert response.status_code == 201
+        result = response.json()
+        assert result["username"] == "autopassuser"
+        assert result["email"] == "autopass@test.com"
+        # Welcome email should have been sent
+        mock_send.assert_called_once()
+        assert mock_send.call_args[0][1] == "autopass@test.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_duplicate_email(self, async_client: AsyncClient, admin_token: str):
+        """Creating two users with the same email returns 400."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        with patch("backend.app.api.routes.users.send_email"):
+            resp1 = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "dupemail1", "email": "dupe@test.com", "role": "user"},
+            )
+            assert resp1.status_code == 201
+
+            resp2 = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "dupemail2", "email": "dupe@test.com", "role": "user"},
+            )
+
+        assert resp2.status_code == 400
+        assert "email" in resp2.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_response_includes_email(self, async_client: AsyncClient, admin_token: str):
+        """Created user response includes email field."""
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        with patch("backend.app.api.routes.users.send_email"):
+            response = await async_client.post(
+                "/api/v1/users/",
+                headers=headers,
+                json={"username": "emailresp", "email": "emailresp@test.com", "role": "user"},
+            )
+
+        assert response.status_code == 201
+        result = response.json()
+        assert "email" in result
+        assert result["email"] == "emailresp@test.com"

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

@@ -774,3 +774,30 @@ class TestAuthMiddlewarePublicRoutes:
             headers={"Authorization": f"Bearer {token}"},
             headers={"Authorization": f"Bearer {token}"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_advanced_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/advanced-auth/status is accessible without auth."""
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        # Should not be 401 (must be accessible for login page)
+        assert response.status_code != 401
+        # Should return valid response (200 with auth status)
+        if response.status_code == 200:
+            result = response.json()
+            assert "advanced_auth_enabled" in result
+            assert "smtp_configured" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/forgot-password is accessible without auth."""
+        response = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "test@example.com"},
+        )
+        # Should not be 401 (must be accessible for password reset from login page)
+        assert response.status_code != 401
+        # Will likely be 400 (advanced auth not enabled) but that's okay -
+        # the important thing is it's not blocked by auth middleware
+        assert response.status_code in [200, 400]

+ 26 - 0
backend/tests/integration/test_discovery_api.py

@@ -25,9 +25,24 @@ class TestDiscoveryAPI:
         assert "is_docker" in data
         assert "is_docker" in data
         assert "ssdp_running" in data
         assert "ssdp_running" in data
         assert "scan_running" in data
         assert "scan_running" in data
+        assert "subnets" in data
         assert isinstance(data["is_docker"], bool)
         assert isinstance(data["is_docker"], bool)
         assert isinstance(data["ssdp_running"], bool)
         assert isinstance(data["ssdp_running"], bool)
         assert isinstance(data["scan_running"], bool)
         assert isinstance(data["scan_running"], bool)
+        assert isinstance(data["subnets"], list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_discovery_info_subnets_are_cidr(self, async_client: AsyncClient):
+        """Verify subnets are valid CIDR notation strings."""
+        response = await async_client.get("/api/v1/discovery/info")
+
+        assert response.status_code == 200
+        data = response.json()
+        for subnet in data["subnets"]:
+            assert isinstance(subnet, str)
+            # Should contain a slash for CIDR notation
+            assert "/" in subnet, f"Subnet {subnet} is not in CIDR notation"
 
 
     # ========================================================================
     # ========================================================================
     # SSDP Discovery endpoints
     # SSDP Discovery endpoints
@@ -140,3 +155,14 @@ class TestDiscoveryService:
         assert response1.status_code == 200
         assert response1.status_code == 200
         assert response2.status_code == 200
         assert response2.status_code == 200
         assert response1.json()["is_docker"] == response2.json()["is_docker"]
         assert response1.json()["is_docker"] == response2.json()["is_docker"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_subnets_consistent_across_calls(self, async_client: AsyncClient):
+        """Verify subnet detection returns consistent results."""
+        response1 = await async_client.get("/api/v1/discovery/info")
+        response2 = await async_client.get("/api/v1/discovery/info")
+
+        assert response1.status_code == 200
+        assert response2.status_code == 200
+        assert response1.json()["subnets"] == response2.json()["subnets"]

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

@@ -63,6 +63,58 @@ class TestPrintersAPI:
         assert result["serial_number"] == "00M09A111111111"
         assert result["serial_number"] == "00M09A111111111"
         assert result["model"] == "X1C"
         assert result["model"] == "X1C"
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_with_hostname(self, async_client: AsyncClient):
+        """Verify printer can be created with a hostname instead of IP address."""
+        data = {
+            "name": "DNS Printer",
+            "serial_number": "00M09A555555555",
+            "ip_address": "printer.local",
+            "access_code": "12345678",
+            "model": "P1S",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "DNS Printer"
+        assert result["ip_address"] == "printer.local"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
+        """Verify printer can be created with a fully qualified domain name."""
+        data = {
+            "name": "FQDN Printer",
+            "serial_number": "00M09A666666666",
+            "ip_address": "my-printer.home.lan",
+            "access_code": "12345678",
+            "model": "X1C",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ip_address"] == "my-printer.home.lan"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
+        """Verify invalid hostnames are rejected."""
+        data = {
+            "name": "Bad Printer",
+            "serial_number": "00M09A777777777",
+            "ip_address": "-invalid",
+            "access_code": "12345678",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 422
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
     async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):

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

@@ -3,6 +3,8 @@
 Tests the full request/response cycle for /api/v1/settings/ endpoints.
 Tests the full request/response cycle for /api/v1/settings/ endpoints.
 """
 """
 
 
+import os
+
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
@@ -393,6 +395,252 @@ class TestSettingsAPI:
         # Default is False as defined in schema
         # Default is False as defined in schema
         assert isinstance(result["per_printer_mapping_expanded"], bool)
         assert isinstance(result["per_printer_mapping_expanded"], bool)
 
 
+    # ========================================================================
+    # Home Assistant environment variable tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_default_no_env_vars(self, async_client: AsyncClient):
+        """Verify HA settings work without environment variables (default behavior)."""
+        # Ensure no env vars are set
+        os.environ.pop("HA_URL", None)
+        os.environ.pop("HA_TOKEN", None)
+
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert response.status_code == 200
+        assert "ha_enabled" in result
+        assert "ha_url" in result
+        assert "ha_token" in result
+        assert "ha_url_from_env" in result
+        assert "ha_token_from_env" in result
+        assert "ha_env_managed" in result
+
+        # Default values without env vars
+        assert result["ha_url_from_env"] is False
+        assert result["ha_token_from_env"] is False
+        assert result["ha_env_managed"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient):
+        """Verify HA settings are overridden when both env vars are set."""
+        # Set environment variables
+        os.environ["HA_URL"] = "http://supervisor/core"
+        os.environ["HA_TOKEN"] = "test-token-12345"
+
+        try:
+            response = await async_client.get("/api/v1/settings/")
+            result = response.json()
+
+            assert response.status_code == 200
+
+            # Verify env var values are used
+            assert result["ha_url"] == "http://supervisor/core"
+            assert result["ha_token"] == "test-token-12345"
+
+            # Verify metadata fields
+            assert result["ha_url_from_env"] is True
+            assert result["ha_token_from_env"] is True
+            assert result["ha_env_managed"] is True
+
+            # Verify auto-enable behavior
+            assert result["ha_enabled"] is True
+
+        finally:
+            # Clean up
+            os.environ.pop("HA_URL", None)
+            os.environ.pop("HA_TOKEN", None)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_with_only_url_env_var(self, async_client: AsyncClient):
+        """Verify partial configuration when only HA_URL is set."""
+        # Set only URL env var
+        os.environ["HA_URL"] = "http://supervisor/core"
+        os.environ.pop("HA_TOKEN", None)
+
+        try:
+            response = await async_client.get("/api/v1/settings/")
+            result = response.json()
+
+            assert response.status_code == 200
+
+            # Verify URL is from env, token is from database
+            assert result["ha_url"] == "http://supervisor/core"
+            assert result["ha_url_from_env"] is True
+            assert result["ha_token_from_env"] is False
+            assert result["ha_env_managed"] is False
+
+            # No auto-enable with partial config
+            assert result["ha_enabled"] is False  # Database default
+
+        finally:
+            os.environ.pop("HA_URL", None)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient):
+        """Verify partial configuration when only HA_TOKEN is set."""
+        # Set only token env var
+        os.environ.pop("HA_URL", None)
+        os.environ["HA_TOKEN"] = "test-token-12345"
+
+        try:
+            response = await async_client.get("/api/v1/settings/")
+            result = response.json()
+
+            assert response.status_code == 200
+
+            # Verify token is from env, URL is from database
+            assert result["ha_token"] == "test-token-12345"
+            assert result["ha_url_from_env"] is False
+            assert result["ha_token_from_env"] is True
+            assert result["ha_env_managed"] is False
+
+            # No auto-enable with partial config
+            assert result["ha_enabled"] is False  # Database default
+
+        finally:
+            os.environ.pop("HA_TOKEN", None)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient):
+        """Verify environment variables take precedence over database values."""
+        # First, set database values
+        await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "ha_enabled": True,
+                "ha_url": "http://database-url:8123",
+                "ha_token": "database-token",
+            },
+        )
+
+        # Verify database values are set
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+        assert result["ha_url"] == "http://database-url:8123"
+        assert result["ha_token"] == "database-token"
+
+        # Now set environment variables
+        os.environ["HA_URL"] = "http://env-url/core"
+        os.environ["HA_TOKEN"] = "env-token-xyz"
+
+        try:
+            response = await async_client.get("/api/v1/settings/")
+            result = response.json()
+
+            # Verify env vars override database
+            assert result["ha_url"] == "http://env-url/core"
+            assert result["ha_token"] == "env-token-xyz"
+            assert result["ha_url_from_env"] is True
+            assert result["ha_token_from_env"] is True
+            assert result["ha_env_managed"] is True
+            assert result["ha_enabled"] is True
+
+        finally:
+            os.environ.pop("HA_URL", None)
+            os.environ.pop("HA_TOKEN", None)
+
+        # Verify database values are still there after removing env vars
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+        assert result["ha_url"] == "http://database-url:8123"
+        assert result["ha_token"] == "database-token"
+        assert result["ha_url_from_env"] is False
+        assert result["ha_token_from_env"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient):
+        """Verify database updates are accepted but have no effect when env vars are set."""
+        # Set environment variables
+        os.environ["HA_URL"] = "http://supervisor/core"
+        os.environ["HA_TOKEN"] = "env-token"
+
+        try:
+            # Attempt to update via API
+            response = await async_client.put(
+                "/api/v1/settings/",
+                json={
+                    "ha_url": "http://different-url:8123",
+                    "ha_token": "different-token",
+                },
+            )
+
+            # Update should succeed
+            assert response.status_code == 200
+
+            # But values should still be from env vars
+            result = response.json()
+            assert result["ha_url"] == "http://supervisor/core"
+            assert result["ha_token"] == "env-token"
+            assert result["ha_url_from_env"] is True
+            assert result["ha_token_from_env"] is True
+
+        finally:
+            os.environ.pop("HA_URL", None)
+            os.environ.pop("HA_TOKEN", None)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_empty_env_vars_treated_as_not_set(self, async_client: AsyncClient):
+        """Verify empty environment variables are treated as not set."""
+        # Set empty env vars
+        os.environ["HA_URL"] = ""
+        os.environ["HA_TOKEN"] = ""
+
+        try:
+            response = await async_client.get("/api/v1/settings/")
+            result = response.json()
+
+            # Empty env vars should be treated as not set
+            assert result["ha_url_from_env"] is False
+            assert result["ha_token_from_env"] is False
+            assert result["ha_env_managed"] is False
+
+        finally:
+            os.environ.pop("HA_URL", None)
+            os.environ.pop("HA_TOKEN", None)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient):
+        """Verify HA settings can be updated normally when env vars are not set."""
+        # Ensure no env vars
+        os.environ.pop("HA_URL", None)
+        os.environ.pop("HA_TOKEN", None)
+
+        # Update HA settings
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "ha_enabled": True,
+                "ha_url": "http://192.168.1.100:8123",
+                "ha_token": "my-long-lived-token",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ha_enabled"] is True
+        assert result["ha_url"] == "http://192.168.1.100:8123"
+        assert result["ha_token"] == "my-long-lived-token"
+        assert result["ha_url_from_env"] is False
+        assert result["ha_token_from_env"] is False
+        assert result["ha_env_managed"] is False
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+        assert result["ha_enabled"] is True
+        assert result["ha_url"] == "http://192.168.1.100:8123"
+        assert result["ha_token"] == "my-long-lived-token"
+
 
 
 class TestSimplifiedBackupRestore:
 class TestSimplifiedBackupRestore:
     """Integration tests for the simplified backup/restore endpoints (ZIP-based).
     """Integration tests for the simplified backup/restore endpoints (ZIP-based).

+ 70 - 2
backend/tests/integration/test_spoolman_api.py

@@ -280,7 +280,7 @@ class TestSpoolmanAPI:
             "id": 42,
             "id": 42,
             "remaining_weight": 800,
             "remaining_weight": 800,
             "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
             "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
-            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "weight": 1000},
         }
         }
         mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
         mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
 
 
@@ -291,7 +291,10 @@ class TestSpoolmanAPI:
         assert isinstance(data["linked"], dict)
         assert isinstance(data["linked"], dict)
         # Tag should be uppercase and stripped of quotes
         # Tag should be uppercase and stripped of quotes
         assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"]
         assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"]
-        assert data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"] == 42
+        linked_info = data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"]
+        assert linked_info["id"] == 42
+        assert linked_info["remaining_weight"] == 800
+        assert linked_info["filament_weight"] == 1000
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -337,6 +340,71 @@ class TestSpoolmanAPI:
         data = response.json()
         data = response.json()
         assert len(data["linked"]) == 0  # Empty tag should be excluded
         assert len(data["linked"]) == 0  # Empty tag should be excluded
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_includes_weight_data(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools response includes remaining_weight and filament_weight."""
+        mock_spool = {
+            "id": 10,
+            "remaining_weight": 500.5,
+            "extra": {"tag": '"AABB11223344556677889900AABBCCDD"'},
+            "filament": {"id": 1, "name": "PETG Blue", "material": "PETG", "weight": 750},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        info = data["linked"]["AABB11223344556677889900AABBCCDD"]
+        assert info["id"] == 10
+        assert info["remaining_weight"] == 500.5
+        assert info["filament_weight"] == 750
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_missing_weight_fields(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools handles missing weight data gracefully."""
+        mock_spool = {
+            "id": 5,
+            "extra": {"tag": '"CCDD11223344556677889900AABBCCDD"'},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        info = data["linked"]["CCDD11223344556677889900AABBCCDD"]
+        assert info["id"] == 5
+        assert info["remaining_weight"] is None
+        assert info["filament_weight"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_null_filament(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools handles null filament object."""
+        mock_spool = {
+            "id": 7,
+            "remaining_weight": 300,
+            "extra": {"tag": '"EEFF11223344556677889900AABBCCDD"'},
+            "filament": None,
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        info = data["linked"]["EEFF11223344556677889900AABBCCDD"]
+        assert info["id"] == 7
+        assert info["remaining_weight"] == 300
+        assert info["filament_weight"] is None
+
     # =========================================================================
     # =========================================================================
     # Link Spool Tests
     # Link Spool Tests
     # =========================================================================
     # =========================================================================

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

@@ -0,0 +1,99 @@
+"""Test fixtures for FTP service tests.
+
+Provides a real implicit FTPS server (via mock_ftp_server) and client factory
+for integration-style testing of BambuFTPClient against a live server.
+"""
+
+import socket
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services.bambu_ftp import BambuFTPClient
+from backend.app.services.virtual_printer.certificate import CertificateService
+from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
+
+
+@pytest.fixture(scope="session")
+def ftp_certs(tmp_path_factory):
+    """Generate self-signed TLS certificates once per test session."""
+    cert_dir = tmp_path_factory.mktemp("ftp_certs")
+    svc = CertificateService(cert_dir, serial="TEST_FTP_SERVER")
+    cert_path, key_path = svc.generate_certificates()
+    return str(cert_path), str(key_path)
+
+
+def _find_free_port() -> int:
+    """Find a free TCP port on localhost."""
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", 0))
+        return s.getsockname()[1]
+
+
+@pytest.fixture()
+def ftp_root(tmp_path):
+    """Create temp directory with standard Bambu printer directory structure."""
+    for d in ("cache", "timelapse", "model", "data", "data/Metadata"):
+        (tmp_path / d).mkdir(parents=True, exist_ok=True)
+    return tmp_path
+
+
+@pytest.fixture()
+def ftp_server(ftp_certs, ftp_root):
+    """Start a mock implicit FTPS server, yield it, stop on cleanup."""
+    cert_path, key_path = ftp_certs
+    port = _find_free_port()
+    server = MockBambuFTPServer(
+        host="127.0.0.1",
+        port=port,
+        root_dir=str(ftp_root),
+        cert_path=cert_path,
+        key_path=key_path,
+        access_code="12345678",
+    )
+    server.start()
+    yield server
+    server.stop()
+
+
+@pytest.fixture()
+def ftp_client_factory(ftp_server):
+    """Factory that creates BambuFTPClient instances pointed at the mock server."""
+
+    def _make_client(
+        printer_model: str = "X1C",
+        force_prot_c: bool = False,
+        access_code: str = "12345678",
+        timeout: float = 10.0,
+    ) -> BambuFTPClient:
+        client = BambuFTPClient(
+            ip_address="127.0.0.1",
+            access_code=access_code,
+            timeout=timeout,
+            printer_model=printer_model,
+            force_prot_c=force_prot_c,
+        )
+        # Override port to point at mock server
+        client.FTP_PORT = ftp_server.port
+        return client
+
+    return _make_client
+
+
+@pytest.fixture(autouse=True)
+def clear_ftp_mode_cache():
+    """Clear BambuFTPClient mode cache before and after each test."""
+    BambuFTPClient._mode_cache.clear()
+    yield
+    BambuFTPClient._mode_cache.clear()
+
+
+@pytest.fixture()
+def patch_ftp_port(ftp_server):
+    """Patch FTP_PORT at class level for async wrapper tests.
+
+    Async wrappers create their own BambuFTPClient instances internally,
+    so we need to patch the class-level default port.
+    """
+    with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
+        yield ftp_server

+ 240 - 0
backend/tests/unit/services/mock_ftp_server.py

@@ -0,0 +1,240 @@
+"""Mock implicit FTPS server for testing BambuFTPClient.
+
+Built on pyftpdlib with implicit TLS support to match Bambu printer behavior.
+Supports failure injection, custom AVBL command, and filesystem inspection.
+"""
+
+import logging
+import os
+import threading
+import time
+
+from pyftpdlib.authorizers import DummyAuthorizer
+from pyftpdlib.handlers import TLS_FTPHandler
+from pyftpdlib.servers import FTPServer
+
+
+class ImplicitTLS_FTPHandler(TLS_FTPHandler):
+    """FTP handler that wraps the socket in TLS before sending the 220 banner.
+
+    This implements implicit FTPS (port 990 style) where the TLS handshake
+    happens immediately on connect, before any FTP protocol exchange.
+    pyftpdlib only natively supports explicit FTPS (AUTH TLS after connect).
+    """
+
+    # Per-class failure injection map: command -> (code, message, remaining_count)
+    # -1 remaining_count = permanent failure
+    _failure_map: dict = {}
+
+    # AVBL command response (bytes available)
+    _avbl_bytes: int = 1073741824  # 1 GB default
+
+    # Register AVBL as a recognized FTP command (pyftpdlib requires this)
+    proto_cmds = {
+        **TLS_FTPHandler.proto_cmds,
+        "AVBL": {
+            "perm": None,
+            "auth": True,
+            "arg": None,
+            "help": "Syntax: AVBL (get available bytes).",
+        },
+    }
+
+    def handle(self):
+        """Wrap socket in TLS immediately, then send 220 banner."""
+        self.secure_connection(self.get_ssl_context())
+        super().handle()
+
+    def ftp_PROT(self, line):
+        """Override PROT to auto-set _pbsz for implicit FTPS.
+
+        In implicit FTPS the connection is already TLS-secured, so requiring
+        a separate PBSZ command is unnecessary. Python's ftplib prot_c()
+        doesn't send PBSZ first (unlike prot_p()), causing 503 errors.
+        Real Bambu printers don't enforce this for implicit FTPS either.
+        """
+        self._pbsz = True
+        return super().ftp_PROT(line)
+
+    def _check_failure(self, command: str, line: str):
+        """Check if a failure is injected for this command.
+
+        Returns True if a failure response was sent, False otherwise.
+        """
+        if command in self._failure_map:
+            code, message, remaining = self._failure_map[command]
+            if remaining != 0:
+                if remaining > 0:
+                    self._failure_map[command] = (code, message, remaining - 1)
+                    if remaining - 1 == 0:
+                        del self._failure_map[command]
+                self.respond(f"{code} {message}")
+                return True
+        return False
+
+    def ftp_AVBL(self, line):
+        """Handle custom AVBL command (available bytes on storage)."""
+        self.respond(f"213 {self._avbl_bytes}")
+
+    def ftp_RETR(self, file):
+        if self._check_failure("RETR", file):
+            return
+        return super().ftp_RETR(file)
+
+    def ftp_STOR(self, file):
+        if self._check_failure("STOR", file):
+            return
+        return super().ftp_STOR(file)
+
+    def ftp_DELE(self, line):
+        if self._check_failure("DELE", line):
+            return
+        return super().ftp_DELE(line)
+
+    def ftp_CWD(self, path):
+        if self._check_failure("CWD", path):
+            return
+        return super().ftp_CWD(path)
+
+    def ftp_LIST(self, path=""):
+        if self._check_failure("LIST", path):
+            return
+        return super().ftp_LIST(path)
+
+    def ftp_SIZE(self, path):
+        if self._check_failure("SIZE", path):
+            return
+        # Override to allow SIZE in ASCII mode (real Bambu printers allow it,
+        # and BambuFTPClient.get_file_size() doesn't set TYPE I first)
+        if not self.fs.isfile(self.fs.realpath(path)):
+            self.respond(f"550 {self.fs.fs2ftp(path)} is not retrievable.")
+            return
+        try:
+            size = self.run_as_current_user(self.fs.getsize, path)
+        except OSError as err:
+            self.respond(f"550 {err}.")
+        else:
+            self.respond(f"213 {size}")
+
+    def ftp_PASS(self, line):
+        if self._check_failure("PASS", line):
+            return
+        return super().ftp_PASS(line)
+
+
+class MockBambuFTPServer:
+    """Manages a mock implicit FTPS server in a background thread.
+
+    Simulates a Bambu printer FTP server with:
+    - Implicit TLS (like real printers on port 990)
+    - Standard Bambu directory structure
+    - AVBL command support
+    - Per-command failure injection for testing error paths
+    """
+
+    def __init__(
+        self,
+        host: str,
+        port: int,
+        root_dir: str,
+        cert_path: str,
+        key_path: str,
+        access_code: str = "12345678",
+    ):
+        self.host = host
+        self.port = port
+        self.root_dir = root_dir
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self.access_code = access_code
+        self._server: FTPServer | None = None
+        self._thread: threading.Thread | None = None
+        # Create a unique handler class per instance so _failure_map is isolated
+        self._handler_class = type(
+            "TestFTPHandler",
+            (ImplicitTLS_FTPHandler,),
+            {
+                "_failure_map": {},
+                "_avbl_bytes": 1073741824,
+            },
+        )
+
+    def start(self):
+        """Start the FTP server in a background daemon thread."""
+        authorizer = DummyAuthorizer()
+        authorizer.add_user("bblp", self.access_code, self.root_dir, perm="elradfmwMT")
+
+        handler = self._handler_class
+        handler.authorizer = authorizer
+        handler.certfile = self.cert_path
+        handler.keyfile = self.key_path
+        handler.passive_ports = range(60000, 60101)
+        handler.tls_control_required = False
+        handler.tls_data_required = False
+        # Reset ssl_context so it picks up our cert/key
+        handler.ssl_context = None
+
+        # Suppress pyftpdlib's noisy logging (startup/shutdown banners)
+        # to avoid "I/O operation on closed file" errors when xdist
+        # workers tear down while the daemon thread is still logging.
+        logging.getLogger("pyftpdlib").setLevel(logging.CRITICAL)
+
+        self._server = FTPServer((self.host, self.port), handler)
+        self._server.max_cons = 10
+        self._server.max_cons_per_ip = 5
+
+        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
+        self._thread.start()
+        # Brief wait for server to be ready
+        time.sleep(0.1)
+
+    def stop(self):
+        """Stop the FTP server and wait for thread to exit."""
+        if self._server:
+            self._server.close_all()
+        if self._thread:
+            self._thread.join(timeout=5)
+        self._server = None
+        self._thread = None
+
+    def inject_failure(self, command: str, code: int, message: str, count: int = -1):
+        """Inject a failure response for a specific FTP command.
+
+        Args:
+            command: FTP command name (RETR, STOR, DELE, CWD, LIST, SIZE, PASS)
+            code: FTP response code (e.g. 550, 553)
+            message: Response message
+            count: Number of times to fail (-1 = permanent)
+        """
+        self._handler_class._failure_map[command] = (code, message, count)
+
+    def clear_failures(self):
+        """Remove all injected failures."""
+        self._handler_class._failure_map.clear()
+
+    def set_avbl_bytes(self, n: int):
+        """Set the response value for the AVBL command."""
+        self._handler_class._avbl_bytes = n
+
+    def add_file(self, relative_path: str, content: bytes = b""):
+        """Add a file to the server's filesystem."""
+        full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
+        os.makedirs(os.path.dirname(full_path), exist_ok=True)
+        with open(full_path, "wb") as f:
+            f.write(content)
+
+    def add_directory(self, relative_path: str):
+        """Create a directory in the server's filesystem."""
+        full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
+        os.makedirs(full_path, exist_ok=True)
+
+    def file_exists(self, relative_path: str) -> bool:
+        """Check if a file exists on the server."""
+        full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
+        return os.path.isfile(full_path)
+
+    def read_file(self, relative_path: str) -> bytes:
+        """Read file content from the server's filesystem."""
+        full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
+        with open(full_path, "rb") as f:
+            return f.read()

+ 864 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -0,0 +1,864 @@
+"""Comprehensive FTP test suite for BambuFTPClient.
+
+Tests against a real mock implicit FTPS server, covering:
+- Connection (auth, SSL modes, timeout, caching)
+- File listing
+- Download (bytes, to_file, 0-byte regression)
+- Upload (chunked transfer, progress, error codes)
+- Delete
+- File size
+- Storage info (AVBL, directory scan, diagnose_storage)
+- Model-specific behavior (X1C prot_p, A1 prot_c fallback)
+- Async wrappers
+- Failure injection scenarios (regressions for 0.1.8 bugs)
+"""
+
+import time
+from pathlib import Path
+
+import pytest
+
+from backend.app.services.bambu_ftp import (
+    BambuFTPClient,
+    delete_file_async,
+    download_file_async,
+    download_file_try_paths_async,
+    list_files_async,
+    upload_file_async,
+)
+
+# Brief delay to allow pyftpdlib to flush uploaded files to disk.
+# Needed because upload_file() skips voidresp() for A1 compatibility,
+# so the server may still be processing the data channel close event.
+_UPLOAD_FLUSH_DELAY = 0.3
+
+
+# ---------------------------------------------------------------------------
+# TestConnection
+# ---------------------------------------------------------------------------
+class TestConnection:
+    """Tests for FTP connect/disconnect behavior."""
+
+    def test_connect_success(self, ftp_client_factory):
+        """Successful implicit FTPS connection and login."""
+        client = ftp_client_factory()
+        assert client.connect() is True
+        client.disconnect()
+
+    def test_connect_wrong_access_code(self, ftp_client_factory):
+        """Wrong access code returns False."""
+        client = ftp_client_factory(access_code="wrongcode")
+        assert client.connect() is False
+
+    def test_connect_unreachable_host(self, ftp_server):
+        """Unreachable host returns False."""
+        client = BambuFTPClient(
+            ip_address="192.0.2.1",  # TEST-NET, guaranteed unreachable
+            access_code="12345678",
+            timeout=1.0,
+            printer_model="X1C",
+        )
+        client.FTP_PORT = ftp_server.port
+        assert client.connect() is False
+
+    def test_connect_timeout(self, ftp_server):
+        """Very short timeout triggers timeout error."""
+        client = BambuFTPClient(
+            ip_address="192.0.2.1",
+            access_code="12345678",
+            timeout=0.001,  # Extremely short
+            printer_model="X1C",
+        )
+        client.FTP_PORT = ftp_server.port
+        assert client.connect() is False
+
+    def test_disconnect_clean(self, ftp_client_factory):
+        """Clean disconnect after successful connect."""
+        client = ftp_client_factory()
+        client.connect()
+        client.disconnect()
+        assert client._ftp is None
+
+    def test_disconnect_without_connect(self, ftp_client_factory):
+        """Disconnect without connect does not raise."""
+        client = ftp_client_factory()
+        client.disconnect()  # Should not raise
+        assert client._ftp is None
+
+    def test_disconnect_after_server_gone(self, ftp_certs, ftp_root):
+        """Disconnect after server has stopped raises EOFError.
+
+        Note: The current disconnect() catches (OSError, ftplib.Error) but
+        EOFError is neither. This documents actual behavior — a future fix
+        could add EOFError to the except clause.
+        """
+        from backend.tests.unit.services.mock_ftp_server import (
+            MockBambuFTPServer,
+        )
+
+        from .conftest import _find_free_port
+
+        cert_path, key_path = ftp_certs
+        port = _find_free_port()
+        server = MockBambuFTPServer("127.0.0.1", port, str(ftp_root), cert_path, key_path)
+        server.start()
+
+        client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
+        client.FTP_PORT = port
+        client.connect()
+
+        server.stop()
+        with pytest.raises(EOFError):
+            client.disconnect()
+
+    def test_x1c_uses_prot_p(self, ftp_client_factory):
+        """X1C model connects with prot_p (protected data channel)."""
+        client = ftp_client_factory(printer_model="X1C")
+        assert client.connect() is True
+        assert client._should_use_prot_c() is False
+        client.disconnect()
+
+    def test_a1_defaults_prot_p(self, ftp_client_factory):
+        """A1 model defaults to prot_p when no cache exists."""
+        client = ftp_client_factory(printer_model="A1")
+        assert client._should_use_prot_c() is False
+        assert client.connect() is True
+        client.disconnect()
+
+    def test_a1_force_prot_c(self, ftp_client_factory):
+        """A1 model with force_prot_c uses clear data channel."""
+        client = ftp_client_factory(printer_model="A1", force_prot_c=True)
+        assert client._should_use_prot_c() is True
+        assert client.connect() is True
+        client.disconnect()
+
+    def test_cached_mode_respected(self, ftp_client_factory):
+        """Cached mode is used on subsequent connections."""
+        BambuFTPClient.cache_mode("127.0.0.1", "prot_c")
+        client = ftp_client_factory(printer_model="A1")
+        assert client._should_use_prot_c() is True
+        assert client.connect() is True
+        client.disconnect()
+
+
+# ---------------------------------------------------------------------------
+# TestListFiles
+# ---------------------------------------------------------------------------
+class TestListFiles:
+    """Tests for directory listing."""
+
+    def test_list_empty_directory(self, ftp_client_factory):
+        """Listing an empty directory returns empty list."""
+        client = ftp_client_factory()
+        client.connect()
+        files = client.list_files("/cache")
+        assert files == []
+        client.disconnect()
+
+    def test_list_directory_with_files(self, ftp_client_factory, ftp_server):
+        """Files in directory are listed correctly."""
+        ftp_server.add_file("cache/test.3mf", b"x" * 1024)
+        ftp_server.add_file("cache/test2.gcode", b"y" * 512)
+        client = ftp_client_factory()
+        client.connect()
+        files = client.list_files("/cache")
+        names = {f["name"] for f in files}
+        assert "test.3mf" in names
+        assert "test2.gcode" in names
+        client.disconnect()
+
+    def test_directories_marked(self, ftp_client_factory, ftp_server):
+        """Subdirectories are identified with is_directory=True."""
+        ftp_server.add_directory("model/subdir")
+        client = ftp_client_factory()
+        client.connect()
+        files = client.list_files("/model")
+        dirs = [f for f in files if f["is_directory"]]
+        assert len(dirs) >= 1
+        assert dirs[0]["name"] == "subdir"
+        client.disconnect()
+
+    def test_nonexistent_path_returns_empty(self, ftp_client_factory):
+        """Listing a nonexistent path returns empty list."""
+        client = ftp_client_factory()
+        client.connect()
+        files = client.list_files("/nonexistent/path")
+        assert files == []
+        client.disconnect()
+
+    def test_file_sizes_and_paths(self, ftp_client_factory, ftp_server):
+        """File sizes and full paths are parsed correctly."""
+        ftp_server.add_file("cache/sized.bin", b"a" * 2048)
+        client = ftp_client_factory()
+        client.connect()
+        files = client.list_files("/cache")
+        sized = [f for f in files if f["name"] == "sized.bin"]
+        assert len(sized) == 1
+        assert sized[0]["size"] == 2048
+        assert sized[0]["path"] == "/cache/sized.bin"
+        client.disconnect()
+
+
+# ---------------------------------------------------------------------------
+# TestDownload
+# ---------------------------------------------------------------------------
+class TestDownload:
+    """Tests for file download operations."""
+
+    def test_download_file_returns_bytes(self, ftp_client_factory, ftp_server):
+        """download_file() returns file content as bytes."""
+        content = b"Hello FTP World!"
+        ftp_server.add_file("cache/hello.txt", content)
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_file("/cache/hello.txt")
+        assert result == content
+        client.disconnect()
+
+    def test_download_file_missing(self, ftp_client_factory):
+        """download_file() returns None for missing file."""
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_file("/cache/does_not_exist.txt")
+        assert result is None
+        client.disconnect()
+
+    def test_download_to_file_writes_to_disk(self, ftp_client_factory, ftp_server, tmp_path):
+        """download_to_file() writes content to local filesystem."""
+        content = b"Downloaded content"
+        ftp_server.add_file("cache/dl.bin", content)
+        local = tmp_path / "output" / "dl.bin"
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_to_file("/cache/dl.bin", local)
+        assert result is True
+        assert local.read_bytes() == content
+        client.disconnect()
+
+    def test_download_to_file_creates_parent_dirs(self, ftp_client_factory, ftp_server, tmp_path):
+        """download_to_file() creates parent directories automatically."""
+        ftp_server.add_file("cache/nested.txt", b"nested content")
+        local = tmp_path / "deep" / "nested" / "path" / "nested.txt"
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_to_file("/cache/nested.txt", local)
+        assert result is True
+        assert local.exists()
+        client.disconnect()
+
+    def test_zero_byte_download_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
+        """0-byte download returns False and cleans up (regression test)."""
+        ftp_server.add_file("cache/empty.bin", b"")
+        local = tmp_path / "empty.bin"
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_to_file("/cache/empty.bin", local)
+        assert result is False
+        assert not local.exists()
+        client.disconnect()
+
+    def test_download_to_file_missing_returns_false(self, ftp_client_factory, tmp_path):
+        """Missing file returns False."""
+        local = tmp_path / "missing.bin"
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_to_file("/cache/no_such_file.bin", local)
+        assert result is False
+        client.disconnect()
+
+    def test_download_large_file(self, ftp_client_factory, ftp_server):
+        """Large file download (>1MB) works correctly."""
+        large_content = b"X" * (1024 * 1024 + 500)  # ~1MB + 500 bytes
+        ftp_server.add_file("cache/large.bin", large_content)
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_file("/cache/large.bin")
+        assert result == large_content
+        client.disconnect()
+
+    def test_download_not_connected(self):
+        """download_file() returns None when not connected."""
+        client = BambuFTPClient("127.0.0.1", "12345678")
+        assert client.download_file("/cache/test.bin") is None
+
+
+# ---------------------------------------------------------------------------
+# TestUpload
+# ---------------------------------------------------------------------------
+class TestUpload:
+    """Tests for file upload operations."""
+
+    def test_upload_success(self, ftp_client_factory, ftp_server, tmp_path):
+        """Successful upload via transfercmd (not storbinary)."""
+        content = b"Upload test content"
+        local = tmp_path / "upload.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory()
+        client.connect()
+        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)
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/upload.3mf")
+        assert downloaded == content
+        client2.disconnect()
+
+    def test_upload_progress_callback(self, ftp_client_factory, ftp_server, tmp_path):
+        """Progress callback receives updates during upload."""
+        content = b"P" * 2048
+        local = tmp_path / "progress.bin"
+        local.write_bytes(content)
+
+        progress_calls = []
+
+        def on_progress(uploaded, total):
+            progress_calls.append((uploaded, total))
+
+        client = ftp_client_factory()
+        client.connect()
+        client.upload_file(local, "/cache/progress.bin", on_progress)
+        assert len(progress_calls) >= 1
+        # Last call should report full file uploaded
+        assert progress_calls[-1][0] == len(content)
+        assert progress_calls[-1][1] == len(content)
+        client.disconnect()
+
+    def test_upload_not_connected(self, tmp_path):
+        """Upload when not connected returns False."""
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data")
+        client = BambuFTPClient("127.0.0.1", "12345678")
+        assert client.upload_file(local, "/cache/test.bin") is False
+
+    def test_upload_553_no_sd_card(self, ftp_client_factory, ftp_server, tmp_path):
+        """553 error (no SD card) returns False."""
+        ftp_server.inject_failure("STOR", 553, "Could not create file.")
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_upload_550_permission_denied(self, ftp_client_factory, ftp_server, tmp_path):
+        """550 error (permission denied) returns False."""
+        ftp_server.inject_failure("STOR", 550, "Permission denied.")
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_upload_552_storage_full(self, ftp_client_factory, ftp_server, tmp_path):
+        """552 error (storage full) returns False."""
+        ftp_server.inject_failure("STOR", 552, "Storage quota exceeded.")
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
+        """upload_bytes() writes data to server."""
+        data = b"Bytes upload content"
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_bytes(data, "/cache/bytes.bin")
+        assert result is True
+        client.disconnect()
+        # Verify via fresh connection
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/bytes.bin")
+        assert downloaded == data
+        client2.disconnect()
+
+    def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):
+        """upload_bytes() returns False on STOR failure."""
+        ftp_server.inject_failure("STOR", 553, "No space.")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_bytes(b"data", "/cache/fail.bin")
+        assert result is False
+        client.disconnect()
+
+    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.
+        """
+        content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
+        local = tmp_path / "large.bin"
+        local.write_bytes(content)
+
+        progress_calls = []
+
+        def on_progress(uploaded, total):
+            progress_calls.append((uploaded, total))
+
+        client = ftp_client_factory()
+        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
+        assert progress_calls[-1][0] == len(content)
+        client.disconnect()
+
+
+# ---------------------------------------------------------------------------
+# TestDelete
+# ---------------------------------------------------------------------------
+class TestDelete:
+    """Tests for file deletion."""
+
+    def test_delete_success(self, ftp_client_factory, ftp_server):
+        """Successful file deletion."""
+        ftp_server.add_file("cache/to_delete.bin", b"delete me")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.delete_file("/cache/to_delete.bin")
+        assert result is True
+        assert not ftp_server.file_exists("cache/to_delete.bin")
+        client.disconnect()
+
+    def test_delete_not_found(self, ftp_client_factory):
+        """Deleting a nonexistent file returns False."""
+        client = ftp_client_factory()
+        client.connect()
+        result = client.delete_file("/cache/no_such_file.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_delete_not_connected(self):
+        """Delete when not connected returns False."""
+        client = BambuFTPClient("127.0.0.1", "12345678")
+        assert client.delete_file("/cache/test.bin") is False
+
+
+# ---------------------------------------------------------------------------
+# TestFileSize
+# ---------------------------------------------------------------------------
+class TestFileSize:
+    """Tests for get_file_size."""
+
+    def test_file_size_correct(self, ftp_client_factory, ftp_server):
+        """Returns correct file size."""
+        ftp_server.add_file("cache/sized.bin", b"a" * 4096)
+        client = ftp_client_factory()
+        client.connect()
+        size = client.get_file_size("/cache/sized.bin")
+        assert size == 4096
+        client.disconnect()
+
+    def test_file_size_missing(self, ftp_client_factory):
+        """Returns None for missing file."""
+        client = ftp_client_factory()
+        client.connect()
+        size = client.get_file_size("/cache/no_file.bin")
+        assert size is None
+        client.disconnect()
+
+    def test_file_size_not_connected(self):
+        """Returns None when not connected."""
+        client = BambuFTPClient("127.0.0.1", "12345678")
+        assert client.get_file_size("/cache/test.bin") is None
+
+
+# ---------------------------------------------------------------------------
+# TestStorageInfo
+# ---------------------------------------------------------------------------
+class TestStorageInfo:
+    """Tests for storage info and diagnostics."""
+
+    def test_avbl_parsed(self, ftp_client_factory, ftp_server):
+        """AVBL response is parsed for free_bytes."""
+        ftp_server.set_avbl_bytes(5000000000)
+        client = ftp_client_factory()
+        client.connect()
+        info = client.get_storage_info()
+        assert info is not None
+        assert info["free_bytes"] == 5000000000
+        client.disconnect()
+
+    def test_used_bytes_from_scan(self, ftp_client_factory, ftp_server):
+        """used_bytes calculated from directory scan."""
+        ftp_server.add_file("cache/file1.bin", b"a" * 1000)
+        ftp_server.add_file("cache/file2.bin", b"b" * 2000)
+        client = ftp_client_factory()
+        client.connect()
+        info = client.get_storage_info()
+        assert info is not None
+        assert info["used_bytes"] >= 3000  # At least these two files
+        client.disconnect()
+
+    def test_storage_info_not_connected(self):
+        """Returns None when not connected."""
+        client = BambuFTPClient("127.0.0.1", "12345678")
+        assert client.get_storage_info() is None
+
+    def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):
+        """diagnose_storage() returns connected=True with working diagnostics."""
+        client = ftp_client_factory()
+        client.connect()
+        diag = client.diagnose_storage()
+        assert diag["connected"] is True
+        assert diag["can_list_root"] is True
+        assert diag["can_list_cache"] is True
+        assert diag["pwd"] is not None
+        assert diag["storage_info"] is not None
+        client.disconnect()
+
+    def test_diagnose_storage_not_connected(self):
+        """diagnose_storage() reports not connected."""
+        client = BambuFTPClient("127.0.0.1", "12345678")
+        diag = client.diagnose_storage()
+        assert diag["connected"] is False
+        assert "FTP not connected" in diag["errors"]
+
+
+# ---------------------------------------------------------------------------
+# TestModelSpecificBehavior
+# ---------------------------------------------------------------------------
+class TestModelSpecificBehavior:
+    """Tests for printer model-specific FTP behavior."""
+
+    def test_x1c_upload(self, ftp_client_factory, ftp_server, tmp_path):
+        """X1C upload with session reuse succeeds."""
+        content = b"X1C upload data"
+        local = tmp_path / "x1c.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="X1C")
+        client.connect()
+        result = client.upload_file(local, "/cache/x1c.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify via fresh connection
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory(printer_model="X1C")
+        client2.connect()
+        downloaded = client2.download_file("/cache/x1c.3mf")
+        assert downloaded == content
+        client2.disconnect()
+
+    def test_a1_upload_prot_c(self, ftp_client_factory, ftp_server, tmp_path):
+        """A1 model upload with prot_c succeeds."""
+        content = b"A1 upload data"
+        local = tmp_path / "a1.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="A1", force_prot_c=True)
+        client.connect()
+        result = client.upload_file(local, "/cache/a1.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify via fresh connection
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory(printer_model="A1", force_prot_c=True)
+        client2.connect()
+        downloaded = client2.download_file("/cache/a1.3mf")
+        assert downloaded == content
+        client2.disconnect()
+
+    def test_a1_mini_upload(self, ftp_client_factory, ftp_server, tmp_path):
+        """A1 Mini model upload succeeds."""
+        content = b"A1 Mini data"
+        local = tmp_path / "a1mini.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="A1 Mini", force_prot_c=True)
+        client.connect()
+        result = client.upload_file(local, "/cache/a1mini.3mf")
+        assert result is True
+        client.disconnect()
+
+    def test_p1s_upload(self, ftp_client_factory, ftp_server, tmp_path):
+        """P1S model upload with session reuse succeeds."""
+        content = b"P1S upload data"
+        local = tmp_path / "p1s.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="P1S")
+        client.connect()
+        result = client.upload_file(local, "/cache/p1s.3mf")
+        assert result is True
+        client.disconnect()
+
+    def test_unknown_model_defaults_prot_p(self, ftp_client_factory):
+        """Unknown model defaults to prot_p."""
+        client = ftp_client_factory(printer_model="FuturePrinter3000")
+        assert client._is_a1_model() is False
+        assert client._should_use_prot_c() is False
+        assert client.connect() is True
+        client.disconnect()
+
+    def test_mode_cache_persists_and_clears(self, ftp_client_factory):
+        """Mode cache works within a test and clears between tests."""
+        # Cache should be empty at start (autouse fixture clears it)
+        assert BambuFTPClient._mode_cache == {}
+
+        # Connect and cache a mode
+        BambuFTPClient.cache_mode("127.0.0.1", "prot_p")
+        assert BambuFTPClient._mode_cache["127.0.0.1"] == "prot_p"
+
+        # New client for same IP uses cached mode
+        client = ftp_client_factory(printer_model="A1")
+        assert client._get_cached_mode() == "prot_p"
+        assert client._should_use_prot_c() is False
+        client.disconnect()
+
+
+# ---------------------------------------------------------------------------
+# TestAsyncWrappers
+# ---------------------------------------------------------------------------
+class TestAsyncWrappers:
+    """Tests for async wrapper functions using patch_ftp_port fixture."""
+
+    @pytest.mark.asyncio
+    async def test_upload_file_async_success(self, patch_ftp_port, tmp_path):
+        """upload_file_async succeeds for X1C."""
+        content = b"async upload"
+        local = tmp_path / "async_up.3mf"
+        local.write_bytes(content)
+        result = await upload_file_async(
+            "127.0.0.1",
+            "12345678",
+            local,
+            "/cache/async_up.3mf",
+            timeout=30.0,
+            printer_model="X1C",
+        )
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_upload_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
+        """upload_file_async tries prot_p then falls back to prot_c for A1."""
+        content = b"a1 async upload"
+        local = tmp_path / "a1_async.3mf"
+        local.write_bytes(content)
+        # For A1 models, if prot_p succeeds we get True.
+        # If prot_p fails, it tries prot_c. Either way should succeed
+        # against our mock server which accepts both.
+        result = await upload_file_async(
+            "127.0.0.1",
+            "12345678",
+            local,
+            "/cache/a1_async.3mf",
+            timeout=30.0,
+            printer_model="A1",
+        )
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_download_file_async_success(self, patch_ftp_port, tmp_path):
+        """download_file_async succeeds."""
+        server = patch_ftp_port
+        content = b"async download content"
+        server.add_file("cache/async_dl.bin", content)
+        local = tmp_path / "async_dl.bin"
+        result = await download_file_async(
+            "127.0.0.1",
+            "12345678",
+            "/cache/async_dl.bin",
+            local,
+            timeout=30.0,
+            printer_model="X1C",
+        )
+        assert result is True
+        assert local.read_bytes() == content
+
+    @pytest.mark.asyncio
+    async def test_download_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
+        """download_file_async falls back for A1 models."""
+        server = patch_ftp_port
+        server.add_file("cache/a1_dl.bin", b"a1 data")
+        local = tmp_path / "a1_dl.bin"
+        result = await download_file_async(
+            "127.0.0.1",
+            "12345678",
+            "/cache/a1_dl.bin",
+            local,
+            timeout=30.0,
+            printer_model="A1",
+        )
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
+        """download_file_try_paths_async succeeds on first path."""
+        server = patch_ftp_port
+        server.add_file("cache/try1.bin", b"first path")
+        local = tmp_path / "try.bin"
+        result = await download_file_try_paths_async(
+            "127.0.0.1",
+            "12345678",
+            ["/cache/try1.bin", "/cache/try2.bin"],
+            local,
+            printer_model="X1C",
+        )
+        assert result is True
+        assert local.read_bytes() == b"first path"
+
+    @pytest.mark.asyncio
+    async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):
+        """download_file_try_paths_async falls back to second path."""
+        server = patch_ftp_port
+        server.add_file("cache/second.bin", b"second path")
+        local = tmp_path / "fallback.bin"
+        result = await download_file_try_paths_async(
+            "127.0.0.1",
+            "12345678",
+            ["/cache/missing.bin", "/cache/second.bin"],
+            local,
+            printer_model="X1C",
+        )
+        assert result is True
+        assert local.read_bytes() == b"second path"
+
+    @pytest.mark.asyncio
+    async def test_list_files_async_success(self, patch_ftp_port):
+        """list_files_async returns file list."""
+        server = patch_ftp_port
+        server.add_file("cache/listed.bin", b"data")
+        result = await list_files_async(
+            "127.0.0.1",
+            "12345678",
+            "/cache",
+            timeout=30.0,
+            printer_model="X1C",
+        )
+        names = {f["name"] for f in result}
+        assert "listed.bin" in names
+
+    @pytest.mark.asyncio
+    async def test_delete_file_async_success(self, patch_ftp_port):
+        """delete_file_async deletes a file."""
+        server = patch_ftp_port
+        server.add_file("cache/to_async_del.bin", b"delete me")
+        result = await delete_file_async(
+            "127.0.0.1",
+            "12345678",
+            "/cache/to_async_del.bin",
+            printer_model="X1C",
+        )
+        assert result is True
+        assert not server.file_exists("cache/to_async_del.bin")
+
+
+# ---------------------------------------------------------------------------
+# TestFailureScenarios
+# ---------------------------------------------------------------------------
+class TestFailureScenarios:
+    """Regression tests for known FTP failure modes."""
+
+    def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):
+        """550 error_perm is caught by (OSError, ftplib.Error) handler.
+
+        Regression: error_perm is a subclass of ftplib.Error, so the
+        broad except clause in upload_file catches it correctly.
+        """
+        ftp_server.inject_failure("STOR", 550, "Permission denied.")
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):
+        """0-byte download is detected and file is cleaned up.
+
+        Regression: Prior to fix, 0-byte downloads were reported as success.
+        """
+        ftp_server.add_file("cache/zero.bin", b"")
+        local = tmp_path / "zero.bin"
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_to_file("/cache/zero.bin", local)
+        assert result is False
+        assert not local.exists()
+        client.disconnect()
+
+    def test_connection_refused_handled(self):
+        """Connection refused is handled gracefully."""
+        client = BambuFTPClient("127.0.0.1", "12345678", timeout=2.0)
+        client.FTP_PORT = 1  # Almost certainly not listening
+        assert client.connect() is False
+
+    def test_auth_failure_530(self, ftp_client_factory, ftp_server):
+        """530 authentication failure returns False."""
+        ftp_server.inject_failure("PASS", 530, "Login incorrect.")
+        client = ftp_client_factory()
+        result = client.connect()
+        assert result is False
+
+    def test_retr_550_handled(self, ftp_client_factory, ftp_server):
+        """RETR 550 (file not found) returns None."""
+        ftp_server.inject_failure("RETR", 550, "File not found.")
+        ftp_server.add_file("cache/exists.bin", b"data")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.download_file("/cache/exists.bin")
+        assert result is None
+        client.disconnect()
+
+    def test_cwd_550_handled(self, ftp_client_factory, ftp_server):
+        """CWD 550 is handled in list_files."""
+        ftp_server.inject_failure("CWD", 550, "Directory not found.")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.list_files("/nonexistent")
+        assert result == []
+        client.disconnect()
+
+    def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):
+        """STOR 553 (no SD card) handled gracefully."""
+        ftp_server.inject_failure("STOR", 553, "Could not create file.")
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"test")
+        client = ftp_client_factory()
+        client.connect()
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
+        """diagnose_storage CWD failure doesn't crash the whole operation.
+
+        Regression: diagnose_storage() was called in the upload path and
+        a CWD failure would propagate and crash the upload.
+        """
+        ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
+        client = ftp_client_factory()
+        client.connect()
+        diag = client.diagnose_storage()
+        # Should still return results (with errors noted)
+        assert diag["connected"] is True
+        assert len(diag["errors"]) > 0
+        client.disconnect()
+
+    def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
+        """Failure injection with count decrements and eventually succeeds."""
+        ftp_server.add_file("cache/retry.bin", b"data after retry")
+        ftp_server.inject_failure("RETR", 550, "Temporary error.", count=1)
+        client = ftp_client_factory()
+        client.connect()
+        # First attempt fails
+        result1 = client.download_file("/cache/retry.bin")
+        assert result1 is None
+        # Second attempt succeeds (failure count exhausted)
+        result2 = client.download_file("/cache/retry.bin")
+        assert result2 == b"data after retry"
+        client.disconnect()

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

@@ -603,3 +603,258 @@ class TestAMSDataMerging:
         # Verify other slots are preserved
         # Verify other slots are preserved
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
+
+
+class TestNozzleRackData:
+    """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):
+        """H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack)."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 0,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 5,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-L",
+                            },
+                            {
+                                "id": 1,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 3,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-R",
+                            },
+                            {
+                                "id": 16,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 10,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-16",
+                            },
+                            {
+                                "id": 17,
+                                "type": "HH01",
+                                "diameter": "0.6",
+                                "wear": 0,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-17",
+                            },
+                            {
+                                "id": 18,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 2,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-18",
+                            },
+                            {
+                                "id": 19,
+                                "type": "",
+                                "diameter": "",
+                                "wear": None,
+                                "stat": None,
+                                "max_temp": 0,
+                                "serial_number": "",
+                            },
+                            {
+                                "id": 20,
+                                "type": "",
+                                "diameter": "",
+                                "wear": None,
+                                "stat": None,
+                                "max_temp": 0,
+                                "serial_number": "",
+                            },
+                            {
+                                "id": 21,
+                                "type": "",
+                                "diameter": "",
+                                "wear": None,
+                                "stat": None,
+                                "max_temp": 0,
+                                "serial_number": "",
+                            },
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert len(mqtt_client.state.nozzle_rack) == 8
+        ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
+        assert ids == [0, 1, 16, 17, 18, 19, 20, 21]
+
+    def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):
+        """H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 0,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 5,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-L",
+                            },
+                            {
+                                "id": 1,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 3,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-R",
+                            },
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert len(mqtt_client.state.nozzle_rack) == 2
+        ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
+        assert ids == [0, 1]
+
+    def test_single_nozzle_h2s_populated(self, mqtt_client):
+        """H2S provides 1 nozzle entry: ID 0 only — single nozzle printer."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 0,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 2,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-0",
+                            },
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert len(mqtt_client.state.nozzle_rack) == 1
+        assert mqtt_client.state.nozzle_rack[0]["id"] == 0
+
+    def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):
+        """Empty nozzle info list should not populate nozzle_rack."""
+        payload = {"print": {"device": {"nozzle": {"info": []}}}}
+        mqtt_client._process_message(payload)
+
+        assert mqtt_client.state.nozzle_rack == []
+
+    def test_nozzle_rack_sorted_by_id(self, mqtt_client):
+        """Nozzle rack entries should be sorted by ID regardless of input order."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {"id": 17, "type": "HS", "diameter": "0.6"},
+                            {"id": 0, "type": "HS", "diameter": "0.4"},
+                            {"id": 16, "type": "HS", "diameter": "0.4"},
+                            {"id": 1, "type": "HS", "diameter": "0.4"},
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
+        assert ids == [0, 1, 16, 17]
+
+    def test_nozzle_rack_field_mapping(self, mqtt_client):
+        """Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 16,
+                                "type": "HH01",
+                                "diameter": "0.6",
+                                "wear": 15,
+                                "stat": 0,
+                                "max_temp": 320,
+                                "serial_number": "SN-ABC123",
+                                "filament_colour": "FF8800",
+                                "filament_id": "F42",
+                                "tray_type": "ABS",
+                            }
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        slot = mqtt_client.state.nozzle_rack[0]
+        assert slot["id"] == 16
+        assert slot["type"] == "HH01"
+        assert slot["diameter"] == "0.6"
+        assert slot["wear"] == 15
+        assert slot["stat"] == 0
+        assert slot["max_temp"] == 320
+        assert slot["serial_number"] == "SN-ABC123"
+        assert slot["filament_color"] == "FF8800"
+        assert slot["filament_id"] == "F42"
+        assert slot["filament_type"] == "ABS"
+
+    def test_nozzle_info_updates_nozzle_state(self, mqtt_client):
+        """Nozzle info for IDs 0,1 should also update nozzle state (type/diameter)."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {"id": 0, "type": "HS", "diameter": "0.4"},
+                            {"id": 1, "type": "HH01", "diameter": "0.6"},
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert mqtt_client.state.nozzles[0].nozzle_type == "HS"
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+        assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
+        assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"

+ 245 - 0
backend/tests/unit/services/test_email_service.py

@@ -0,0 +1,245 @@
+"""Unit tests for email service.
+
+These tests verify email template rendering, HTML formatting,
+password generation, and SMTP settings persistence.
+"""
+
+import string
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.notification_template import NotificationTemplate
+from backend.app.services.email_service import (
+    create_password_reset_email_from_template,
+    create_welcome_email_from_template,
+    generate_secure_password,
+    render_template,
+)
+
+
+class TestEmailTemplateFormatting:
+    """Tests for email template formatting."""
+
+    @pytest.mark.asyncio
+    async def test_welcome_email_newlines_converted_to_br(self):
+        """Verify that newlines in welcome email body are converted to <br> tags."""
+        # Mock database session
+        db = AsyncMock(spec=AsyncSession)
+
+        # Mock template with newlines
+        template = NotificationTemplate(
+            event_type="user_created",
+            name="Welcome Email",
+            title_template="Welcome to {app_name}",
+            body_template="Hello {username}!\n\nYour password is: {password}\n\nPlease login at: {login_url}",
+            is_default=True,
+        )
+
+        # Patch get_notification_template to return our template
+        with patch("backend.app.services.email_service.get_notification_template", return_value=template):
+            # Generate email
+            subject, text_body, html_body = await create_welcome_email_from_template(
+                db=db,
+                username="testuser",
+                password="testpass123",
+                login_url="http://example.com/login",
+                app_name="TestApp",
+            )
+
+        # Verify subject
+        assert subject == "Welcome to TestApp"
+
+        # Verify text body has newlines
+        assert "\n\n" in text_body
+        assert "Hello testuser!" in text_body
+        assert "Your password is: testpass123" in text_body
+
+        # Verify HTML body has <br> tags instead of relying on CSS
+        assert "<br>" in html_body
+        # Should not use white-space: pre-wrap
+        assert "white-space: pre-wrap" not in html_body
+        # Should have proper structure
+        assert "<!DOCTYPE html>" in html_body
+        assert '<div style="font-size: 16px;">' in html_body
+
+        # Verify that escaped content is present (XSS protection)
+        assert "Hello testuser!<br>" in html_body
+        assert "Your password is: testpass123<br>" in html_body
+
+    @pytest.mark.asyncio
+    async def test_password_reset_email_newlines_converted_to_br(self):
+        """Verify that newlines in password reset email body are converted to <br> tags."""
+        # Mock database session
+        db = AsyncMock(spec=AsyncSession)
+
+        # Mock template with newlines
+        template = NotificationTemplate(
+            event_type="password_reset",
+            name="Password Reset",
+            title_template="{app_name} - Password Reset",
+            body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}",
+            is_default=True,
+        )
+
+        # Patch get_notification_template to return our template
+        with patch("backend.app.services.email_service.get_notification_template", return_value=template):
+            # Generate email
+            subject, text_body, html_body = await create_password_reset_email_from_template(
+                db=db,
+                username="testuser",
+                password="newpass456",
+                login_url="http://example.com/login",
+                app_name="TestApp",
+            )
+
+        # Verify subject
+        assert subject == "TestApp - Password Reset"
+
+        # Verify text body has newlines
+        assert "\n\n" in text_body
+        assert "Hello testuser," in text_body
+
+        # Verify HTML body has <br> tags
+        assert "<br>" in html_body
+        # Should not use white-space: pre-wrap
+        assert "white-space: pre-wrap" not in html_body
+        # Should have security alert
+        assert "Security Alert" in html_body
+
+    @pytest.mark.asyncio
+    async def test_email_header_padding(self):
+        """Verify that email header has proper padding to prevent cutoff."""
+        # Mock database session
+        db = AsyncMock(spec=AsyncSession)
+
+        # Mock template
+        template = NotificationTemplate(
+            event_type="user_created",
+            name="Welcome Email",
+            title_template="Welcome",
+            body_template="Test body",
+            is_default=True,
+        )
+
+        # Patch get_notification_template to return our template
+        with patch("backend.app.services.email_service.get_notification_template", return_value=template):
+            # Generate email
+            subject, text_body, html_body = await create_welcome_email_from_template(
+                db=db,
+                username="testuser",
+                password="testpass123",
+                login_url="http://example.com/login",
+            )
+
+        # Verify header has 30px padding (not 20px which was cutting off)
+        assert "padding: 30px; border-radius: 8px 8px 0 0;" in html_body
+
+    @pytest.mark.asyncio
+    async def test_email_xss_protection(self):
+        """Verify that HTML escaping is applied to prevent XSS attacks."""
+        # Mock database session
+        db = AsyncMock(spec=AsyncSession)
+
+        # Mock template with potential XSS content
+        template = NotificationTemplate(
+            event_type="user_created",
+            name="Welcome Email",
+            title_template="Welcome <script>alert('xss')</script>",
+            body_template="Hello <script>alert('xss')</script>\nTest",
+            is_default=True,
+        )
+
+        # Patch get_notification_template to return our template
+        with patch("backend.app.services.email_service.get_notification_template", return_value=template):
+            # Generate email
+            subject, text_body, html_body = await create_welcome_email_from_template(
+                db=db,
+                username="testuser",
+                password="testpass123",
+                login_url="http://example.com/login",
+            )
+
+        # Verify that script tags are escaped
+        assert "&lt;script&gt;" in html_body
+        # Verify no unescaped script tags
+        assert "<script>" not in html_body
+
+
+class TestGenerateSecurePassword:
+    """Tests for generate_secure_password()."""
+
+    def test_password_default_length(self):
+        """Default password is 16 characters."""
+        password = generate_secure_password()
+        assert len(password) == 16
+
+    def test_password_custom_length(self):
+        """Custom length is respected."""
+        password = generate_secure_password(24)
+        assert len(password) == 24
+
+    def test_password_has_required_char_types(self):
+        """Password contains uppercase, lowercase, digit, and special character."""
+        # Run multiple times to reduce flakiness from random shuffling
+        for _ in range(5):
+            password = generate_secure_password()
+            assert any(c in string.ascii_uppercase for c in password), "Missing uppercase"
+            assert any(c in string.ascii_lowercase for c in password), "Missing lowercase"
+            assert any(c in string.digits for c in password), "Missing digit"
+            assert any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password), "Missing special"
+
+
+class TestRenderTemplate:
+    """Tests for render_template()."""
+
+    def test_render_template_basic(self):
+        """Placeholders are replaced correctly."""
+        result = render_template("Hello {name}, welcome to {app}!", {"name": "Alice", "app": "BamBuddy"})
+        assert result == "Hello Alice, welcome to BamBuddy!"
+
+    def test_render_template_removes_unreplaced(self):
+        """Unreplaced placeholders are removed."""
+        result = render_template("Hello {name}, your code is {code}", {"name": "Bob"})
+        assert result == "Hello Bob, your code is "
+
+
+class TestSMTPSettingsPersistence:
+    """Tests for save_smtp_settings() and get_smtp_settings() round-trip."""
+
+    @pytest.mark.asyncio
+    async def test_save_and_retrieve_smtp_settings(self, db_session):
+        """Save SMTP settings, then retrieve them and verify values match."""
+        from backend.app.schemas.auth import SMTPSettings
+        from backend.app.services.email_service import get_smtp_settings, save_smtp_settings
+
+        settings = SMTPSettings(
+            smtp_host="mail.example.com",
+            smtp_port=465,
+            smtp_username="user@example.com",
+            smtp_password="secret",
+            smtp_security="ssl",
+            smtp_auth_enabled=True,
+            smtp_from_email="noreply@example.com",
+        )
+        await save_smtp_settings(db_session, settings)
+        await db_session.commit()
+
+        retrieved = await get_smtp_settings(db_session)
+        assert retrieved is not None
+        assert retrieved.smtp_host == "mail.example.com"
+        assert retrieved.smtp_port == 465
+        assert retrieved.smtp_username == "user@example.com"
+        assert retrieved.smtp_password == "secret"
+        assert retrieved.smtp_security == "ssl"
+        assert retrieved.smtp_auth_enabled is True
+        assert retrieved.smtp_from_email == "noreply@example.com"
+
+    @pytest.mark.asyncio
+    async def test_get_smtp_settings_returns_none_when_unconfigured(self, db_session):
+        """Empty DB returns None for SMTP settings."""
+        from backend.app.services.email_service import get_smtp_settings
+
+        result = await get_smtp_settings(db_session)
+        assert result is None

+ 203 - 1
backend/tests/unit/services/test_spoolman_service.py

@@ -4,7 +4,7 @@ These tests specifically target the sync_ams_tray method's disable_weight_sync
 functionality that controls whether remaining_weight is updated.
 functionality that controls whether remaining_weight is updated.
 """
 """
 
 
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, Mock, patch
 
 
 import pytest
 import pytest
 
 
@@ -172,3 +172,205 @@ class TestSpoolmanClient:
                 assert call_kwargs["remaining_weight"] == expected, (
                 assert call_kwargs["remaining_weight"] == expected, (
                     f"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}"
                     f"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}"
                 )
                 )
+
+    # ========================================================================
+    # Tests for caching functionality
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_find_spool_by_tag_with_cached_spools(self, client):
+        """Verify find_spool_by_tag uses cached spools when provided (no API call)."""
+        cached = [
+            {"id": 1, "extra": {"tag": '"ABC123"'}},
+            {"id": 2, "extra": {"tag": '"XYZ789"'}},
+        ]
+
+        with patch.object(client, "get_spools", AsyncMock()) as mock_get:
+            result = await client.find_spool_by_tag("ABC123", cached_spools=cached)
+            assert result["id"] == 1
+            mock_get.assert_not_called()  # Should NOT call get_spools
+
+    @pytest.mark.asyncio
+    async def test_find_spool_by_tag_without_cached_spools(self, client):
+        """Verify find_spool_by_tag fetches spools when cache not provided."""
+        mock_spools = [{"id": 1, "extra": {"tag": '"ABC123"'}}]
+
+        with patch.object(client, "get_spools", AsyncMock(return_value=mock_spools)) as mock_get:
+            result = await client.find_spool_by_tag("ABC123")
+            assert result["id"] == 1
+            mock_get.assert_called_once()  # Should call get_spools
+
+    @pytest.mark.asyncio
+    async def test_find_spools_by_location_prefix_with_cached_spools(self, client):
+        """Verify find_spools_by_location_prefix uses cached spools when provided."""
+        cached = [
+            {"id": 1, "location": "Printer1 - AMS A1"},
+            {"id": 2, "location": "Printer2 - AMS A1"},
+            {"id": 3, "location": "Printer1 - AMS A2"},
+        ]
+
+        with patch.object(client, "get_spools", AsyncMock()) as mock_get:
+            result = await client.find_spools_by_location_prefix("Printer1 - ", cached_spools=cached)
+            assert len(result) == 2
+            assert result[0]["id"] == 1
+            assert result[1]["id"] == 3
+            mock_get.assert_not_called()  # Should NOT call get_spools
+
+    @pytest.mark.asyncio
+    async def test_sync_ams_tray_with_cached_spools(self, client, sample_tray, existing_spool):
+        """Verify sync_ams_tray passes cached_spools to find_spool_by_tag."""
+        cached = [existing_spool]
+
+        with (
+            patch.object(client, "get_spools", AsyncMock()) as mock_get,
+            patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
+        ):
+            await client.sync_ams_tray(sample_tray, "TestPrinter", cached_spools=cached)
+            mock_get.assert_not_called()  # Should NOT call get_spools
+
+    @pytest.mark.asyncio
+    async def test_clear_location_for_removed_spools_with_cached_spools(self, client):
+        """Verify clear_location_for_removed_spools uses cached spools."""
+        cached = [
+            {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"TAG1"'}},
+            {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"TAG2"'}},
+            {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"TAG3"'}},
+        ]
+        current_tags = {"TAG1", "TAG2"}  # TAG3 was removed
+
+        with (
+            patch.object(client, "get_spools", AsyncMock()) as mock_get,
+            patch.object(client, "update_spool", AsyncMock(return_value={"id": 3})) as mock_update,
+        ):
+            cleared = await client.clear_location_for_removed_spools("Printer1", current_tags, cached_spools=cached)
+            assert cleared == 1
+            mock_get.assert_not_called()  # Should NOT call get_spools
+            mock_update.assert_called_once()
+            # Verify it cleared TAG3 (not in current_tags)
+            call_kwargs = mock_update.call_args.kwargs
+            assert call_kwargs["spool_id"] == 3
+            assert call_kwargs.get("clear_location") is True
+
+    # ========================================================================
+    # Tests for retry logic in get_spools
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_get_spools_succeeds_on_first_attempt(self, client):
+        """Verify get_spools succeeds immediately when no errors occur."""
+        mock_spools = [{"id": 1}, {"id": 2}]
+
+        with patch.object(client, "_get_client") as mock_get_client:
+            mock_http_client = AsyncMock()
+            mock_response = Mock()
+            mock_response.raise_for_status = Mock()
+            mock_response.json = Mock(return_value=mock_spools)
+            mock_http_client.get = AsyncMock(return_value=mock_response)
+            mock_get_client.return_value = mock_http_client
+
+            result = await client.get_spools()
+
+            assert result == mock_spools
+            mock_get_client.assert_called_once()
+            mock_http_client.get.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_get_spools_retries_on_connection_error(self, client):
+        """Verify get_spools retries up to 3 times on connection errors."""
+        import httpx
+
+        mock_spools = [{"id": 1}]
+
+        with (
+            patch.object(client, "_get_client") as mock_get_client,
+            patch.object(client, "close", AsyncMock()) as mock_close,
+            patch("asyncio.sleep", AsyncMock()) as mock_sleep,
+        ):
+            mock_http_client = AsyncMock()
+            mock_get_client.return_value = mock_http_client
+
+            # First 2 attempts fail with ReadError, 3rd succeeds
+            mock_response = Mock()
+            mock_response.raise_for_status = Mock()
+            mock_response.json = Mock(return_value=mock_spools)
+
+            mock_http_client.get = AsyncMock(
+                side_effect=[
+                    httpx.ReadError("Connection closed"),
+                    httpx.ReadError("Connection closed"),
+                    mock_response,
+                ]
+            )
+
+            result = await client.get_spools()
+
+            assert result == mock_spools
+            assert mock_get_client.call_count == 3
+            assert mock_http_client.get.call_count == 3
+            # Should close client twice (after each failed attempt)
+            assert mock_close.call_count == 2
+            # Should sleep twice (after first 2 attempts)
+            assert mock_sleep.call_count == 2
+            mock_sleep.assert_called_with(0.5)
+
+    @pytest.mark.asyncio
+    async def test_get_spools_raises_after_3_failed_attempts(self, client):
+        """Verify get_spools raises exception after 3 failed attempts."""
+        import httpx
+
+        with (
+            patch.object(client, "_get_client", AsyncMock()) as mock_get_client,
+            patch.object(client, "close", AsyncMock()) as mock_close,
+            patch("asyncio.sleep", AsyncMock()) as mock_sleep,
+        ):
+            mock_http_client = AsyncMock()
+            mock_get_client.return_value = mock_http_client
+
+            # All 3 attempts fail
+            mock_http_client.get.side_effect = httpx.ReadError("Connection closed")
+
+            with pytest.raises(httpx.ReadError):
+                await client.get_spools()
+
+            assert mock_get_client.call_count == 3
+            assert mock_http_client.get.call_count == 3
+            # Should close client twice (after first 2 failed attempts, not after 3rd)
+            assert mock_close.call_count == 2
+            # Should sleep twice (after first 2 attempts, not after 3rd)
+            assert mock_sleep.call_count == 2
+
+    @pytest.mark.asyncio
+    async def test_get_spools_handles_non_connection_errors(self, client):
+        """Verify get_spools retries on non-connection errors without recreating client."""
+        import httpx
+
+        mock_spools = [{"id": 1}]
+
+        with (
+            patch.object(client, "_get_client") as mock_get_client,
+            patch.object(client, "close", AsyncMock()) as mock_close,
+            patch("asyncio.sleep", AsyncMock()) as mock_sleep,
+        ):
+            mock_http_client = AsyncMock()
+            mock_get_client.return_value = mock_http_client
+
+            # First attempt fails with HTTP error, 2nd succeeds
+            mock_response_error = Mock()
+            mock_response_error.raise_for_status = Mock(
+                side_effect=httpx.HTTPStatusError("500 Server Error", request=Mock(), response=Mock())
+            )
+
+            mock_response_success = Mock()
+            mock_response_success.raise_for_status = Mock()
+            mock_response_success.json = Mock(return_value=mock_spools)
+
+            mock_http_client.get = AsyncMock(side_effect=[mock_response_error, mock_response_success])
+
+            result = await client.get_spools()
+
+            assert result == mock_spools
+            assert mock_get_client.call_count == 2
+            # Should NOT close client for HTTP errors (only connection errors)
+            mock_close.assert_not_called()
+            # Should sleep once (after first failed attempt)
+            assert mock_sleep.call_count == 1

+ 286 - 6
backend/tests/unit/services/test_virtual_printer.py

@@ -382,6 +382,74 @@ class TestSSDPServer:
 
 
         assert b"DevModel.bambu.com: BL-P001" in message
         assert b"DevModel.bambu.com: BL-P001" in message
 
 
+    # ========================================================================
+    # Tests for advertise_ip parameter
+    # ========================================================================
+
+    def test_advertise_ip_sets_local_ip(self):
+        """Verify advertise_ip overrides auto-detection."""
+        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+        server = VirtualPrinterSSDPServer(
+            serial="TEST123",
+            name="TestPrinter",
+            model="BL-P001",
+            advertise_ip="10.0.0.50",
+        )
+
+        assert server._local_ip == "10.0.0.50"
+
+    def test_advertise_ip_empty_string_uses_auto_detect(self):
+        """Verify empty advertise_ip falls back to auto-detection."""
+        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+        server = VirtualPrinterSSDPServer(
+            serial="TEST123",
+            name="TestPrinter",
+            model="BL-P001",
+            advertise_ip="",
+        )
+
+        assert server._local_ip is None
+
+    def test_advertise_ip_in_notify_message(self):
+        """Verify NOTIFY message uses the advertise_ip."""
+        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+        server = VirtualPrinterSSDPServer(
+            serial="TEST123",
+            name="TestPrinter",
+            model="BL-P001",
+            advertise_ip="10.0.0.50",
+        )
+
+        message = server._build_notify_message()
+
+        assert b"Location: 10.0.0.50" in message
+
+    def test_advertise_ip_in_response_message(self):
+        """Verify M-SEARCH response uses the advertise_ip."""
+        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+        server = VirtualPrinterSSDPServer(
+            serial="TEST123",
+            name="TestPrinter",
+            model="BL-P001",
+            advertise_ip="10.0.0.50",
+        )
+
+        message = server._build_response_message()
+
+        assert b"Location: 10.0.0.50" in message
+
+    def test_default_no_advertise_ip(self):
+        """Verify default constructor has None local_ip (auto-detect)."""
+        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+        server = VirtualPrinterSSDPServer()
+
+        assert server._local_ip is None
+
 
 
 class TestCertificateService:
 class TestCertificateService:
     """Tests for TLS certificate generation."""
     """Tests for TLS certificate generation."""
@@ -507,7 +575,7 @@ class TestSSDPProxy:
         """Verify SSDP Location header is rewritten to remote interface IP."""
         """Verify SSDP Location header is rewritten to remote interface IP."""
         original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
         original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
 
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
 
         # Location should be changed to remote interface IP
         # Location should be changed to remote interface IP
         assert b"Location: 10.0.0.100" in rewritten
         assert b"Location: 10.0.0.100" in rewritten
@@ -519,7 +587,7 @@ class TestSSDPProxy:
         """Verify SSDP Location rewrite is case insensitive."""
         """Verify SSDP Location rewrite is case insensitive."""
         original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
         original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
 
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
 
         assert b"10.0.0.100" in rewritten
         assert b"10.0.0.100" in rewritten
 
 
@@ -527,10 +595,10 @@ class TestSSDPProxy:
         """Verify packet without Location header is returned unchanged."""
         """Verify packet without Location header is returned unchanged."""
         original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
         original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
 
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
 
-        # Should be unchanged (no Location header to rewrite)
-        assert rewritten == original_packet
+        # No Location header, but _rewrite_ssdp logs a warning and returns as-is
+        assert b"DevName.bambu.com: Test" in rewritten
 
 
     def test_parse_ssdp_message(self, ssdp_proxy):
     def test_parse_ssdp_message(self, ssdp_proxy):
         """Verify SSDP message parsing extracts headers."""
         """Verify SSDP message parsing extracts headers."""
@@ -684,7 +752,7 @@ class TestVirtualPrinterManagerProxyMode:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
     async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
-        """Verify changing remote_interface_ip restarts services."""
+        """Verify changing remote_interface_ip restarts services in proxy mode."""
         # Simulate running state
         # Simulate running state
         manager._enabled = True
         manager._enabled = True
         manager._mode = "proxy"
         manager._mode = "proxy"
@@ -704,3 +772,215 @@ class TestVirtualPrinterManagerProxyMode:
         # Should have stopped and started
         # Should have stopped and started
         manager._stop.assert_called_once()
         manager._stop.assert_called_once()
         manager._start.assert_called_once()
         manager._start.assert_called_once()
+
+
+class TestVirtualPrinterManagerServerModeIPOverride:
+    """Tests for remote_interface_ip in server mode (immediate/review/print_queue)."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a VirtualPrinterManager instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        return VirtualPrinterManager()
+
+    @pytest.mark.asyncio
+    async def test_configure_immediate_mode_stores_remote_interface_ip(self, manager):
+        """Verify immediate mode stores remote_interface_ip."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="immediate",
+            remote_interface_ip="10.0.0.50",
+        )
+
+        assert manager._remote_interface_ip == "10.0.0.50"
+
+    @pytest.mark.asyncio
+    async def test_configure_review_mode_stores_remote_interface_ip(self, manager):
+        """Verify review mode stores remote_interface_ip."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="review",
+            remote_interface_ip="10.0.0.50",
+        )
+
+        assert manager._remote_interface_ip == "10.0.0.50"
+
+    @pytest.mark.asyncio
+    async def test_configure_print_queue_mode_stores_remote_interface_ip(self, manager):
+        """Verify print_queue mode stores remote_interface_ip."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="print_queue",
+            remote_interface_ip="10.0.0.50",
+        )
+
+        assert manager._remote_interface_ip == "10.0.0.50"
+
+    @pytest.mark.asyncio
+    async def test_remote_interface_change_restarts_immediate_mode(self, manager):
+        """Verify changing remote_interface_ip restarts services in immediate mode."""
+        manager._enabled = True
+        manager._mode = "immediate"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="immediate",
+            remote_interface_ip="10.0.0.99",  # Changed
+        )
+
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_remote_interface_change_restarts_review_mode(self, manager):
+        """Verify changing remote_interface_ip restarts services in review mode."""
+        manager._enabled = True
+        manager._mode = "review"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="review",
+            remote_interface_ip="10.0.0.99",  # Changed
+        )
+
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_remote_interface_change_restarts_print_queue_mode(self, manager):
+        """Verify changing remote_interface_ip restarts services in print_queue mode."""
+        manager._enabled = True
+        manager._mode = "print_queue"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="print_queue",
+            remote_interface_ip="10.0.0.99",  # Changed
+        )
+
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_no_restart_when_remote_interface_unchanged(self, manager):
+        """Verify no restart if remote_interface_ip hasn't changed."""
+        manager._enabled = True
+        manager._mode = "immediate"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="immediate",
+            remote_interface_ip="10.0.0.50",  # Same
+        )
+
+        manager._stop.assert_not_called()
+        manager._start.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_server_mode_passes_advertise_ip_to_ssdp(self, manager):
+        """Verify _start_server_mode passes remote_interface_ip as advertise_ip to SSDP."""
+        manager._mode = "immediate"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._model = "3DPrinter-X1-Carbon"
+
+        with (
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer") as mock_ssdp_cls,
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
+            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(
+                manager._cert_service,
+                "generate_certificates",
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
+            ),
+        ):
+            mock_ssdp_cls.return_value.start = AsyncMock()
+            await manager._start_server_mode()
+
+            mock_ssdp_cls.assert_called_once_with(
+                name="Bambuddy",
+                serial=manager.printer_serial,
+                model="3DPrinter-X1-Carbon",
+                advertise_ip="10.0.0.50",
+            )
+
+    @pytest.mark.asyncio
+    async def test_server_mode_passes_additional_ips_to_certificate(self, manager):
+        """Verify _start_server_mode includes remote_interface_ip in certificate SANs."""
+        manager._mode = "immediate"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._model = "3DPrinter-X1-Carbon"
+
+        with (
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
+            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(
+                manager._cert_service,
+                "generate_certificates",
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
+            ) as mock_gen_certs,
+        ):
+            await manager._start_server_mode()
+
+            mock_gen_certs.assert_called_once_with(additional_ips=["10.0.0.50"])
+
+    @pytest.mark.asyncio
+    async def test_server_mode_no_additional_ips_without_remote_interface(self, manager):
+        """Verify _start_server_mode passes None for additional_ips when no remote interface."""
+        manager._mode = "immediate"
+        manager._access_code = "12345678"
+        manager._remote_interface_ip = ""
+        manager._model = "3DPrinter-X1-Carbon"
+
+        with (
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
+            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
+            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
+            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(
+                manager._cert_service,
+                "generate_certificates",
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
+            ) as mock_gen_certs,
+        ):
+            await manager._start_server_mode()
+
+            mock_gen_certs.assert_called_once_with(additional_ips=None)

+ 486 - 0
backend/tests/unit/test_archive_filtering.py

@@ -0,0 +1,486 @@
+"""
+Unit tests for archive filtering and timelapse snapshot-diff logic.
+
+Tests:
+1. Calibration print filtering — /usr/ prefix skips archive creation
+2. Timelapse snapshot-diff — _list_timelapse_mp4s and _scan_for_timelapse_with_retries
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# Patch paths for lazy imports inside functions
+_FTP_MODULE = "backend.app.services.bambu_ftp"
+
+
+class TestCalibrationPrintFiltering:
+    """Test that internal printer files under /usr/ are not archived."""
+
+    @pytest.mark.asyncio
+    async def test_usr_prefix_skips_archive(self, capture_logs):
+        """Calibration gcode (/usr/etc/print/auto_cali_for_user.gcode) should skip archiving."""
+        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.printer_manager") as mock_pm,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+        ):
+            mock_notif.on_print_start = AsyncMock()
+            mock_plug.on_print_start = AsyncMock()
+            mock_ws.send_print_start = AsyncMock()
+            mock_relay.on_print_start = AsyncMock()
+            mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+            # Mock printer with auto_archive enabled
+            mock_printer = MagicMock()
+            mock_printer.auto_archive = True
+            mock_printer.id = 1
+
+            mock_session = AsyncMock()
+            mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+            mock_session.__aexit__ = AsyncMock()
+            mock_session.execute = AsyncMock(
+                return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+            )
+            mock_session_maker.return_value = mock_session
+
+            # Mock _send_print_start_notification
+            with patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock) as mock_notif_send:
+                from backend.app.main import on_print_start
+
+                await on_print_start(
+                    1,
+                    {
+                        "filename": "/usr/etc/print/auto_cali_for_user.gcode",
+                        "subtask_name": "auto_cali_for_user",
+                    },
+                )
+
+                # Notification should still be sent
+                mock_notif_send.assert_called_once()
+
+        # Verify the skip was logged
+        info_messages = [r.message for r in capture_logs.records if r.levelno >= 20]
+        skip_msgs = [m for m in info_messages if "internal printer file" in str(m)]
+        assert skip_msgs, "Should log that internal printer file was skipped"
+
+    @pytest.mark.asyncio
+    async def test_usr_prefix_various_paths(self, capture_logs):
+        """Various /usr/ paths should all be skipped."""
+        test_paths = [
+            "/usr/etc/print/auto_cali_for_user.gcode",
+            "/usr/etc/print/some_other_calibration.gcode",
+            "/usr/bin/firmware_test.gcode",
+        ]
+
+        for path in test_paths:
+            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.printer_manager") as mock_pm,
+                patch("backend.app.main.mqtt_relay") as mock_relay,
+                patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+            ):
+                mock_notif.on_print_start = AsyncMock()
+                mock_plug.on_print_start = AsyncMock()
+                mock_ws.send_print_start = AsyncMock()
+                mock_relay.on_print_start = AsyncMock()
+                mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+                mock_printer = MagicMock()
+                mock_printer.auto_archive = True
+                mock_printer.id = 1
+
+                mock_session = AsyncMock()
+                mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+                mock_session.__aexit__ = AsyncMock()
+                mock_session.execute = AsyncMock(
+                    return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+                )
+                mock_session_maker.return_value = mock_session
+
+                from backend.app.main import on_print_start
+
+                await on_print_start(1, {"filename": path, "subtask_name": "test"})
+
+            skip_msgs = [r for r in capture_logs.records if "internal printer file" in str(r.message)]
+            assert skip_msgs, f"Path {path} should be skipped"
+            capture_logs.clear()
+
+    @pytest.mark.asyncio
+    async def test_normal_gcode_not_skipped(self, capture_logs):
+        """User gcode files under /data/ should NOT be skipped."""
+        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.printer_manager") as mock_pm,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+        ):
+            mock_notif.on_print_start = AsyncMock()
+            mock_plug.on_print_start = AsyncMock()
+            mock_ws.send_print_start = AsyncMock()
+            mock_relay.on_print_start = AsyncMock()
+            mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+            mock_printer = MagicMock()
+            mock_printer.auto_archive = True
+            mock_printer.id = 1
+
+            mock_session = AsyncMock()
+            mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+            mock_session.__aexit__ = AsyncMock()
+            mock_session.execute = AsyncMock(
+                return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+            )
+            mock_session_maker.return_value = mock_session
+
+            from backend.app.main import on_print_start
+
+            await on_print_start(
+                1,
+                {
+                    "filename": "/data/Metadata/benchy.gcode.3mf",
+                    "subtask_name": "benchy",
+                },
+            )
+
+        # Should NOT see "internal printer file" skip message
+        skip_msgs = [r for r in capture_logs.records if "internal printer file" in str(r.message)]
+        assert not skip_msgs, "User gcode should not be skipped"
+
+
+class TestListTimelapseMp4s:
+    """Test the _list_timelapse_mp4s helper function."""
+
+    @pytest.mark.asyncio
+    async def test_finds_mp4_files_in_timelapse_dir(self):
+        """Should return MP4 files found in /timelapse directory."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        mock_files = [
+            {"name": "video1.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/video1.mp4"},
+            {"name": "video2.mp4", "is_directory": False, "size": 2000, "path": "/timelapse/video2.mp4"},
+            {"name": "thumbs", "is_directory": True, "size": 0, "path": "/timelapse/thumbs"},
+            {"name": "video3.avi", "is_directory": False, "size": 500, "path": "/timelapse/video3.avi"},
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert len(mp4s) == 2
+        assert path == "/timelapse"
+        assert all(f["name"].endswith(".mp4") for f in mp4s)
+
+    @pytest.mark.asyncio
+    async def test_tries_multiple_directories(self):
+        """Should try /timelapse, /timelapse/video, /record, /recording."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "H2D"
+
+        async def mock_list_files(ip, code, path, printer_model=None):
+            if path == "/record":
+                return [{"name": "clip.mp4", "is_directory": False, "size": 500, "path": "/record/clip.mp4"}]
+            return []
+
+        with patch(f"{_FTP_MODULE}.list_files_async", side_effect=mock_list_files):
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert len(mp4s) == 1
+        assert path == "/record"
+        assert mp4s[0]["name"] == "clip.mp4"
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_no_files(self):
+        """Should return ([], None) when no MP4 files exist."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = []
+
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert mp4s == []
+        assert path is None
+
+    @pytest.mark.asyncio
+    async def test_skips_directories(self):
+        """Should filter out directory entries even if named .mp4."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        mock_files = [
+            {"name": "fake.mp4", "is_directory": True, "size": 0, "path": "/timelapse/fake.mp4"},
+            {"name": "real.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/real.mp4"},
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert len(mp4s) == 1
+        assert mp4s[0]["name"] == "real.mp4"
+
+
+class TestScanForTimelapseWithRetries:
+    """Test the snapshot-diff timelapse scan logic."""
+
+    def _make_mocks(self, archive_filename="benchy.gcode.3mf", timelapse_path=None):
+        """Create standard mock archive and printer."""
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.timelapse_path = timelapse_path
+        mock_archive.printer_id = 1
+        mock_archive.filename = archive_filename
+
+        mock_printer = MagicMock()
+        mock_printer.id = 1
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        return mock_archive, mock_printer
+
+    def _make_session_mock(self, mock_printer):
+        """Create a mock async session that returns the given printer."""
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_session.execute = AsyncMock(
+            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+        )
+        return mock_session
+
+    @pytest.mark.asyncio
+    async def test_detects_new_file_after_baseline(self):
+        """Should detect a file that wasn't in the baseline snapshot."""
+        mock_archive, mock_printer = self._make_mocks()
+
+        baseline_files = [
+            {"name": "old_video.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/old_video.mp4"},
+        ]
+        new_files = baseline_files + [
+            {"name": "new_video.mp4", "is_directory": False, "size": 2000, "path": "/timelapse/new_video.mp4"},
+        ]
+
+        call_count = 0
+
+        async def mock_list_mp4s(printer):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return baseline_files, "/timelapse"
+            return new_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake video data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Should have attached the NEW file, not the old one
+        mock_service.attach_timelapse.assert_called_once()
+        attached_filename = mock_service.attach_timelapse.call_args[0][2]
+        assert attached_filename == "new_video.mp4", f"Expected new_video.mp4, got {attached_filename}"
+
+    @pytest.mark.asyncio
+    async def test_ignores_old_files_with_wrong_mtime(self):
+        """Should not pick old files even if they'd sort first by mtime."""
+        mock_archive, mock_printer = self._make_mocks()
+
+        # Both old files exist at baseline — neither should be picked
+        baseline_files = [
+            {"name": "old_video1.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/old_video1.mp4"},
+            {"name": "old_video2.mp4", "is_directory": False, "size": 2000, "path": "/timelapse/old_video2.mp4"},
+        ]
+
+        # Always return same files — no new file ever appears
+        async def mock_list_mp4s(printer):
+            return baseline_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake video data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # "benchy" not in "old_video1.mp4" or "old_video2.mp4" — no match at all
+        mock_service.attach_timelapse.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_name_match_fallback(self):
+        """When no new file appears, should fall back to name matching."""
+        mock_archive, mock_printer = self._make_mocks()
+
+        baseline_files = [
+            {"name": "old_video.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/old_video.mp4"},
+            {
+                "name": "benchy_20240101.mp4",
+                "is_directory": False,
+                "size": 2000,
+                "path": "/timelapse/benchy_20240101.mp4",
+            },
+        ]
+
+        async def mock_list_mp4s(printer):
+            return baseline_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake video data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Name-match fallback: "benchy" is in "benchy_20240101.mp4"
+        mock_service.attach_timelapse.assert_called_once()
+        attached_filename = mock_service.attach_timelapse.call_args[0][2]
+        assert attached_filename == "benchy_20240101.mp4"
+
+    @pytest.mark.asyncio
+    async def test_stops_when_archive_already_has_timelapse(self):
+        """Should stop immediately if archive already has a timelapse."""
+        mock_archive, _ = self._make_mocks(timelapse_path="/some/existing/timelapse.mp4")
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+        ):
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Should not have tried to list files or sleep
+        mock_list.assert_not_called()
+        mock_sleep.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_stops_when_archive_not_found(self):
+        """Should stop immediately if archive doesn't exist."""
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=None)
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+        ):
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(999)
+
+        mock_list.assert_not_called()
+        mock_sleep.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_retries_four_times(self):
+        """Should retry with delays [5, 10, 20, 30]."""
+        mock_archive, mock_printer = self._make_mocks(archive_filename="test.gcode.3mf")
+
+        # Never find any files
+        async def mock_list_mp4s(printer):
+            return [], None
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Should have slept 4 times with delays [5, 10, 20, 30]
+        assert mock_sleep.call_count == 4
+        sleep_args = [call.args[0] for call in mock_sleep.call_args_list]
+        assert sleep_args == [5, 10, 20, 30]

+ 229 - 0
backend/tests/unit/test_homeassistant_settings.py

@@ -0,0 +1,229 @@
+"""Unit tests for Home Assistant settings with environment variable support.
+
+Tests the get_homeassistant_settings() function in isolation.
+"""
+
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_no_env_vars():
+    """Test get_homeassistant_settings with no environment variables."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    # Mock database session
+    db = AsyncMock(spec=AsyncSession)
+
+    # Mock get_setting to return database values
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "http://db-url:8123",
+            "ha_token": "db-token",
+            "ha_enabled": "true",
+        }.get(key, "")
+
+        # Ensure no env vars
+        with patch.dict(os.environ, {}, clear=False):
+            os.environ.pop("HA_URL", None)
+            os.environ.pop("HA_TOKEN", None)
+
+            result = await get_homeassistant_settings(db)
+
+            # Should use database values
+            assert result["ha_url"] == "http://db-url:8123"
+            assert result["ha_token"] == "db-token"
+            assert result["ha_enabled"] is True
+            assert result["ha_url_from_env"] is False
+            assert result["ha_token_from_env"] is False
+            assert result["ha_env_managed"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_with_env_vars():
+    """Test get_homeassistant_settings with environment variables set."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    db = AsyncMock(spec=AsyncSession)
+
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "http://db-url:8123",
+            "ha_token": "db-token",
+            "ha_enabled": "false",
+        }.get(key, "")
+
+        # Set environment variables
+        with patch.dict(os.environ, {"HA_URL": "http://supervisor/core", "HA_TOKEN": "env-token"}, clear=False):
+            result = await get_homeassistant_settings(db)
+
+            # Should use environment values
+            assert result["ha_url"] == "http://supervisor/core"
+            assert result["ha_token"] == "env-token"
+            assert result["ha_enabled"] is True  # Auto-enabled
+            assert result["ha_url_from_env"] is True
+            assert result["ha_token_from_env"] is True
+            assert result["ha_env_managed"] is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_partial_env_url_only():
+    """Test get_homeassistant_settings with only HA_URL set."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    db = AsyncMock(spec=AsyncSession)
+
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "http://db-url:8123",
+            "ha_token": "db-token",
+            "ha_enabled": "false",
+        }.get(key, "")
+
+        # Set only URL env var
+        with patch.dict(os.environ, {"HA_URL": "http://supervisor/core"}, clear=False):
+            os.environ.pop("HA_TOKEN", None)
+
+            result = await get_homeassistant_settings(db)
+
+            # URL from env, token from database
+            assert result["ha_url"] == "http://supervisor/core"
+            assert result["ha_token"] == "db-token"
+            assert result["ha_enabled"] is False  # Not auto-enabled
+            assert result["ha_url_from_env"] is True
+            assert result["ha_token_from_env"] is False
+            assert result["ha_env_managed"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_partial_env_token_only():
+    """Test get_homeassistant_settings with only HA_TOKEN set."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    db = AsyncMock(spec=AsyncSession)
+
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "http://db-url:8123",
+            "ha_token": "db-token",
+            "ha_enabled": "false",
+        }.get(key, "")
+
+        # Set only token env var
+        with patch.dict(os.environ, {"HA_TOKEN": "env-token"}, clear=False):
+            os.environ.pop("HA_URL", None)
+
+            result = await get_homeassistant_settings(db)
+
+            # URL from database, token from env
+            assert result["ha_url"] == "http://db-url:8123"
+            assert result["ha_token"] == "env-token"
+            assert result["ha_enabled"] is False  # Not auto-enabled
+            assert result["ha_url_from_env"] is False
+            assert result["ha_token_from_env"] is True
+            assert result["ha_env_managed"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_empty_env_vars():
+    """Test get_homeassistant_settings with empty environment variables."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    db = AsyncMock(spec=AsyncSession)
+
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "http://db-url:8123",
+            "ha_token": "db-token",
+            "ha_enabled": "false",
+        }.get(key, "")
+
+        # Set empty env vars
+        with patch.dict(os.environ, {"HA_URL": "", "HA_TOKEN": ""}, clear=False):
+            result = await get_homeassistant_settings(db)
+
+            # Empty env vars treated as not set, should use database values
+            assert result["ha_url"] == "http://db-url:8123"
+            assert result["ha_token"] == "db-token"
+            assert result["ha_enabled"] is False
+            assert result["ha_url_from_env"] is False
+            assert result["ha_token_from_env"] is False
+            assert result["ha_env_managed"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_auto_enable_logic():
+    """Test auto-enable behavior with various configurations."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    db = AsyncMock(spec=AsyncSession)
+
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        # Database has ha_enabled=false
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "",
+            "ha_token": "",
+            "ha_enabled": "false",
+        }.get(key, "")
+
+        # Test 1: No env vars - use database enabled state
+        with patch.dict(os.environ, {}, clear=False):
+            os.environ.pop("HA_URL", None)
+            os.environ.pop("HA_TOKEN", None)
+
+            result = await get_homeassistant_settings(db)
+            assert result["ha_enabled"] is False
+
+        # Test 2: Both env vars set - auto-enable
+        with patch.dict(os.environ, {"HA_URL": "http://test", "HA_TOKEN": "token"}, clear=False):
+            result = await get_homeassistant_settings(db)
+            assert result["ha_enabled"] is True
+
+        # Test 3: Only URL - use database enabled state
+        with patch.dict(os.environ, {"HA_URL": "http://test"}, clear=False):
+            os.environ.pop("HA_TOKEN", None)
+
+            result = await get_homeassistant_settings(db)
+            assert result["ha_enabled"] is False
+
+        # Test 4: Only token - use database enabled state
+        with patch.dict(os.environ, {"HA_TOKEN": "token"}, clear=False):
+            os.environ.pop("HA_URL", None)
+
+            result = await get_homeassistant_settings(db)
+            assert result["ha_enabled"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_get_homeassistant_settings_env_vars_override_enabled_true():
+    """Test that env vars auto-enable even when database has ha_enabled=true."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    db = AsyncMock(spec=AsyncSession)
+
+    with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
+        # Database has ha_enabled=true
+        mock_get_setting.side_effect = lambda db, key: {
+            "ha_url": "http://db-url:8123",
+            "ha_token": "db-token",
+            "ha_enabled": "true",
+        }.get(key, "")
+
+        # Both env vars set - should still be enabled
+        with patch.dict(os.environ, {"HA_URL": "http://supervisor/core", "HA_TOKEN": "env-token"}, clear=False):
+            result = await get_homeassistant_settings(db)
+
+            assert result["ha_enabled"] is True  # Auto-enabled by env vars
+            assert result["ha_url"] == "http://supervisor/core"
+            assert result["ha_token"] == "env-token"
+            assert result["ha_env_managed"] is True

+ 341 - 0
backend/tests/unit/test_orca_profiles.py

@@ -0,0 +1,341 @@
+"""Unit tests for OrcaSlicer profile import service.
+
+Tests _guess_profile_type, _parse_material_from_name, _parse_vendor_from_name,
+and extract_core_fields.
+"""
+
+import json
+
+import pytest
+
+
+class TestGuessProfileType:
+    """Tests for _guess_profile_type()."""
+
+    def test_explicit_filament_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "filament", "name": "Some Filament"}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_explicit_process_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "process", "name": "0.20mm Standard"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_explicit_machine_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "machine", "name": "Bambu Lab X1C"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_explicit_printer_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "printer", "name": "Bambu Lab X1C"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_explicit_print_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "print", "name": "0.20mm Standard"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_path_hint_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Some Preset"}
+        assert _guess_profile_type(data, path_hint="filament/MyPreset.json") == "filament"
+
+    def test_path_hint_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Some Preset"}
+        assert _guess_profile_type(data, path_hint="process/MyProcess.json") == "process"
+
+    def test_path_hint_machine(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Some Preset"}
+        assert _guess_profile_type(data, path_hint="machine/MyPrinter.json") == "printer"
+
+    def test_print_settings_id_indicates_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "# 0.08mm Extra Fine @BBL H2D", "print_settings_id": "# 0.08mm Extra Fine @BBL H2D"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_filament_settings_id_indicates_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "eSUN PLA", "filament_settings_id": "eSUN PLA"}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_printer_settings_id_indicates_printer(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Bambu Lab X1C", "printer_settings_id": "Bambu Lab X1C"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_prime_tower_keys_indicate_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {
+            "name": "# 0.16mm High Quality",
+            "prime_tower_width": "20",
+            "prime_tower_max_speed": "100",
+        }
+        assert _guess_profile_type(data) == "process"
+
+    def test_outer_wall_speed_indicates_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "H2D eSUN PETG Process", "outer_wall_speed": ["150"]}
+        assert _guess_profile_type(data) == "process"
+
+    def test_layer_height_indicates_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Standard", "layer_height": "0.2", "first_layer_height": "0.2"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_machine_keys_indicate_printer(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "My Printer", "machine_max_speed_x": "500", "bed_shape": "0x0,220x0,220x220,0x220"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_filament_type_indicates_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Generic PLA", "filament_type": ["PLA"]}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_name_with_layer_height_pattern(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "0.20mm Standard @BBL X1C"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_name_ending_with_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "H2D eSUN PETG Process"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_default_to_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Unknown Preset"}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_override_keys_only_process(self):
+        """Test realistic override-only process preset (inheritance unresolved)."""
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {
+            "from": "User",
+            "inherits": "0.08mm Extra Fine @BBL H2D",
+            "name": "# 0.08mm Extra Fine @BBL H2D",
+            "prime_tower_max_speed": "100",
+            "prime_tower_rib_wall": "0",
+            "prime_tower_width": "20",
+            "print_extruder_id": ["1", "1"],
+            "print_settings_id": "# 0.08mm Extra Fine @BBL H2D",
+            "version": "2.3.0.4",
+        }
+        assert _guess_profile_type(data) == "process"
+
+
+class TestParseMaterialFromName:
+    """Tests for _parse_material_from_name()."""
+
+    def test_pla_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("Overture PLA Matte @BBL X1C") == "PLA"
+
+    def test_abs_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("CR3D ABS+ @Bambu Lab X1 Carbon") == "ABS"
+
+    def test_petg_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("eSUN PETG Silk @Bambu Lab X1 Carbon") == "PETG"
+
+    def test_tpu_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("Sunlu TPU @Bambu Lab X1 Carbon") == "TPU"
+
+    def test_no_material_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("# 0.20mm Standard @BBL X1C") is None
+
+    def test_material_word_boundary(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        # "PLA" should match as a word, not inside "DISPLAY"
+        assert _parse_material_from_name("Bambu PLA Basic @BBL X1C") == "PLA"
+
+    def test_asa_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("Bambu ASA-CF @BBL H2D") == "ASA"
+
+    def test_pa_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        # "PA12" doesn't match \bPA\b because 1 is a word char — PA needs word boundary
+        assert _parse_material_from_name("Fiberlogy PA12+CF15") is None
+        assert _parse_material_from_name("Fiberlogy PA @BBL X1C") == "PA"
+
+
+class TestParseVendorFromName:
+    """Tests for _parse_vendor_from_name()."""
+
+    def test_overture_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("Overture PLA Matte @BBL X1C") == "Overture"
+
+    def test_esun_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("eSUN PETG @Bambu Lab H2D") == "eSUN"
+
+    def test_bambu_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("Bambu PLA Basic @BBL X1C") == "Bambu"
+
+    def test_devil_design_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("Devil Design PLA @Bambu Lab X1 Carbon") == "Devil Design"
+
+    def test_no_vendor_process_name(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("# 0.20mm Standard @BBL X1C") is None
+
+    def test_strips_at_suffix(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        # Should strip @BBL X1C before parsing
+        result = _parse_vendor_from_name("Azurefilm PLA Wood @Bambu Lab H2D 0.4 nozzle")
+        assert result == "Azurefilm"
+
+    def test_single_char_vendor_rejected(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        # Vendor must be >1 char
+        assert _parse_vendor_from_name("X PLA") is None
+
+
+class TestExtractCoreFields:
+    """Tests for extract_core_fields()."""
+
+    def test_filament_type_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_type": ["PLA"]})
+        assert core["filament_type"] == "PLA"
+
+    def test_filament_type_string(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_type": "ABS"})
+        assert core["filament_type"] == "ABS"
+
+    def test_filament_vendor_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_vendor": ["Bambu Lab"]})
+        assert core["filament_vendor"] == "Bambu Lab"
+
+    def test_nozzle_temp_from_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"nozzle_temperature": ["220"]})
+        assert core["nozzle_temp_min"] == 220
+        assert core["nozzle_temp_max"] == 220
+
+    def test_nozzle_temp_range_override(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields(
+            {
+                "nozzle_temperature": ["220"],
+                "nozzle_temperature_range_low": ["190"],
+                "nozzle_temperature_range_high": ["230"],
+            }
+        )
+        assert core["nozzle_temp_min"] == 190
+        assert core["nozzle_temp_max"] == 230
+
+    def test_pressure_advance_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"pressure_advance": ["0.04"]})
+        assert core["pressure_advance"] == json.dumps(["0.04"])
+
+    def test_default_filament_colour(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"default_filament_colour": ["#FFAA00"]})
+        assert "#FFAA00" in core["default_filament_colour"]
+
+    def test_filament_cost_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_cost": ["24.99"]})
+        assert core["filament_cost"] == "24.99"
+
+    def test_filament_density(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_density": ["1.24"]})
+        assert core["filament_density"] == "1.24"
+
+    def test_compatible_printers(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"compatible_printers": ["Bambu Lab X1 Carbon", "Bambu Lab P1S"]})
+        parsed = json.loads(core["compatible_printers"])
+        assert "Bambu Lab X1 Carbon" in parsed
+        assert "Bambu Lab P1S" in parsed
+
+    def test_empty_data(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({})
+        assert core == {}
+
+    def test_full_resolved_preset(self):
+        """Test extraction from a realistic fully resolved preset."""
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        data = {
+            "filament_type": ["PETG"],
+            "filament_vendor": ["eSUN"],
+            "nozzle_temperature": ["240"],
+            "nozzle_temperature_range_low": ["220"],
+            "nozzle_temperature_range_high": ["260"],
+            "pressure_advance": ["0.035"],
+            "default_filament_colour": ["#4A90D9"],
+            "filament_cost": ["19.99"],
+            "filament_density": ["1.27"],
+            "compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle"],
+        }
+        core = extract_core_fields(data)
+        assert core["filament_type"] == "PETG"
+        assert core["filament_vendor"] == "eSUN"
+        assert core["nozzle_temp_min"] == 220
+        assert core["nozzle_temp_max"] == 260
+        assert core["filament_cost"] == "19.99"
+        assert core["filament_density"] == "1.27"

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

@@ -129,7 +129,7 @@ class TestBuildLoadedFilaments:
         result = scheduler._build_loaded_filaments(MockStatus())
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
         assert len(result) == 1
         assert result[0]["is_ht"] is True
         assert result[0]["is_ht"] is True
-        assert result[0]["global_tray_id"] == 512  # 128 * 4 + 0
+        assert result[0]["global_tray_id"] == 128  # AMS-HT uses ams_id directly
 
 
     def test_build_loaded_filaments_with_external(self, scheduler):
     def test_build_loaded_filaments_with_external(self, scheduler):
         """Should include external spool."""
         """Should include external spool."""

+ 48 - 0
backend/tests/unit/test_slicer_settings.py

@@ -0,0 +1,48 @@
+"""Unit tests for preferred_slicer setting in AppSettings schema."""
+
+import pytest
+
+from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+
+
+@pytest.mark.unit
+class TestPreferredSlicerSchema:
+    """Tests for the preferred_slicer field in settings schemas."""
+
+    def test_default_value_is_bambu_studio(self):
+        """Default preferred_slicer should be bambu_studio."""
+        settings = AppSettings()
+        assert settings.preferred_slicer == "bambu_studio"
+
+    def test_set_to_orcaslicer(self):
+        """Should accept orcaslicer as a valid value."""
+        settings = AppSettings(preferred_slicer="orcaslicer")
+        assert settings.preferred_slicer == "orcaslicer"
+
+    def test_set_to_bambu_studio_explicit(self):
+        """Should accept bambu_studio as an explicit value."""
+        settings = AppSettings(preferred_slicer="bambu_studio")
+        assert settings.preferred_slicer == "bambu_studio"
+
+    def test_update_schema_default_is_none(self):
+        """AppSettingsUpdate preferred_slicer should default to None."""
+        update = AppSettingsUpdate()
+        assert update.preferred_slicer is None
+
+    def test_update_schema_accepts_value(self):
+        """AppSettingsUpdate should accept a preferred_slicer value."""
+        update = AppSettingsUpdate(preferred_slicer="orcaslicer")
+        assert update.preferred_slicer == "orcaslicer"
+
+    def test_serialization_roundtrip(self):
+        """Settings should survive serialization roundtrip."""
+        settings = AppSettings(preferred_slicer="orcaslicer")
+        data = settings.model_dump()
+        restored = AppSettings(**data)
+        assert restored.preferred_slicer == "orcaslicer"
+
+    def test_partial_update_preserves_other_fields(self):
+        """Updating preferred_slicer should not affect other fields."""
+        update = AppSettingsUpdate(preferred_slicer="orcaslicer")
+        data = update.model_dump(exclude_none=True)
+        assert data == {"preferred_slicer": "orcaslicer"}

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

@@ -0,0 +1,428 @@
+"""Unit tests for support module helper functions.
+
+Tests _anonymize_mqtt_broker, _check_port, _get_container_memory_limit,
+_format_bytes, and _collect_support_info diagnostic sections.
+"""
+
+import asyncio
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestAnonymizeMqttBroker:
+    """Tests for _anonymize_mqtt_broker()."""
+
+    def test_empty_string(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("") == ""
+
+    def test_ipv4_address(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("192.168.1.100") == "[IP]"
+
+    def test_ipv6_address(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("::1") == "[IP]"
+
+    def test_hostname_with_domain(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("mqtt.example.com") == "*.example.com"
+
+    def test_hostname_with_subdomain(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("broker.mqtt.example.com") == "*.example.com"
+
+    def test_single_part_hostname(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("localhost") == "localhost"
+
+
+class TestCheckPort:
+    """Tests for _check_port()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_reachable_port(self):
+        from backend.app.api.routes.support import _check_port
+
+        # Mock a successful connection
+        mock_writer = AsyncMock()
+        mock_writer.close = MagicMock()
+        mock_writer.wait_closed = AsyncMock()
+
+        with patch("backend.app.api.routes.support.asyncio.open_connection", return_value=(AsyncMock(), mock_writer)):
+            result = await _check_port("192.168.1.1", 8883, timeout=1.0)
+
+        assert result is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_unreachable_port(self):
+        from backend.app.api.routes.support import _check_port
+
+        with (
+            patch(
+                "backend.app.api.routes.support.asyncio.open_connection",
+                side_effect=ConnectionRefusedError,
+            ),
+            patch(
+                "backend.app.api.routes.support.asyncio.wait_for",
+                side_effect=ConnectionRefusedError,
+            ),
+        ):
+            result = await _check_port("192.168.1.1", 8883, timeout=1.0)
+
+        assert result is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_timeout(self):
+        from backend.app.api.routes.support import _check_port
+
+        with patch(
+            "backend.app.api.routes.support.asyncio.wait_for",
+            side_effect=asyncio.TimeoutError,
+        ):
+            result = await _check_port("192.168.1.1", 8883, timeout=0.1)
+
+        assert result is False
+
+
+class TestGetContainerMemoryLimit:
+    """Tests for _get_container_memory_limit()."""
+
+    def test_cgroup_v2_with_limit(self):
+        from backend.app.api.routes.support import _get_container_memory_limit
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            v2_path = Path(tmpdir) / "memory.max"
+            v2_path.write_text("1073741824\n")
+
+            with patch("backend.app.api.routes.support.Path") as mock_path:
+                # v2 path exists with value
+                v2_mock = MagicMock()
+                v2_mock.exists.return_value = True
+                v2_mock.read_text.return_value = "1073741824\n"
+
+                v1_mock = MagicMock()
+                v1_mock.exists.return_value = False
+
+                mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
+
+                result = _get_container_memory_limit()
+
+        assert result == 1073741824
+
+    def test_cgroup_v2_unlimited(self):
+        from backend.app.api.routes.support import _get_container_memory_limit
+
+        with patch("backend.app.api.routes.support.Path") as mock_path:
+            v2_mock = MagicMock()
+            v2_mock.exists.return_value = True
+            v2_mock.read_text.return_value = "max\n"
+
+            v1_mock = MagicMock()
+            v1_mock.exists.return_value = False
+
+            mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
+
+            result = _get_container_memory_limit()
+
+        assert result is None
+
+    def test_no_cgroup_files(self):
+        from backend.app.api.routes.support import _get_container_memory_limit
+
+        with patch("backend.app.api.routes.support.Path") as mock_path:
+            mock_instance = MagicMock()
+            mock_instance.exists.return_value = False
+            mock_path.return_value = mock_instance
+
+            result = _get_container_memory_limit()
+
+        assert result is None
+
+
+class TestFormatBytes:
+    """Tests for _format_bytes()."""
+
+    def test_bytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(500) == "500 B"
+
+    def test_kilobytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(2048) == "2.0 KB"
+
+    def test_megabytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(10 * 1024 * 1024) == "10.0 MB"
+
+    def test_gigabytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(2 * 1024 * 1024 * 1024) == "2.00 GB"
+
+    def test_zero(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(0) == "0 B"
+
+
+class TestCollectSupportInfo:
+    """Tests for _collect_support_info() new diagnostic sections."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_environment_has_timezone(self):
+        """Verify environment section includes timezone."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+            patch.dict("os.environ", {"TZ": "America/New_York"}),
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert info["environment"]["timezone"] == "America/New_York"
+        assert info["environment"]["docker"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_docker_section_present_when_in_docker(self):
+        """Verify docker section is added when running in Docker."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
+            patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch(
+                "backend.app.api.routes.support.get_network_interfaces",
+                return_value=[{"name": "eth0", "subnet": "172.17.0.0/16"}],
+            ),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert "docker" in info
+        assert info["docker"]["container_memory_limit_bytes"] == 1073741824
+        assert info["docker"]["container_memory_limit_formatted"] == "1.00 GB"
+        assert info["docker"]["network_mode_hint"] == "bridge"
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_docker_section_absent_when_not_docker(self):
+        """Verify docker section is absent when not in Docker."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert "docker" not in info
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_dependencies_section(self):
+        """Verify dependencies section lists package versions."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert "dependencies" in info
+        # fastapi should be installed in test environment
+        assert "fastapi" in info["dependencies"]
+        assert info["dependencies"]["fastapi"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_websockets_section(self):
+        """Verify websockets section shows connection count."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = ["conn1", "conn2"]
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert info["websockets"]["active_connections"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_network_section(self):
+        """Verify network section shows interface subnets."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        mock_interfaces = [
+            {"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"},
+            {"name": "wlan0", "ip": "10.0.0.50", "netmask": "255.255.255.0", "subnet": "10.0.0.0/24"},
+        ]
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=mock_interfaces),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert info["network"]["interface_count"] == 2
+        assert info["network"]["interfaces"][0]["name"] == "eth0"
+        assert info["network"]["interfaces"][0]["subnet"] == "192.168.1.0/24"
+        # Verify IP addresses are NOT included
+        for iface in info["network"]["interfaces"]:
+            assert "ip" not in iface
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_log_file_section(self):
+        """Verify log file section shows size info."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_dir = Path(tmpdir)
+            log_file = log_dir / "bambuddy.log"
+            log_file.write_text("some log content\n" * 100)
+
+            with (
+                patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+                patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+                patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+                patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+                patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+                patch("backend.app.api.routes.support.settings") as mock_settings,
+            ):
+                mock_settings.base_dir = Path(tmpdir)
+                mock_settings.log_dir = log_dir
+                mock_settings.debug = False
+                mock_pm.get_all_statuses.return_value = {}
+                mock_ws.active_connections = []
+
+                mock_db = AsyncMock()
+                mock_result = MagicMock()
+                mock_result.scalar.return_value = 0
+                mock_result.scalar_one_or_none.return_value = None
+                mock_result.scalars.return_value.all.return_value = []
+                mock_db.execute = AsyncMock(return_value=mock_result)
+
+                mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+                mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+                info = await _collect_support_info()
+
+        assert "log_file" in info
+        assert info["log_file"]["size_bytes"] > 0
+        assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]

+ 13 - 1
docker-compose.yml

@@ -10,6 +10,11 @@ services:
     # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
     # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
     user: "${PUID:-1000}:${PGID:-1000}"
     user: "${PUID:-1000}:${PGID:-1000}"
     #
     #
+    # Proxy mode: allow binding to port 990 (FTP) as non-root user.
+    # Without this, the FTP proxy silently fails and sending prints won't work.
+    cap_add:
+      - NET_BIND_SERVICE
+    #
     # LINUX: Use host mode for printer discovery and camera streaming
     # LINUX: Use host mode for printer discovery and camera streaming
     network_mode: host
     network_mode: host
     #
     #
@@ -18,6 +23,9 @@ services:
     # Note: Printer discovery won't work - add printers manually by IP.
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #ports:
     #  - "${PORT:-8000}:8000"
     #  - "${PORT:-8000}:8000"
+    #  - "8883:8883"                  # Virtual printer MQTT
+    #  - "9990:9990"                  # Virtual printer FTP control
+    #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs
@@ -26,10 +34,14 @@ services:
       # This ensures the slicer only needs to trust one CA certificate.
       # This ensures the slicer only needs to trust one CA certificate.
       - ./virtual_printer:/app/data/virtual_printer
       - ./virtual_printer:/app/data/virtual_printer
     environment:
     environment:
-      - TZ=Europe/Berlin
+      - TZ=${TZ:-Europe/Berlin}
       # Port BamBuddy runs on (default: 8000)
       # Port BamBuddy runs on (default: 8000)
       # Usage: PORT=8080 docker compose up -d
       # Usage: PORT=8080 docker compose up -d
       - PORT=${PORT:-8000}
       - PORT=${PORT:-8000}
+      # Virtual printer: Set to the Docker host's IP when using bridge mode (ports:).
+      # Required for FTP passive mode to work behind NAT.
+      # Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100
+      #- VIRTUAL_PRINTER_PASV_ADDRESS=
     restart: unless-stopped
     restart: unless-stopped
 
 
 volumes:
 volumes:

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


+ 4 - 1
frontend/package.json

@@ -17,6 +17,7 @@
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@dnd-kit/utilities": "^3.2.2",
+    "@floating-ui/dom": "^1.7.5",
     "@tanstack/react-query": "^5.90.11",
     "@tanstack/react-query": "^5.90.11",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",
@@ -28,11 +29,13 @@
     "@tiptap/starter-kit": "^3.11.1",
     "@tiptap/starter-kit": "^3.11.1",
     "@types/three": "^0.181.0",
     "@types/three": "^0.181.0",
     "gcode-preview": "^2.18.0",
     "gcode-preview": "^2.18.0",
-    "i18next": "^25.6.3",
+    "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-http-backend": "^3.0.2",
     "i18next-http-backend": "^3.0.2",
+    "install": "^0.13.0",
     "jszip": "^3.10.1",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
     "lucide-react": "^0.555.0",
+    "npm": "^11.9.0",
     "react": "^19.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
     "react-i18next": "^16.3.5",

BIN
frontend/public/img/printers/h2c.png


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

@@ -0,0 +1,182 @@
+/**
+ * Tests for AddPrinterModal discovery subnet auto-detection.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: null,
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockPrinterStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+};
+
+describe('AddPrinterModal Discovery', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json(mockPrinterStatus);
+      }),
+      http.get('/api/v1/queue/', () => {
+        return HttpResponse.json([]);
+      })
+    );
+  });
+
+  it('auto-populates subnet from discovery info in Docker mode', async () => {
+    server.use(
+      http.get('/api/v1/discovery/info', () => {
+        return HttpResponse.json({
+          is_docker: true,
+          ssdp_running: false,
+          scan_running: false,
+          subnets: ['10.0.0.0/24'],
+        });
+      })
+    );
+
+    render(<PrintersPage />);
+
+    // Wait for printer page to load
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+
+    // Click the Add Printer button
+    const addButton = screen.getByText(/add printer/i);
+    await userEvent.click(addButton);
+
+    // Wait for the modal and discovery info to load
+    await waitFor(() => {
+      // Should show subnet dropdown with detected subnet
+      const subnetSelect = screen.getByDisplayValue('10.0.0.0/24');
+      expect(subnetSelect).toBeInTheDocument();
+    });
+  });
+
+  it('shows dropdown when multiple subnets detected in Docker mode', async () => {
+    server.use(
+      http.get('/api/v1/discovery/info', () => {
+        return HttpResponse.json({
+          is_docker: true,
+          ssdp_running: false,
+          scan_running: false,
+          subnets: ['192.168.1.0/24', '10.0.0.0/24'],
+        });
+      })
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+
+    const addButton = screen.getByText(/add printer/i);
+    await userEvent.click(addButton);
+
+    await waitFor(() => {
+      // Should show a select element (dropdown) with both subnets
+      const selectElement = screen.getByDisplayValue('192.168.1.0/24');
+      expect(selectElement.tagName).toBe('SELECT');
+
+      // Both options should be available
+      const options = selectElement.querySelectorAll('option');
+      expect(options).toHaveLength(2);
+      expect(options[0].textContent).toBe('192.168.1.0/24');
+      expect(options[1].textContent).toBe('10.0.0.0/24');
+    });
+  });
+
+  it('shows text input when no subnets detected in Docker mode', async () => {
+    server.use(
+      http.get('/api/v1/discovery/info', () => {
+        return HttpResponse.json({
+          is_docker: true,
+          ssdp_running: false,
+          scan_running: false,
+          subnets: [],
+        });
+      })
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+
+    const addButton = screen.getByText(/add printer/i);
+    await userEvent.click(addButton);
+
+    await waitFor(() => {
+      // Should show a text input with placeholder
+      const textInput = screen.getByPlaceholderText('192.168.1.0/24');
+      expect(textInput).toBeInTheDocument();
+      expect(textInput.tagName).toBe('INPUT');
+    });
+  });
+
+  it('does not show subnet field in non-Docker mode', async () => {
+    server.use(
+      http.get('/api/v1/discovery/info', () => {
+        return HttpResponse.json({
+          is_docker: false,
+          ssdp_running: false,
+          scan_running: false,
+          subnets: ['192.168.1.0/24'],
+        });
+      })
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+
+    const addButton = screen.getByText(/add printer/i);
+    await userEvent.click(addButton);
+
+    await waitFor(() => {
+      // Should show the discover button but NOT the subnet field
+      expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
+    });
+
+    // Subnet field should not exist
+    expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();
+    expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();
+  });
+});

+ 168 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -0,0 +1,168 @@
+/**
+ * Tests for the FilamentHoverCard component.
+ * Focuses on fill level display and Spoolman source indicator.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '../utils';
+import { FilamentHoverCard } from '../../components/FilamentHoverCard';
+
+const baseFilamentData = {
+  vendor: 'Bambu Lab' as const,
+  profile: 'PLA Basic',
+  colorName: 'Red',
+  colorHex: 'FF0000',
+  kFactor: '0.030',
+  fillLevel: 75,
+  trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+};
+
+function renderWithHover(ui: React.ReactElement) {
+  const result = render(ui);
+  // Trigger hover to show the card
+  const trigger = result.container.firstElementChild as HTMLElement;
+  fireEvent.mouseEnter(trigger);
+  return result;
+}
+
+describe('FilamentHoverCard', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+
+  describe('fill level display', () => {
+    it('shows fill percentage when fillLevel is set', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('75%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows dash when fillLevel is null', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('—')).toBeInTheDocument();
+      });
+    });
+
+    it('shows 0% when fillLevel is zero', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('0%')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('Spoolman source indicator', () => {
+    it('shows Spoolman label when fillSource is spoolman', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('(Spoolman)')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillSource is ams', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'ams' }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('80%')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillLevel is null', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('—')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillSource is undefined', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 50 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('50%')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('hover behavior', () => {
+    it('does not show card when disabled', () => {
+      renderWithHover(
+        <FilamentHoverCard data={baseFilamentData} disabled>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      // Card should not be visible
+      expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
+    });
+
+    it('shows filament details on hover', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={baseFilamentData}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('Red')).toBeInTheDocument();
+        expect(screen.getByText('PLA Basic')).toBeInTheDocument();
+        expect(screen.getByText('0.030')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 194 - 0
frontend/src/__tests__/components/LocalProfilesView.test.tsx

@@ -0,0 +1,194 @@
+/**
+ * Tests for LocalProfilesView component.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { LocalProfilesView } from '../../components/LocalProfilesView';
+
+const mockLocalPresets = {
+  filament: [
+    {
+      id: 1,
+      name: 'Overture PLA Matte @BBL X1C',
+      preset_type: 'filament',
+      source: 'orcaslicer',
+      filament_type: 'PLA',
+      filament_vendor: 'Overture',
+      nozzle_temp_min: 190,
+      nozzle_temp_max: 230,
+      pressure_advance: '["0.04"]',
+      default_filament_colour: '["#FFAA00"]',
+      filament_cost: '24.99',
+      filament_density: '1.24',
+      compatible_printers: '["Bambu Lab X1 Carbon 0.4 nozzle"]',
+      inherits: 'Bambu PLA Basic @BBL X1C',
+      version: '2.3.0.4',
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    },
+    {
+      id: 2,
+      name: 'eSUN PETG @Bambu Lab H2D',
+      preset_type: 'filament',
+      source: 'orcaslicer',
+      filament_type: 'PETG',
+      filament_vendor: null,
+      nozzle_temp_min: 220,
+      nozzle_temp_max: 250,
+      pressure_advance: null,
+      default_filament_colour: null,
+      filament_cost: null,
+      filament_density: null,
+      compatible_printers: null,
+      inherits: null,
+      version: null,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    },
+  ],
+  process: [
+    {
+      id: 3,
+      name: '0.20mm Standard @BBL X1C',
+      preset_type: 'process',
+      source: 'orcaslicer',
+      filament_type: null,
+      filament_vendor: null,
+      nozzle_temp_min: null,
+      nozzle_temp_max: null,
+      pressure_advance: null,
+      default_filament_colour: null,
+      filament_cost: null,
+      filament_density: null,
+      compatible_printers: null,
+      inherits: '0.20mm Standard @BBL X1C',
+      version: '2.3.0.4',
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    },
+  ],
+  printer: [],
+};
+
+describe('LocalProfilesView', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/local-presets/', () => {
+        return HttpResponse.json(mockLocalPresets);
+      }),
+      http.delete('/api/v1/local-presets/:id', () => {
+        return HttpResponse.json({ success: true });
+      }),
+    );
+  });
+
+  it('renders filament and process columns', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
+    expect(screen.getByText('0.20mm Standard @BBL X1C')).toBeInTheDocument();
+  });
+
+  it('shows material badges from filament_type', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    // PLA badge should appear for the first preset
+    const plaBadges = screen.getAllByText('PLA');
+    expect(plaBadges.length).toBeGreaterThan(0);
+  });
+
+  it('shows vendor from filament_vendor field', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture')).toBeInTheDocument();
+    });
+  });
+
+  it('parses vendor from name when filament_vendor is null', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
+    });
+
+    // eSUN should be parsed from the name
+    expect(screen.getByText('eSUN')).toBeInTheDocument();
+  });
+
+  it('filters presets by search query', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    const searchInput = screen.getByPlaceholderText(/search/i);
+    fireEvent.change(searchInput, { target: { value: 'PETG' } });
+
+    expect(screen.queryByText('Overture PLA Matte @BBL X1C')).not.toBeInTheDocument();
+    expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
+  });
+
+  it('shows empty state when no presets', async () => {
+    server.use(
+      http.get('/api/v1/local-presets/', () => {
+        return HttpResponse.json({ filament: [], process: [], printer: [] });
+      }),
+    );
+
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/no local presets/i)).toBeInTheDocument();
+    });
+  });
+
+  it('shows Local badge on preset cards', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    const badges = screen.getAllByText(/^Local$/i);
+    expect(badges.length).toBeGreaterThan(0);
+  });
+
+  it('shows delete confirmation modal', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    // Click first delete button
+    const deleteButtons = screen.getAllByTitle(/delete/i);
+    fireEvent.click(deleteButtons[0]);
+
+    await waitFor(() => {
+      expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
+    });
+  });
+
+  it('shows import zone', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/import profiles/i)).toBeInTheDocument();
+    });
+
+    expect(screen.getByText(/\.bbscfg/i)).toBeInTheDocument();
+  });
+});

+ 90 - 0
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -19,6 +19,8 @@ vi.mock('../../api/client', () => ({
   api: {
   api: {
     getSettings: vi.fn().mockResolvedValue({}),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),
   },
   },
   virtualPrinterApi: {
   virtualPrinterApi: {
     getSettings: vi.fn(),
     getSettings: vi.fn(),
@@ -570,4 +572,92 @@ describe('VirtualPrinterSettings', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('network interface override', () => {
+    it('shows interface dropdown when enabled in immediate mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, mode: 'immediate' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
+      });
+    });
+
+    it('shows interface dropdown when enabled in review mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, mode: 'review' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
+      });
+    });
+
+    it('shows interface dropdown when enabled in print_queue mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, mode: 'print_queue' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
+      });
+    });
+
+    it('shows interface dropdown when enabled in proxy mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, mode: 'proxy', target_printer_id: 1 })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
+      });
+    });
+
+    it('hides interface dropdown when disabled', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: false, mode: 'immediate' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Mode')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Network Interface Override')).not.toBeInTheDocument();
+    });
+
+    it('shows configured status when interface is set', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '10.0.0.50' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Interface override active')).toBeInTheDocument();
+      });
+    });
+
+    it('shows optional hint when no interface is set', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Optional.*auto-detected IP/)).toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 1 - 1
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -134,7 +134,7 @@ describe('buildLoadedFilaments', () => {
     const result = buildLoadedFilaments(status);
     const result = buildLoadedFilaments(status);
 
 
     expect(result[0].isHt).toBe(true);
     expect(result[0].isHt).toBe(true);
-    expect(result[0].globalTrayId).toBe(512);  // 128 * 4 + 0
+    expect(result[0].globalTrayId).toBe(128);  // AMS-HT uses ams_id directly
   });
   });
 });
 });
 
 

+ 13 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -364,6 +364,19 @@ export const handlers = [
     return new HttpResponse(null, { status: 204 });
     return new HttpResponse(null, { status: 204 });
   }),
   }),
 
 
+  // ========================================================================
+  // Discovery
+  // ========================================================================
+
+  http.get('/api/v1/discovery/info', () => {
+    return HttpResponse.json({
+      is_docker: false,
+      ssdp_running: false,
+      scan_running: false,
+      subnets: ['192.168.1.0/24'],
+    });
+  }),
+
   // ========================================================================
   // ========================================================================
   // Version / Health
   // Version / Health
   // ========================================================================
   // ========================================================================

+ 8 - 5
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -11,6 +11,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 import i18n from '../../i18n';
 import i18n from '../../i18n';
 
 
@@ -44,11 +45,13 @@ function renderCameraPage(printerId: number) {
       <I18nextProvider i18n={i18n}>
       <I18nextProvider i18n={i18n}>
         <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
         <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
           <ThemeProvider>
           <ThemeProvider>
-            <ToastProvider>
-              <Routes>
-                <Route path="/cameras/:printerId" element={<CameraPage />} />
-              </Routes>
-            </ToastProvider>
+            <AuthProvider>
+              <ToastProvider>
+                <Routes>
+                  <Route path="/cameras/:printerId" element={<CameraPage />} />
+                </Routes>
+              </ToastProvider>
+            </AuthProvider>
           </ThemeProvider>
           </ThemeProvider>
         </MemoryRouter>
         </MemoryRouter>
       </I18nextProvider>
       </I18nextProvider>

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

@@ -183,4 +183,201 @@ describe('PrintersPage', () => {
       expect(disabledPrinter).toBeInTheDocument();
       expect(disabledPrinter).toBeInTheDocument();
     });
     });
   });
   });
+
+  describe('nozzle rack card', () => {
+    const h2cStatus = {
+      ...mockPrinterStatus,
+      nozzle_rack: [
+        { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: 'SN-L', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 0, max_temp: 300, serial_number: 'SN-R', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 16, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 10, stat: 0, max_temp: 300, serial_number: 'SN-16', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 17, nozzle_type: 'HH01', nozzle_diameter: '0.6', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-17', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 18, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 2, stat: 0, max_temp: 300, serial_number: 'SN-18', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 19, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 20, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 21, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+      ],
+    };
+
+    it('shows nozzle rack when H2C rack slots present', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(h2cStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
+      });
+    });
+
+    it('shows 6 rack slot elements for H2C', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(h2cStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
+      });
+
+      // Rack shows diameters for occupied slots and dashes for empty ones
+      const dashes = screen.getAllByText('—');
+      expect(dashes.length).toBeGreaterThanOrEqual(3); // 3 empty rack positions (IDs 19,20,21)
+    });
+
+    it('hides nozzle rack when only L/R nozzles present (H2D)', async () => {
+      const h2dStatus = {
+        ...mockPrinterStatus,
+        nozzle_rack: [
+          { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+          { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        ],
+      };
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(h2dStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Nozzle Rack')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('firmware version badge', () => {
+    const firmwareUpToDate = {
+      printer_id: 1,
+      current_version: '01.09.00.00',
+      latest_version: '01.09.00.00',
+      update_available: false,
+      download_url: null,
+      release_notes: 'Bug fixes and improvements.',
+    };
+
+    const firmwareUpdateAvailable = {
+      printer_id: 1,
+      current_version: '01.08.00.00',
+      latest_version: '01.09.00.00',
+      update_available: true,
+      download_url: 'https://example.com/firmware.bin',
+      release_notes: 'New features added.',
+    };
+
+    it('shows green badge when firmware is up to date', async () => {
+      server.use(
+        http.get('/api/v1/firmware/updates/:id', () => {
+          return HttpResponse.json(firmwareUpToDate);
+        }),
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: true,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('01.09.00.00').length).toBeGreaterThan(0);
+      });
+
+      const badge = screen.getAllByText('01.09.00.00')[0].closest('button');
+      expect(badge).toBeInTheDocument();
+      expect(badge?.className).toContain('text-status-ok');
+    });
+
+    it('shows orange badge when firmware update is available', async () => {
+      server.use(
+        http.get('/api/v1/firmware/updates/:id', () => {
+          return HttpResponse.json(firmwareUpdateAvailable);
+        }),
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: true,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('01.08.00.00').length).toBeGreaterThan(0);
+      });
+
+      const badge = screen.getAllByText('01.08.00.00')[0].closest('button');
+      expect(badge).toBeInTheDocument();
+      expect(badge?.className).toContain('text-orange-400');
+    });
+
+    it('hides badge when firmware check is disabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: false,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Version should not appear when firmware check is disabled
+      expect(screen.queryByText('01.09.00.00')).not.toBeInTheDocument();
+      expect(screen.queryByText('01.08.00.00')).not.toBeInTheDocument();
+    });
+
+    it('hides badge when API has no firmware data for the model', async () => {
+      const firmwareNoData = {
+        printer_id: 1,
+        current_version: '01.01.03.00',
+        latest_version: null,
+        update_available: false,
+        download_url: null,
+        release_notes: null,
+      };
+
+      server.use(
+        http.get('/api/v1/firmware/updates/:id', () => {
+          return HttpResponse.json(firmwareNoData);
+        }),
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            check_printer_firmware: true,
+            auto_archive: true,
+            save_thumbnails: true,
+          });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Badge should not appear when API returns no latest_version
+      expect(screen.queryByText('01.01.03.00')).not.toBeInTheDocument();
+    });
+  });
 });
 });

+ 17 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -118,6 +118,23 @@ describe('SettingsPage', () => {
       });
       });
     });
     });
 
 
+    it('shows preferred slicer setting', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows slicer dropdown with both options', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
+        expect(slicerSelect.length).toBeGreaterThan(0);
+      });
+    });
+
     it('shows appearance section', async () => {
     it('shows appearance section', async () => {
       render(<SettingsPage />);
       render(<SettingsPage />);
 
 

+ 22 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -281,6 +281,28 @@ describe('SystemInfoPage', () => {
     expect(progressBars.length).toBeGreaterThan(0);
     expect(progressBars.length).toBeGreaterThan(0);
   });
   });
 
 
+  it('displays extended privacy disclosure items', async () => {
+    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
+
+    render(<SystemInfoPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText("What's in the support bundle?")).toBeInTheDocument();
+    });
+
+    // Original items
+    expect(screen.getByText(/App version and debug mode/)).toBeInTheDocument();
+    expect(screen.getByText(/Debug logs \(sanitized\)/)).toBeInTheDocument();
+
+    // New diagnostic items
+    expect(screen.getByText(/Printer connectivity and firmware versions/)).toBeInTheDocument();
+    expect(screen.getByText(/Integration status \(Spoolman, MQTT, HA\)/)).toBeInTheDocument();
+    expect(screen.getByText(/Network interfaces \(subnets only\)/)).toBeInTheDocument();
+    expect(screen.getByText(/Python package versions/)).toBeInTheDocument();
+    expect(screen.getByText(/Database health checks/)).toBeInTheDocument();
+    expect(screen.getByText(/Docker environment details/)).toBeInTheDocument();
+  });
+
   it('applies danger color for critical disk usage', async () => {
   it('applies danger color for critical disk usage', async () => {
     const criticalDiskUsage = {
     const criticalDiskUsage = {
       ...mockSystemInfo,
       ...mockSystemInfo,

+ 78 - 0
frontend/src/__tests__/utils/getSpoolmanFillLevel.test.ts

@@ -0,0 +1,78 @@
+/**
+ * Tests for getSpoolmanFillLevel helper function.
+ * This function is defined in PrintersPage.tsx but tested here for isolation.
+ * We replicate the logic to test it independently.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+// Replicate the function from PrintersPage.tsx for testing
+interface LinkedSpoolInfo {
+  id: number;
+  remaining_weight: number | null;
+  filament_weight: number | null;
+}
+
+function getSpoolmanFillLevel(
+  linkedSpool: LinkedSpoolInfo | undefined
+): number | null {
+  if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
+      || linkedSpool.filament_weight <= 0) return null;
+  return Math.min(100, Math.round(
+    (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
+  ));
+}
+
+describe('getSpoolmanFillLevel', () => {
+  it('returns null for undefined spool', () => {
+    expect(getSpoolmanFillLevel(undefined)).toBeNull();
+  });
+
+  it('returns null when remaining_weight is null', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: null, filament_weight: 1000 })).toBeNull();
+  });
+
+  it('returns null when filament_weight is null', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: null })).toBeNull();
+  });
+
+  it('returns null when remaining_weight is 0', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 0, filament_weight: 1000 })).toBeNull();
+  });
+
+  it('returns null when filament_weight is 0', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 0 })).toBeNull();
+  });
+
+  it('returns null when filament_weight is negative', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: -100 })).toBeNull();
+  });
+
+  it('calculates correct percentage for half-full spool', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 1000 })).toBe(50);
+  });
+
+  it('calculates correct percentage for full spool', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1000, filament_weight: 1000 })).toBe(100);
+  });
+
+  it('calculates correct percentage for nearly empty spool', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 50, filament_weight: 1000 })).toBe(5);
+  });
+
+  it('caps at 100% when remaining exceeds filament weight', () => {
+    // This can happen if user manually sets remaining_weight higher
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1200, filament_weight: 1000 })).toBe(100);
+  });
+
+  it('rounds to nearest integer', () => {
+    // 333/1000 = 33.3% -> 33%
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 333, filament_weight: 1000 })).toBe(33);
+    // 666/1000 = 66.6% -> 67%
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 666, filament_weight: 1000 })).toBe(67);
+  });
+
+  it('handles small weights correctly', () => {
+    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1, filament_weight: 100 })).toBe(1);
+  });
+});

+ 115 - 0
frontend/src/__tests__/utils/slicer.test.ts

@@ -0,0 +1,115 @@
+/**
+ * Tests for the slicer utility functions.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { openInSlicer, detectPlatform, buildDownloadUrl } from '../../utils/slicer';
+
+describe('slicer utility', () => {
+  let clickSpy: ReturnType<typeof vi.fn>;
+  let appendSpy: ReturnType<typeof vi.fn>;
+  let removeSpy: ReturnType<typeof vi.fn>;
+  let createdLink: HTMLAnchorElement;
+
+  beforeEach(() => {
+    clickSpy = vi.fn();
+    appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
+      createdLink = node as HTMLAnchorElement;
+      return node;
+    });
+    removeSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
+
+    // Mock click on created elements
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  describe('detectPlatform', () => {
+    it('detects Windows', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
+      expect(detectPlatform()).toBe('windows');
+    });
+
+    it('detects macOS', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
+      expect(detectPlatform()).toBe('macos');
+    });
+
+    it('detects Linux', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
+      expect(detectPlatform()).toBe('linux');
+    });
+  });
+
+  describe('openInSlicer', () => {
+    it('uses bambustudio:// protocol on Windows for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(appendSpy).toHaveBeenCalled();
+      expect(createdLink.href).toContain('bambustudio://');
+      expect(createdLink.href).toContain(encodeURIComponent('http://localhost:8000/file.3mf'));
+      expect(clickSpy).toHaveBeenCalled();
+      expect(removeSpy).toHaveBeenCalled();
+    });
+
+    it('uses bambustudioopen:// protocol on macOS for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(createdLink.href).toContain('bambustudioopen://');
+    });
+
+    it('uses bambustudioopen:// protocol on Linux for bambu_studio', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(createdLink.href).toContain('bambustudioopen://');
+    });
+
+    it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
+      openInSlicer('http://localhost:8000/file.3mf', 'orcaslicer');
+
+      expect(createdLink.href).toContain('orcaslicer://');
+      expect(createdLink.href).toContain('open?file=');
+    });
+
+    it('does not encode the file URL for orcaslicer', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      const url = 'http://localhost:8000/api/v1/archives/1/file/My Model.3mf';
+      openInSlicer(url, 'orcaslicer');
+
+      // The href should contain the raw URL (browser may normalize it but it should not be double-encoded)
+      expect(createdLink.href).toContain('orcaslicer://open?file=');
+      // Should NOT contain %253A (double-encoded colon)
+      expect(createdLink.href).not.toContain('%253A');
+    });
+
+    it('defaults to bambu_studio when no slicer specified', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf');
+
+      expect(createdLink.href).toContain('bambustudio://');
+    });
+
+    it('creates and removes a temporary link element', () => {
+      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
+      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
+
+      expect(appendSpy).toHaveBeenCalledOnce();
+      expect(clickSpy).toHaveBeenCalledOnce();
+      expect(removeSpy).toHaveBeenCalledOnce();
+    });
+  });
+
+  describe('buildDownloadUrl', () => {
+    it('prepends window.location.origin', () => {
+      const result = buildDownloadUrl('/api/v1/archives/1/file/test.3mf');
+      expect(result).toBe(`${window.location.origin}/api/v1/archives/1/file/test.3mf`);
+    });
+  });
+});

+ 183 - 4
frontend/src/api/client.ts

@@ -129,6 +129,19 @@ export interface NozzleInfo {
   nozzle_diameter: string;  // e.g., "0.4"
   nozzle_diameter: string;  // e.g., "0.4"
 }
 }
 
 
+export interface NozzleRackSlot {
+  id: number;
+  nozzle_type: string;
+  nozzle_diameter: string;
+  wear: number | null;
+  stat: number | null;  // Nozzle status (e.g. mounted/docked)
+  max_temp: number;
+  serial_number: string;
+  filament_color: string;  // RGBA hex ("00000000" = no filament)
+  filament_id: string;
+  filament_type: string;  // Material type (e.g. "PLA", "PETG")
+}
+
 export interface PrintOptions {
 export interface PrintOptions {
   // Core AI detectors
   // Core AI detectors
   spaghetti_detector: boolean;
   spaghetti_detector: boolean;
@@ -186,6 +199,7 @@ export interface PrinterStatus {
   ipcam: boolean;  // Live view enabled
   ipcam: boolean;  // Live view enabled
   wifi_signal: number | null;  // WiFi signal strength in dBm
   wifi_signal: number | null;  // WiFi signal strength in dBm
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
+  nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack
   print_options: PrintOptions | null;  // AI detection and print options
   print_options: PrintOptions | null;  // AI detection and print options
   // Calibration stage tracking
   // Calibration stage tracking
   stg_cur: number;  // Current stage number (-1 = not calibrating)
   stg_cur: number;  // Current stage number (-1 = not calibrating)
@@ -751,11 +765,16 @@ export interface AppSettings {
   ha_enabled: boolean;
   ha_enabled: boolean;
   ha_url: string;
   ha_url: string;
   ha_token: string;
   ha_token: string;
+  ha_url_from_env: boolean;
+  ha_token_from_env: boolean;
+  ha_env_managed: boolean;
   // File Manager / Library settings
   // File Manager / Library settings
   library_archive_mode: 'always' | 'never' | 'ask';
   library_archive_mode: 'always' | 'never' | 'ask';
   library_disk_warning_gb: number;
   library_disk_warning_gb: number;
   // Camera view settings
   // Camera view settings
   camera_view_mode: 'window' | 'embedded';
   camera_view_mode: 'window' | 'embedded';
+  // Preferred slicer
+  preferred_slicer: 'bambu_studio' | 'orcaslicer';
   // Prometheus metrics
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_enabled: boolean;
   prometheus_token: string;
   prometheus_token: string;
@@ -834,6 +853,44 @@ export interface SlicerSettingDeleteResponse {
   message: string;
   message: string;
 }
 }
 
 
+// Local preset types (OrcaSlicer imports)
+export interface LocalPreset {
+  id: number;
+  name: string;
+  preset_type: string;
+  source: string;
+  filament_type: string | null;
+  filament_vendor: string | null;
+  nozzle_temp_min: number | null;
+  nozzle_temp_max: number | null;
+  pressure_advance: string | null;
+  default_filament_colour: string | null;
+  filament_cost: string | null;
+  filament_density: string | null;
+  compatible_printers: string | null;
+  inherits: string | null;
+  version: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface LocalPresetDetail extends LocalPreset {
+  setting: Record<string, unknown>;
+}
+
+export interface LocalPresetsResponse {
+  filament: LocalPreset[];
+  printer: LocalPreset[];
+  process: LocalPreset[];
+}
+
+export interface ImportResponse {
+  success: boolean;
+  imported: number;
+  skipped: number;
+  errors: string[];
+}
+
 export interface FieldOption {
 export interface FieldOption {
   value: string;
   value: string;
   label: string;
   label: string;
@@ -1619,8 +1676,14 @@ export interface UnlinkedSpool {
   location: string | null;
   location: string | null;
 }
 }
 
 
+export interface LinkedSpoolInfo {
+  id: number;
+  remaining_weight: number | null;
+  filament_weight: number | null;
+}
+
 export interface LinkedSpoolsMap {
 export interface LinkedSpoolsMap {
-  linked: Record<string, number>; // tag (uppercase) -> spool_id
+  linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
 }
 }
 
 
 // Update types
 // Update types
@@ -1843,6 +1906,7 @@ export interface LoginResponse {
 export interface UserResponse {
 export interface UserResponse {
   id: number;
   id: number;
   username: string;
   username: string;
+  email?: string;
   role: string;  // Deprecated, kept for backward compatibility
   role: string;  // Deprecated, kept for backward compatibility
   is_active: boolean;
   is_active: boolean;
   is_admin: boolean;  // Computed from role and group membership
   is_admin: boolean;  // Computed from role and group membership
@@ -1853,7 +1917,8 @@ export interface UserResponse {
 
 
 export interface UserCreate {
 export interface UserCreate {
   username: string;
   username: string;
-  password: string;
+  password?: string;  // Optional when advanced auth is enabled
+  email?: string;
   role: string;
   role: string;
   group_ids?: number[];
   group_ids?: number[];
 }
 }
@@ -1861,6 +1926,7 @@ export interface UserCreate {
 export interface UserUpdate {
 export interface UserUpdate {
   username?: string;
   username?: string;
   password?: string;
   password?: string;
+  email?: string;
   role?: string;
   role?: string;
   is_active?: boolean;
   is_active?: boolean;
   group_ids?: number[];
   group_ids?: number[];
@@ -1872,6 +1938,54 @@ export interface SetupRequest {
   admin_password?: string;
   admin_password?: string;
 }
 }
 
 
+export interface ForgotPasswordRequest {
+  email: string;
+}
+
+export interface ForgotPasswordResponse {
+  message: string;
+}
+
+export interface ResetPasswordRequest {
+  user_id: number;
+}
+
+export interface ResetPasswordResponse {
+  message: string;
+}
+
+export interface SMTPSettings {
+  smtp_host: string;
+  smtp_port: number;
+  smtp_username?: string;
+  smtp_password?: string;
+  smtp_security: 'starttls' | 'ssl' | 'none';
+  smtp_auth_enabled: boolean;
+  smtp_from_email: string;
+  smtp_from_name: string;
+}
+
+export interface TestSMTPRequest {
+  smtp_host: string;
+  smtp_port: number;
+  smtp_username?: string;
+  smtp_password?: string;
+  smtp_security: 'starttls' | 'ssl' | 'none';
+  smtp_auth_enabled: boolean;
+  smtp_from_email: string;
+  test_recipient: string;
+}
+
+export interface TestSMTPResponse {
+  success: boolean;
+  message: string;
+}
+
+export interface AdvancedAuthStatus {
+  advanced_auth_enabled: boolean;
+  smtp_configured: boolean;
+}
+
 export interface SetupResponse {
 export interface SetupResponse {
   auth_enabled: boolean;
   auth_enabled: boolean;
   admin_created?: boolean;
   admin_created?: boolean;
@@ -1905,6 +2019,38 @@ export const api = {
     request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
     request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
       method: 'POST',
       method: 'POST',
     }),
     }),
+  
+  // Advanced Authentication
+  testSMTP: (data: TestSMTPRequest) =>
+    request<TestSMTPResponse>('/auth/smtp/test', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  getSMTPSettings: () => request<SMTPSettings | null>('/auth/smtp'),
+  saveSMTPSettings: (data: SMTPSettings) =>
+    request<{ message: string }>('/auth/smtp', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  enableAdvancedAuth: () =>
+    request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/enable', {
+      method: 'POST',
+    }),
+  disableAdvancedAuth: () =>
+    request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/disable', {
+      method: 'POST',
+    }),
+  getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),
+  forgotPassword: (data: ForgotPasswordRequest) =>
+    request<ForgotPasswordResponse>('/auth/forgot-password', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  resetUserPassword: (data: ResetPasswordRequest) =>
+    request<ResetPasswordResponse>('/auth/reset-password', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
 
 
   // Users
   // Users
   getUsers: () => request<UserResponse[]>('/users/'),
   getUsers: () => request<UserResponse[]>('/users/'),
@@ -2911,8 +3057,8 @@ export const api = {
     request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
     request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
   getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
   getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
     request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
     request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
-  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) =>
-    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}`, {
+  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud') =>
+    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}`, {
       method: 'PUT',
       method: 'PUT',
     }),
     }),
   deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
   deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
@@ -3638,6 +3784,38 @@ export const api = {
 
 
   clearGitHubBackupLogs: (keepLast: number = 10) =>
   clearGitHubBackupLogs: (keepLast: number = 10) =>
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
+
+  // Local Presets (OrcaSlicer imports)
+  getLocalPresets: () =>
+    request<LocalPresetsResponse>('/local-presets/'),
+  getLocalPresetDetail: (id: number) =>
+    request<LocalPresetDetail>(`/local-presets/${id}`),
+  importLocalPresets: (formData: FormData) =>
+    fetch(`${API_BASE}/local-presets/import`, {
+      method: 'POST',
+      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
+      body: formData,
+    }).then(async (res) => {
+      if (!res.ok) {
+        const err = await res.json().catch(() => ({}));
+        throw new Error(err.detail || `HTTP ${res.status}`);
+      }
+      return res.json() as Promise<ImportResponse>;
+    }),
+  createLocalPreset: (data: { name: string; preset_type: string; setting: Record<string, unknown> }) =>
+    request<LocalPreset>('/local-presets/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateLocalPreset: (id: number, data: { name?: string; setting?: Record<string, unknown> }) =>
+    request<LocalPreset>(`/local-presets/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteLocalPreset: (id: number) =>
+    request<{ success: boolean }>(`/local-presets/${id}`, { method: 'DELETE' }),
+  refreshBaseProfileCache: () =>
+    request<{ refreshed: number; failed: number; total: number }>('/local-presets/base-cache/refresh', { method: 'POST' }),
 };
 };
 
 
 // AMS History types
 // AMS History types
@@ -3925,6 +4103,7 @@ export interface DiscoveryInfo {
   is_docker: boolean;
   is_docker: boolean;
   ssdp_running: boolean;
   ssdp_running: boolean;
   scan_running: boolean;
   scan_running: boolean;
+  subnets: string[];
 }
 }
 
 
 export interface SubnetScanStatus {
 export interface SubnetScanStatus {

+ 2 - 5
frontend/src/components/AddExternalLinkModal.tsx

@@ -5,8 +5,6 @@ import { api } from '../api/client';
 import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
 import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { IconPicker, getIconByName } from './IconPicker';
 import { IconPicker, getIconByName } from './IconPicker';
-import { useTheme } from '../contexts/ThemeContext';
-
 interface AddExternalLinkModalProps {
 interface AddExternalLinkModalProps {
   link?: ExternalLink | null;
   link?: ExternalLink | null;
   onClose: () => void;
   onClose: () => void;
@@ -14,7 +12,6 @@ interface AddExternalLinkModalProps {
 
 
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
-  const { mode } = useTheme();
   const isEditing = !!link;
   const isEditing = !!link;
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
 
@@ -166,7 +163,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
           <div className="flex items-center gap-3">
           <div className="flex items-center gap-3">
             <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
             <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
               {useCustomIcon && customIconPreview ? (
               {useCustomIcon && customIconPreview ? (
-                <img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
+                <img src={customIconPreview} alt="" className="w-5 h-5 rounded" />
               ) : (
               ) : (
                 <PresetIcon className="w-5 h-5" />
                 <PresetIcon className="w-5 h-5" />
               )}
               )}
@@ -233,7 +230,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
                 />
                 />
                 {useCustomIcon && customIconPreview ? (
                 {useCustomIcon && customIconPreview ? (
                   <div className="flex items-center gap-2">
                   <div className="flex items-center gap-2">
-                    <img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
+                    <img src={customIconPreview} alt="Custom icon" className="w-8 h-8 rounded border border-bambu-dark-tertiary" />
                     <button
                     <button
                       type="button"
                       type="button"
                       onClick={handleRemoveCustomIcon}
                       onClick={handleRemoveCustomIcon}

+ 11 - 9
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye } from 'lucide-react';
 import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -11,6 +12,7 @@ interface AddSmartPlugModalProps {
 }
 }
 
 
 export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
 export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const isEditing = !!plug;
   const isEditing = !!plug;
 
 
@@ -469,7 +471,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               {isScanning && scanProgress.total > 0 && (
               {isScanning && scanProgress.total > 0 && (
                 <div className="space-y-1">
                 <div className="space-y-1">
                   <div className="flex justify-between text-xs text-bambu-gray">
                   <div className="flex justify-between text-xs text-bambu-gray">
-                    <span>Scanning network...</span>
+                    <span>{t('smartPlugs.addSmartPlug.scanningNetwork')}</span>
                     <span>{scanProgress.scanned} / {scanProgress.total}</span>
                     <span>{scanProgress.scanned} / {scanProgress.total}</span>
                   </div>
                   </div>
                   <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
                   <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
@@ -538,7 +540,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       disabled
                       disabled
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed opacity-50"
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed opacity-50"
                     >
                     >
-                      <option>Choose an entity...</option>
+                      <option>{t('smartPlugs.addSmartPlug.chooseEntity')}</option>
                     </select>
                     </select>
                   </div>
                   </div>
                 </div>
                 </div>
@@ -585,7 +587,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                               setIsEntityDropdownOpen(true);
                               setIsEntityDropdownOpen(true);
                               setHaEntitySearch('');
                               setHaEntitySearch('');
                             }}
                             }}
-                            placeholder="Search entities..."
+                            placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEntities')}
                             className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                             className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                           />
                           />
                           {haEntityId && !isEntityDropdownOpen && (
                           {haEntityId && !isEntityDropdownOpen && (
@@ -697,7 +699,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                                   setIsPowerDropdownOpen(true);
                                   setIsPowerDropdownOpen(true);
                                   setPowerSensorSearch('');
                                   setPowerSensorSearch('');
                                 }}
                                 }}
-                                placeholder="Search power sensors..."
+                                placeholder={t('smartPlugs.addSmartPlug.placeholders.searchPowerSensors')}
                                 className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                                 className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                               />
                               />
                               {haPowerEntity && !isPowerDropdownOpen && (
                               {haPowerEntity && !isPowerDropdownOpen && (
@@ -781,7 +783,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                                   setIsEnergyTodayDropdownOpen(true);
                                   setIsEnergyTodayDropdownOpen(true);
                                   setEnergyTodaySearch('');
                                   setEnergyTodaySearch('');
                                 }}
                                 }}
-                                placeholder="Search energy sensors..."
+                                placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
                                 className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                                 className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                               />
                               />
                               {haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
                               {haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
@@ -865,7 +867,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                                   setIsEnergyTotalDropdownOpen(true);
                                   setIsEnergyTotalDropdownOpen(true);
                                   setEnergyTotalSearch('');
                                   setEnergyTotalSearch('');
                                 }}
                                 }}
-                                placeholder="Search energy sensors..."
+                                placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
                                 className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                                 className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                               />
                               />
                               {haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
                               {haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
@@ -1060,7 +1062,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                           type="text"
                           type="text"
                           value={mqttStateOnValue}
                           value={mqttStateOnValue}
                           onChange={(e) => setMqttStateOnValue(e.target.value)}
                           onChange={(e) => setMqttStateOnValue(e.target.value)}
-                          placeholder="ON, true, 1"
+                          placeholder={t('smartPlugs.addSmartPlug.placeholders.mqttStateOnValue')}
                           className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                           className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                         />
                         />
                       </div>
                       </div>
@@ -1128,7 +1130,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               ) : (
               ) : (
                 <>
                 <>
                   <WifiOff className="w-5 h-5" />
                   <WifiOff className="w-5 h-5" />
-                  <span>Connection failed</span>
+                  <span>{t('smartPlugs.addSmartPlug.connectionFailed')}</span>
                 </>
                 </>
               )}
               )}
             </div>
             </div>
@@ -1141,7 +1143,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               type="text"
               type="text"
               value={name}
               value={name}
               onChange={(e) => setName(e.target.value)}
               onChange={(e) => setName(e.target.value)}
-              placeholder="Living Room Plug"
+              placeholder={t('smartPlugs.addSmartPlug.placeholders.plugName')}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             />
             />
           </div>
           </div>

+ 154 - 82
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useQuery, useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
 import type { KProfile } from '../api/client';
@@ -216,6 +217,7 @@ export function ConfigureAmsSlotModal({
   nozzleDiameter = '0.4',
   nozzleDiameter = '0.4',
   onSuccess,
   onSuccess,
 }: ConfigureAmsSlotModalProps) {
 }: ConfigureAmsSlotModalProps) {
+  const { t } = useTranslation();
   const [selectedPresetId, setSelectedPresetId] = useState<string>('');
   const [selectedPresetId, setSelectedPresetId] = useState<string>('');
   const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
   const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
   const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
   const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
@@ -231,6 +233,13 @@ export function ConfigureAmsSlotModal({
     enabled: isOpen,
     enabled: isOpen,
   });
   });
 
 
+  // Fetch local presets
+  const { data: localPresets, isLoading: localLoading } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+    enabled: isOpen,
+  });
+
   // Fetch K profiles
   // Fetch K profiles
   const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
   const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
     queryKey: ['kprofiles', printerId, nozzleDiameter],
     queryKey: ['kprofiles', printerId, nozzleDiameter],
@@ -243,12 +252,24 @@ export function ConfigureAmsSlotModal({
     mutationFn: async () => {
     mutationFn: async () => {
       if (!selectedPresetId) throw new Error('No filament preset selected');
       if (!selectedPresetId) throw new Error('No filament preset selected');
 
 
-      // Get the selected preset details
-      const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
-      if (!selectedPreset) throw new Error('Selected preset not found');
+      // Check if this is a local preset
+      const isLocal = selectedPresetId.startsWith('local_');
+      const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
+      const localPreset = isLocal
+        ? localPresets?.filament.find(p => p.id === localId)
+        : null;
+
+      // Get the selected cloud preset details (null for local presets)
+      const selectedPreset = !isLocal
+        ? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
+        : null;
+
+      if (!isLocal && !selectedPreset) throw new Error('Selected preset not found');
+      if (isLocal && !localPreset) throw new Error('Selected local preset not found');
 
 
       // Parse the preset name for filament info
       // Parse the preset name for filament info
-      const parsed = parsePresetName(selectedPreset.name);
+      const presetName = isLocal ? localPreset!.name : selectedPreset!.name;
+      const parsed = parsePresetName(presetName);
 
 
       // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
       // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
       const caliIdx = selectedKProfile?.slot_id ?? -1;
       const caliIdx = selectedKProfile?.slot_id ?? -1;
@@ -257,70 +278,86 @@ export function ConfigureAmsSlotModal({
       const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
       const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
 
 
       // Create the tray_sub_brands from preset name (without printer/nozzle suffix)
       // Create the tray_sub_brands from preset name (without printer/nozzle suffix)
-      const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
-
-      // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
-      let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
-
-      // For user presets (not starting with GF), fetch the detail to get the real filament_id
-      if (!selectedPresetId.startsWith('GFS')) {
-        try {
-          const detail = await api.getCloudSettingDetail(selectedPresetId);
-          if (detail.filament_id) {
-            trayInfoIdx = detail.filament_id;
-          } else if (detail.base_id) {
-            // If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
-            // This is common for user presets that inherit from Bambu presets
-            trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
-            console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
+      const traySubBrands = presetName.replace(/@.+$/, '').trim();
+
+      let trayInfoIdx: string;
+      let settingId: string;
+
+      if (isLocal) {
+        // Local presets have no Bambu Cloud mapping
+        trayInfoIdx = '';
+        settingId = '';
+      } else {
+        // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
+        trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
+        settingId = selectedPresetId;
+
+        // For user presets (not starting with GF), fetch the detail to get the real filament_id
+        if (!selectedPresetId.startsWith('GFS')) {
+          try {
+            const detail = await api.getCloudSettingDetail(selectedPresetId);
+            if (detail.filament_id) {
+              trayInfoIdx = detail.filament_id;
+            } else if (detail.base_id) {
+              trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
+              console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
+            }
+          } catch (e) {
+            console.warn('Failed to fetch preset detail for filament_id:', e);
           }
           }
-        } catch (e) {
-          console.warn('Failed to fetch preset detail for filament_id:', e);
-          // Fall back to derived tray_info_idx
         }
         }
       }
       }
 
 
-      // Default temp range based on material type
-      let tempMin = 190;
-      let tempMax = 230;
-      const material = parsed.material.toUpperCase();
-      if (material.includes('PLA')) {
-        tempMin = 190;
-        tempMax = 230;
-      } else if (material.includes('PETG')) {
-        tempMin = 220;
-        tempMax = 260;
-      } else if (material.includes('ABS')) {
-        tempMin = 240;
-        tempMax = 280;
-      } else if (material.includes('ASA')) {
-        tempMin = 240;
-        tempMax = 280;
-      } else if (material.includes('TPU')) {
+      // Default temp range — use local preset core fields if available
+      let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
+      let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
+
+      if (!isLocal || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
+        // Fall back to material-based defaults
+        const material = (isLocal ? (localPreset?.filament_type || parsed.material) : parsed.material).toUpperCase();
+        if (material.includes('PLA')) {
+          tempMin = 190;
+          tempMax = 230;
+        } else if (material.includes('PETG')) {
+          tempMin = 220;
+          tempMax = 260;
+        } else if (material.includes('ABS')) {
+          tempMin = 240;
+          tempMax = 280;
+        } else if (material.includes('ASA')) {
+          tempMin = 240;
+          tempMax = 280;
+        } else if (material.includes('TPU')) {
         tempMin = 200;
         tempMin = 200;
         tempMax = 240;
         tempMax = 240;
       } else if (material.includes('PC')) {
       } else if (material.includes('PC')) {
         tempMin = 260;
         tempMin = 260;
         tempMax = 300;
         tempMax = 300;
       } else if (material.includes('PA') || material.includes('NYLON')) {
       } else if (material.includes('PA') || material.includes('NYLON')) {
-        tempMin = 250;
-        tempMax = 290;
+          tempMin = 250;
+          tempMax = 290;
+        }
       }
       }
 
 
       // Parse K value from selected profile
       // Parse K value from selected profile
       const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
       const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
 
 
+      // Determine tray_type: use local preset's filament_type or parsed material
+      const trayType = isLocal
+        ? (localPreset?.filament_type || parsed.material || 'PLA')
+        : (parsed.material || 'PLA');
+
       // Configure the slot via MQTT
       // Configure the slot via MQTT
       const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
       const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
         tray_info_idx: trayInfoIdx,
         tray_info_idx: trayInfoIdx,
-        tray_type: parsed.material || 'PLA',
+        tray_type: trayType,
         tray_sub_brands: traySubBrands,
         tray_sub_brands: traySubBrands,
         tray_color: color + 'FF', // Add alpha
         tray_color: color + 'FF', // Add alpha
         nozzle_temp_min: tempMin,
         nozzle_temp_min: tempMin,
         nozzle_temp_max: tempMax,
         nozzle_temp_max: tempMax,
         cali_idx: caliIdx,
         cali_idx: caliIdx,
         nozzle_diameter: nozzleDiameter,
         nozzle_diameter: nozzleDiameter,
-        setting_id: selectedPresetId, // Full setting ID for slicer compatibility
+        setting_id: settingId, // Full setting ID for slicer compatibility (empty for local)
         // Pass K profile's filament_id and setting_id for proper linking
         // Pass K profile's filament_id and setting_id for proper linking
         kprofile_filament_id: selectedKProfile?.filament_id,
         kprofile_filament_id: selectedKProfile?.filament_id,
         kprofile_setting_id: selectedKProfile?.setting_id || undefined,
         kprofile_setting_id: selectedKProfile?.setting_id || undefined,
@@ -331,8 +368,10 @@ export function ConfigureAmsSlotModal({
       // Save the preset mapping so we can display the correct name in the UI
       // Save the preset mapping so we can display the correct name in the UI
       // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
       // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
       // which can't be resolved to a name via the filamentInfo API
       // which can't be resolved to a name via the filamentInfo API
+      const mappingPresetId = isLocal ? `local_${localId}` : selectedPresetId;
+      const mappingSource = isLocal ? 'local' : 'cloud';
       try {
       try {
-        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
+        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
       } catch (e) {
       } catch (e) {
         console.warn('Failed to save slot preset mapping:', e);
         console.warn('Failed to save slot preset mapping:', e);
         // Don't fail the whole operation - slot was configured successfully
         // Don't fail the whole operation - slot was configured successfully
@@ -366,34 +405,60 @@ export function ConfigureAmsSlotModal({
     },
     },
   });
   });
 
 
-  // Filter filament presets based on search
-  const filteredPresets = useMemo(() => {
-    if (!cloudSettings?.filament) return [];
+  // Unified preset item for the list (cloud + local)
+  type PresetItem = { id: string; name: string; source: 'cloud' | 'local'; isUser: boolean };
 
 
+  // Filter filament presets based on search (merged cloud + local)
+  const filteredPresets = useMemo(() => {
     const query = searchQuery.toLowerCase();
     const query = searchQuery.toLowerCase();
-    return cloudSettings.filament
-      .filter(p => {
-        if (!query) return true;
-        return p.name.toLowerCase().includes(query);
-      })
-      .sort((a, b) => {
-        // Sort user presets first, then alphabetically
-        const aIsUser = isUserPreset(a.setting_id);
-        const bIsUser = isUserPreset(b.setting_id);
-        if (aIsUser && !bIsUser) return -1;
-        if (!aIsUser && bIsUser) return 1;
-        return a.name.localeCompare(b.name);
-      });
-  }, [cloudSettings?.filament, searchQuery]);
+    const items: PresetItem[] = [];
+
+    // Add local presets first
+    if (localPresets?.filament) {
+      for (const lp of localPresets.filament) {
+        if (!query || lp.name.toLowerCase().includes(query)) {
+          items.push({ id: `local_${lp.id}`, name: lp.name, source: 'local', isUser: false });
+        }
+      }
+    }
+
+    // Add cloud presets
+    if (cloudSettings?.filament) {
+      for (const cp of cloudSettings.filament) {
+        if (!query || cp.name.toLowerCase().includes(query)) {
+          items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
+        }
+      }
+    }
+
+    // Sort: local first, then user cloud presets, then built-in, alphabetically within groups
+    return items.sort((a, b) => {
+      if (a.source === 'local' && b.source !== 'local') return -1;
+      if (a.source !== 'local' && b.source === 'local') return 1;
+      if (a.isUser && !b.isUser) return -1;
+      if (!a.isUser && b.isUser) return 1;
+      return a.name.localeCompare(b.name);
+    });
+  }, [cloudSettings?.filament, localPresets?.filament, searchQuery]);
 
 
   // Get full preset name for K profile filtering (brand + material, without printer suffix)
   // Get full preset name for K profile filtering (brand + material, without printer suffix)
   const selectedPresetInfo = useMemo(() => {
   const selectedPresetInfo = useMemo(() => {
-    if (!selectedPresetId || !cloudSettings?.filament) return null;
-    const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
-    if (!selectedPreset) return null;
+    if (!selectedPresetId) return null;
+
+    // Resolve the name from either local or cloud presets
+    let presetName: string | null = null;
+    if (selectedPresetId.startsWith('local_')) {
+      const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
+      const lp = localPresets?.filament.find(p => p.id === localId);
+      presetName = lp?.name || null;
+    } else if (cloudSettings?.filament) {
+      const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
+      presetName = cp?.name || null;
+    }
+    if (!presetName) return null;
 
 
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
-    let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
+    let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();
     // Strip leading "# " from custom preset names (user convention)
     // Strip leading "# " from custom preset names (user convention)
     if (nameWithoutSuffix.startsWith('# ')) {
     if (nameWithoutSuffix.startsWith('# ')) {
       nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
       nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
@@ -405,7 +470,7 @@ export function ConfigureAmsSlotModal({
       material: parsed.material,
       material: parsed.material,
       brand: parsed.brand,
       brand: parsed.brand,
     };
     };
-  }, [selectedPresetId, cloudSettings?.filament]);
+  }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament]);
 
 
   // For backwards compatibility with the label
   // For backwards compatibility with the label
   const selectedMaterial = selectedPresetInfo?.fullName || '';
   const selectedMaterial = selectedPresetInfo?.fullName || '';
@@ -531,7 +596,7 @@ export function ConfigureAmsSlotModal({
 
 
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
-  const isLoading = settingsLoading || kprofilesLoading;
+  const isLoading = settingsLoading || localLoading || kprofilesLoading;
   const canSave = selectedPresetId && !configureMutation.isPending;
   const canSave = selectedPresetId && !configureMutation.isPending;
 
 
   // Get display color (custom or slot default)
   // Get display color (custom or slot default)
@@ -569,7 +634,7 @@ export function ConfigureAmsSlotModal({
               <div className="text-center space-y-3">
               <div className="text-center space-y-3">
                 <CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
                 <CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
                 <p className="text-lg font-semibold text-white">Slot Configured!</p>
                 <p className="text-lg font-semibold text-white">Slot Configured!</p>
-                <p className="text-sm text-bambu-gray">Settings sent to printer</p>
+                <p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
               </div>
               </div>
             </div>
             </div>
           )}
           )}
@@ -607,7 +672,7 @@ export function ConfigureAmsSlotModal({
                 <div className="relative">
                 <div className="relative">
                   <input
                   <input
                     type="text"
                     type="text"
-                    placeholder="Search presets..."
+                    placeholder={t('configureAmsSlot.searchPresets')}
                     value={searchQuery}
                     value={searchQuery}
                     onChange={(e) => setSearchQuery(e.target.value)}
                     onChange={(e) => setSearchQuery(e.target.value)}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
@@ -615,28 +680,35 @@ export function ConfigureAmsSlotModal({
                   <div className="max-h-48 overflow-y-auto space-y-1">
                   <div className="max-h-48 overflow-y-auto space-y-1">
                     {filteredPresets.length === 0 ? (
                     {filteredPresets.length === 0 ? (
                       <p className="text-center py-4 text-bambu-gray">
                       <p className="text-center py-4 text-bambu-gray">
-                        {cloudSettings?.filament?.length === 0
-                          ? 'No cloud presets. Login to Bambu Cloud to sync.'
+                        {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
+                          ? 'No presets available. Login to Bambu Cloud or import local profiles.'
                           : 'No matching presets found.'}
                           : 'No matching presets found.'}
                       </p>
                       </p>
                     ) : (
                     ) : (
                       filteredPresets.map((preset) => (
                       filteredPresets.map((preset) => (
                         <button
                         <button
-                          key={preset.setting_id}
-                          onClick={() => setSelectedPresetId(preset.setting_id)}
+                          key={preset.id}
+                          onClick={() => setSelectedPresetId(preset.id)}
                           className={`w-full p-2 rounded-lg border text-left transition-colors ${
                           className={`w-full p-2 rounded-lg border text-left transition-colors ${
-                            selectedPresetId === preset.setting_id
+                            selectedPresetId === preset.id
                               ? 'bg-bambu-green/20 border-bambu-green'
                               ? 'bg-bambu-green/20 border-bambu-green'
                               : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
                               : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
                           }`}
                           }`}
                         >
                         >
                           <div className="flex items-center justify-between">
                           <div className="flex items-center justify-between">
                             <span className="text-white text-sm truncate">{preset.name}</span>
                             <span className="text-white text-sm truncate">{preset.name}</span>
-                            {isUserPreset(preset.setting_id) && (
-                              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
-                                Custom
-                              </span>
-                            )}
+                            <div className="flex items-center gap-1 flex-shrink-0">
+                              {preset.source === 'local' && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
+                                  {t('profiles.localProfiles.badge')}
+                                </span>
+                              )}
+                              {preset.isUser && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
+                                  {t('configureAmsSlot.custom')}
+                                </span>
+                              )}
+                            </div>
                           </div>
                           </div>
                         </button>
                         </button>
                       ))
                       ))
@@ -750,7 +822,7 @@ export function ConfigureAmsSlotModal({
                   />
                   />
                   <input
                   <input
                     type="text"
                     type="text"
-                    placeholder="Color name or hex (e.g., brown, FF8800)"
+                    placeholder={t('configureAmsSlot.colorPlaceholder')}
                     value={colorInput}
                     value={colorInput}
                     onChange={(e) => {
                     onChange={(e) => {
                       const input = e.target.value;
                       const input = e.target.value;
@@ -780,7 +852,7 @@ export function ConfigureAmsSlotModal({
                         setColorInput('');
                         setColorInput('');
                       }}
                       }}
                       className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
                       className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
-                      title="Clear custom color"
+                      title={t('configureAmsSlot.clearCustomColor')}
                     >
                     >
                       Clear
                       Clear
                     </button>
                     </button>

+ 181 - 0
frontend/src/components/CreateUserAdvancedAuthModal.tsx

@@ -0,0 +1,181 @@
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X, Plus, Loader2, Users as UsersIcon } from 'lucide-react';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import type { Group, UserCreate } from '../api/client';
+
+interface AdvancedAuthFormData extends UserCreate {
+  group_ids: number[];
+  confirmPassword: string;
+  email?: string;
+}
+
+interface CreateUserAdvancedAuthModalProps {
+  formData: AdvancedAuthFormData;
+  setFormData: (data: AdvancedAuthFormData) => void;
+  groups: Group[];
+  onClose: () => void;
+  onCreate: () => void;
+  isCreating: boolean;
+  isCreateButtonDisabled: boolean;
+}
+
+export function CreateUserAdvancedAuthModal({
+  formData,
+  setFormData,
+  groups,
+  onClose,
+  onCreate,
+  isCreating,
+  isCreateButtonDisabled,
+}: CreateUserAdvancedAuthModalProps) {
+  const { t } = useTranslation();
+
+  // Close modal on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const toggleGroup = (groupId: number) => {
+    setFormData({
+      ...formData,
+      group_ids: formData.group_ids.includes(groupId)
+        ? formData.group_ids.filter(id => id !== groupId)
+        : [...formData.group_ids, groupId],
+    });
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card
+        className="w-full max-w-md"
+        onClick={(e: React.MouseEvent) => e.stopPropagation()}
+      >
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1">
+              <div className="flex items-center gap-2">
+                <UsersIcon className="w-5 h-5 text-bambu-green" />
+                <h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
+              </div>
+              <p className="text-sm text-bambu-gray ml-7">{t('users.modal.advancedAuthSubtitle') || 'with Advanced Authentication'}</p>
+            </div>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={onClose}
+            >
+              <X className="w-5 h-5" />
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-4">
+            {/* Username Field */}
+            <div>
+              <label className="block text-sm font-medium text-white mb-2">
+                {t('users.form.username')} <span className="text-red-400">*</span>
+              </label>
+              <input
+                type="text"
+                value={formData.username}
+                onChange={(e) => setFormData({ ...formData, username: e.target.value })}
+                className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                placeholder={t('users.form.usernamePlaceholder')}
+                autoComplete="username"
+                required
+              />
+            </div>
+
+            {/* Email Field */}
+            <div>
+              <label className="block text-sm font-medium text-white mb-2">
+                {t('users.form.email') || 'Email'} <span className="text-red-400">*</span>
+              </label>
+              <input
+                type="email"
+                value={formData.email}
+                onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+                className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
+                required
+              />
+            </div>
+
+            {/* Info box about auto-generated password */}
+            <div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3">
+              <p className="text-sm text-bambu-gray">
+                {t('users.form.autoGeneratedPassword') || 'A secure password will be automatically generated and emailed to the user.'}
+              </p>
+            </div>
+
+            {/* Groups Field */}
+            <div>
+              <label className="block text-sm font-medium text-white mb-2">
+                {t('users.form.groups')}
+              </label>
+              <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+                {groups.map(group => (
+                  <label
+                    key={group.id}
+                    className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
+                  >
+                    <input
+                      type="checkbox"
+                      checked={formData.group_ids.includes(group.id)}
+                      onChange={() => toggleGroup(group.id)}
+                      className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
+                    />
+                    <span className="text-sm text-white">{group.name}</span>
+                    {group.is_system && (
+                      <span className="text-xs text-yellow-400">({t('users.system')})</span>
+                    )}
+                  </label>
+                ))}
+                {groups.length === 0 && (
+                  <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
+                )}
+              </div>
+            </div>
+          </div>
+
+          {/* Action Buttons */}
+          <div className="mt-6 flex justify-end gap-3">
+            <Button
+              variant="secondary"
+              onClick={onClose}
+            >
+              {t('users.modal.cancel')}
+            </Button>
+            <Button
+              onClick={onCreate}
+              disabled={isCreateButtonDisabled}
+            >
+              {isCreating ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('users.modal.creating')}
+                </>
+              ) : (
+                <>
+                  <Plus className="w-4 h-4" />
+                  {t('users.modal.createUser')}
+                </>
+              )}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 402 - 0
frontend/src/components/EmailSettings.tsx

@@ -0,0 +1,402 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Mail, Send, Lock, Unlock, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { SMTPSettings, TestSMTPRequest } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { useEffect } from 'react';
+
+export function EmailSettings() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+
+  const [smtpSettings, setSMTPSettings] = useState<SMTPSettings>({
+    smtp_host: '',
+    smtp_port: 587,
+    smtp_username: '',
+    smtp_password: '',
+    smtp_security: 'starttls',
+    smtp_auth_enabled: true,
+    smtp_from_email: '',
+    smtp_from_name: 'BamBuddy',
+  });
+  const [testEmail, setTestEmail] = useState('');
+
+  // Fetch SMTP settings
+  const { data: existingSettings, isLoading } = useQuery({
+    queryKey: ['smtpSettings'],
+    queryFn: () => api.getSMTPSettings(),
+  });
+
+  // Fetch advanced auth status
+  const { data: advancedAuthStatus } = useQuery({
+    queryKey: ['advancedAuthStatus'],
+    queryFn: () => api.getAdvancedAuthStatus(),
+  });
+
+  // Load existing settings when fetched
+  useEffect(() => {
+    if (existingSettings) {
+      setSMTPSettings({
+        ...existingSettings,
+        smtp_password: '', // Never show password
+      });
+    }
+  }, [existingSettings]);
+
+  // Save SMTP settings
+  const saveMutation = useMutation({
+    mutationFn: (settings: SMTPSettings) => api.saveSMTPSettings(settings),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smtpSettings'] });
+      queryClient.invalidateQueries({ queryKey: ['advancedAuthStatus'] });
+      showToast(t('settings.email.success.settingsSaved'), 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  // Test SMTP connection
+  const testMutation = useMutation({
+    mutationFn: (request: TestSMTPRequest) => api.testSMTP(request),
+    onSuccess: (data) => {
+      showToast(data.message, data.success ? 'success' : 'error');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  // Toggle advanced auth
+  const toggleAdvancedAuthMutation = useMutation({
+    mutationFn: (enabled: boolean) =>
+      enabled ? api.enableAdvancedAuth() : api.disableAdvancedAuth(),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['advancedAuthStatus'] });
+      showToast(data.message, 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleSave = () => {
+    // Validate required fields
+    if (!smtpSettings.smtp_host || !smtpSettings.smtp_from_email) {
+      showToast(t('settings.email.errors.requiredFields'), 'error');
+      return;
+    }
+    // Validate auth fields when authentication is enabled
+    if (smtpSettings.smtp_auth_enabled && (!smtpSettings.smtp_username)) {
+      showToast(t('settings.email.errors.usernameRequired'), 'error');
+      return;
+    }
+    saveMutation.mutate(smtpSettings);
+  };
+
+  const handleTest = () => {
+    if (!testEmail) {
+      showToast(t('settings.email.errors.enterTestEmail'), 'error');
+      return;
+    }
+    if (!smtpSettings.smtp_host || !smtpSettings.smtp_from_email) {
+      showToast(t('settings.email.errors.smtpServerAndEmail'), 'error');
+      return;
+    }
+    // Validate auth fields when authentication is enabled
+    if (smtpSettings.smtp_auth_enabled && (!smtpSettings.smtp_username || !smtpSettings.smtp_password)) {
+      showToast(t('settings.email.errors.usernamePasswordRequired'), 'error');
+      return;
+    }
+    testMutation.mutate({
+      smtp_host: smtpSettings.smtp_host,
+      smtp_port: smtpSettings.smtp_port,
+      smtp_username: smtpSettings.smtp_username,
+      smtp_password: smtpSettings.smtp_password,
+      smtp_security: smtpSettings.smtp_security,
+      smtp_auth_enabled: smtpSettings.smtp_auth_enabled,
+      smtp_from_email: smtpSettings.smtp_from_email,
+      test_recipient: testEmail,
+    });
+  };
+
+  const handleToggleAdvancedAuth = () => {
+    if (!advancedAuthStatus?.advanced_auth_enabled && !advancedAuthStatus?.smtp_configured) {
+      showToast(t('settings.email.errors.configureSmtpFirst'), 'error');
+      return;
+    }
+    toggleAdvancedAuthMutation.mutate(!advancedAuthStatus?.advanced_auth_enabled);
+  };
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center p-12">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Advanced Authentication Toggle - Only show when SMTP is configured */}
+      {advancedAuthStatus?.smtp_configured && (
+        <Card>
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <Mail className="w-5 h-5 text-bambu-green" />
+                <h2 className="text-lg font-semibold text-white">
+                  {t('settings.email.advancedAuth') || 'Advanced Authentication'}
+                </h2>
+              </div>
+              <Button
+                onClick={handleToggleAdvancedAuth}
+                disabled={toggleAdvancedAuthMutation.isPending}
+                variant={advancedAuthStatus?.advanced_auth_enabled ? 'danger' : 'primary'}
+              >
+                {advancedAuthStatus?.advanced_auth_enabled ? (
+                  <>
+                    <Unlock className="w-4 h-4" />
+                    {t('settings.email.disable') || 'Disable'}
+                  </>
+                ) : (
+                  <>
+                    <Lock className="w-4 h-4" />
+                    {t('settings.email.enable') || 'Enable'}
+                  </>
+                )}
+              </Button>
+            </div>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-4">
+              {advancedAuthStatus?.advanced_auth_enabled ? (
+                <div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
+                  <div className="flex items-start gap-3">
+                    <CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
+                    <div className="space-y-2">
+                      <p className="text-white font-medium">
+                        {t('settings.email.advancedAuthEnabled') || 'Advanced Authentication is enabled'}
+                      </p>
+                      <ul className="text-sm text-green-300 space-y-1 list-disc list-inside">
+                        <li>{t('settings.email.feature1') || 'Passwords are auto-generated and emailed to new users'}</li>
+                        <li>{t('settings.email.feature2') || 'Users can login with username or email'}</li>
+                        <li>{t('settings.email.feature3') || 'Forgot password feature is available'}</li>
+                        <li>{t('settings.email.feature4') || 'Admins can reset user passwords via email'}</li>
+                      </ul>
+                    </div>
+                  </div>
+                </div>
+              ) : (
+                <div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
+                  <div className="flex items-start gap-3">
+                    <AlertTriangle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
+                    <div className="space-y-2">
+                      <p className="text-white font-medium">
+                        {t('settings.email.advancedAuthDisabled') || 'Advanced Authentication is disabled'}
+                      </p>
+                      <p className="text-sm text-yellow-300">
+                        {t('settings.email.advancedAuthDisabledDesc') || 'Enable advanced authentication to activate email-based features for user management.'}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* SMTP Configuration */}
+      <Card>
+        <CardHeader>
+          <h2 className="text-lg font-semibold text-white">
+            {t('settings.email.smtpSettings') || 'SMTP Configuration'}
+          </h2>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-4">
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div>
+                <label className="block text-sm font-medium text-white mb-2">
+                  {t('settings.email.smtpHost') || 'SMTP Server'} *
+                </label>
+                <input
+                  type="text"
+                  value={smtpSettings.smtp_host}
+                  onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_host: e.target.value })}
+                  placeholder="smtp.gmail.com"
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                />
+              </div>
+              <div>
+                <label className="block text-sm font-medium text-white mb-2">
+                  {t('settings.email.smtpPort') || 'SMTP Port'}
+                </label>
+                <input
+                  type="number"
+                  value={smtpSettings.smtp_port}
+                  onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_port: parseInt(e.target.value) || 587 })}
+                  placeholder="587"
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                />
+              </div>
+            </div>
+
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div>
+                <label className="block text-sm font-medium text-white mb-2">
+                  {t('settings.email.security') || 'Security'}
+                </label>
+                <select
+                  value={smtpSettings.smtp_security}
+                  onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_security: e.target.value as 'starttls' | 'ssl' | 'none' })}
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                >
+                  <option value="starttls">{t('settings.email.securityOptions.starttls')}</option>
+                  <option value="ssl">{t('settings.email.securityOptions.ssl')}</option>
+                  <option value="none">{t('settings.email.securityOptions.none')}</option>
+                </select>
+              </div>
+              <div>
+                <label className="block text-sm font-medium text-white mb-2">
+                  {t('settings.email.authentication') || 'Authentication'}
+                </label>
+                <select
+                  value={smtpSettings.smtp_auth_enabled ? 'true' : 'false'}
+                  onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_auth_enabled: e.target.value === 'true' })}
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                >
+                  <option value="true">{t('settings.email.authOptions.enabled')}</option>
+                  <option value="false">{t('settings.email.authOptions.disabled')}</option>
+                </select>
+              </div>
+            </div>
+
+            {smtpSettings.smtp_auth_enabled && (
+              <>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    {t('settings.email.username') || 'Username'}
+                  </label>
+                  <input
+                    type="text"
+                    value={smtpSettings.smtp_username || ''}
+                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_username: e.target.value })}
+                    placeholder="your.email@gmail.com"
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                  />
+                </div>
+
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    {t('settings.email.password') || 'Password'}
+                  </label>
+                  <input
+                    type="password"
+                    value={smtpSettings.smtp_password || ''}
+                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_password: e.target.value })}
+                    placeholder={existingSettings ? '••••••••' : 'App password'}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                  />
+                </div>
+              </>
+            )}
+
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div>
+                <label className="block text-sm font-medium text-white mb-2">
+                  {t('settings.email.fromEmail') || 'From Email'} *
+                </label>
+                <input
+                  type="email"
+                  value={smtpSettings.smtp_from_email}
+                  onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_from_email: e.target.value })}
+                  placeholder="your@email.com"
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                />
+              </div>
+              <div>
+                <label className="block text-sm font-medium text-white mb-2">
+                  {t('settings.email.fromName') || 'From Name'}
+                </label>
+                <input
+                  type="text"
+                  value={smtpSettings.smtp_from_name}
+                  onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_from_name: e.target.value })}
+                  placeholder="BamBuddy"
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                />
+              </div>
+            </div>
+
+            <div className="flex gap-2">
+              <Button
+                onClick={handleSave}
+                disabled={saveMutation.isPending}
+                className="flex-1"
+              >
+                {saveMutation.isPending ? (
+                  <>
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                    {t('settings.email.saving') || 'Saving...'}
+                  </>
+                ) : (
+                  t('settings.email.save') || 'Save Settings'
+                )}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Test SMTP */}
+      <Card>
+        <CardHeader>
+          <h2 className="text-lg font-semibold text-white">
+            {t('settings.email.testConnection') || 'Test SMTP Connection'}
+          </h2>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-4">
+            <div>
+              <label className="block text-sm font-medium text-white mb-2">
+                {t('settings.email.testRecipient') || 'Test Recipient Email'}
+              </label>
+              <input
+                type="email"
+                value={testEmail}
+                onChange={(e) => setTestEmail(e.target.value)}
+                placeholder="test@example.com"
+                className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+              />
+            </div>
+            <Button
+              onClick={handleTest}
+              disabled={testMutation.isPending}
+              variant="secondary"
+            >
+              {testMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('settings.email.sending') || 'Sending...'}
+                </>
+              ) : (
+                <>
+                  <Send className="w-4 h-4" />
+                  {t('settings.email.sendTest') || 'Send Test Email'}
+                </>
+              )}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+
+    </div>
+  );
+}

+ 83 - 4
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -1,7 +1,12 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useState, useEffect, useRef, useCallback } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
 import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
-import { api } from '../api/client';
+import { api, getAuthToken } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+import { ChamberLight } from './icons/ChamberLight';
+import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
 
 
 interface EmbeddedCameraViewerProps {
 interface EmbeddedCameraViewerProps {
   printerId: number;
   printerId: number;
@@ -31,6 +36,11 @@ const DEFAULT_STATE: CameraState = {
 };
 };
 
 
 export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
 export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+
   // Printer-specific storage key
   // Printer-specific storage key
   const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
   const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
 
 
@@ -87,6 +97,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
 
+  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+
   // Fetch printer info
   // Fetch printer info
   const { data: printer } = useQuery({
   const { data: printer } = useQuery({
     queryKey: ['printer', printerId],
     queryKey: ['printer', printerId],
@@ -94,6 +106,39 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     enabled: printerId > 0,
     enabled: printerId > 0,
   });
   });
 
 
+  // Fetch printer status for light toggle and skip objects
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    refetchInterval: 30000,
+    enabled: printerId > 0,
+  });
+
+  // Chamber light mutation with optimistic update
+  const chamberLightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
+    onMutate: async (on) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] });
+      const previousStatus = queryClient.getQueryData(['printerStatus', printerId]);
+      queryClient.setQueryData(['printerStatus', printerId], (old: typeof status) => ({
+        ...old,
+        chamber_light: on,
+      }));
+      return { previousStatus };
+    },
+    onSuccess: (_, on) => {
+      showToast(`Chamber light ${on ? 'on' : 'off'}`);
+    },
+    onError: (error: Error, _, context) => {
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', printerId], context.previousStatus);
+      }
+      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
+    },
+  });
+
+  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
+
   // Save state to localStorage (printer-specific)
   // Save state to localStorage (printer-specific)
   useEffect(() => {
   useEffect(() => {
     const saveTimeout = setTimeout(() => {
     const saveTimeout = setTimeout(() => {
@@ -111,7 +156,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     const sendStopOnce = () => {
     const sendStopOnce = () => {
       if (printerId > 0 && !stopSentRef.current) {
       if (printerId > 0 && !stopSentRef.current) {
         stopSentRef.current = true;
         stopSentRef.current = true;
-        navigator.sendBeacon(stopUrl);
+        const headers: Record<string, string> = {};
+        const token = getAuthToken();
+        if (token) headers['Authorization'] = `Bearer ${token}`;
+        fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});
       }
       }
     };
     };
 
 
@@ -403,7 +451,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
     if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
     if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
     if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
 
 
-    fetch(`/api/v1/printers/${printerId}/camera/stop`).catch(() => {});
+    const stopHeaders: Record<string, string> = {};
+    const stopToken = getAuthToken();
+    if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`;
+    fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {});
 
 
     if (imgRef.current) imgRef.current.src = '';
     if (imgRef.current) imgRef.current.src = '';
     setTimeout(() => setImageKey(Date.now()), 100);
     setTimeout(() => setImageKey(Date.now()), 100);
@@ -482,6 +533,28 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           <span className="truncate">{printer?.name || printerName}</span>
           <span className="truncate">{printer?.name || printerName}</span>
         </div>
         </div>
         <div className="flex items-center gap-1 no-drag">
         <div className="flex items-center gap-1 no-drag">
+          <button
+            onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
+            disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
+            className={`p-1 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}
+            title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}
+          >
+            <ChamberLight on={status?.chamber_light ?? false} className="w-3.5 h-3.5" />
+          </button>
+          <button
+            onClick={() => setShowSkipObjectsModal(true)}
+            disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
+            className={`p-1 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}
+            title={
+              !hasPermission('printers:control')
+                ? t('printers.permission.noControl')
+                : !isPrintingWithObjects
+                  ? t('printers.skipObjects.onlyWhilePrinting')
+                  : t('printers.skipObjects.tooltip')
+            }
+          >
+            <SkipObjectsIcon className="w-3.5 h-3.5 text-bambu-gray" />
+          </button>
           <button
           <button
             onClick={refresh}
             onClick={refresh}
             disabled={streamLoading || isReconnecting}
             disabled={streamLoading || isReconnecting}
@@ -625,6 +698,12 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           )}
           )}
         </div>
         </div>
       )}
       )}
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={printerId}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
     </div>
     </div>
   );
   );
 }
 }

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

@@ -1,6 +1,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
 import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { ExternalLink } from '../api/client';
 import type { ExternalLink } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Card, CardContent, CardHeader } from './Card';
@@ -10,6 +11,7 @@ import { ConfirmModal } from './ConfirmModal';
 import { getIconByName } from './IconPicker';
 import { getIconByName } from './IconPicker';
 
 
 export function ExternalLinksSettings() {
 export function ExternalLinksSettings() {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [showAddModal, setShowAddModal] = useState(false);
   const [showAddModal, setShowAddModal] = useState(false);
   const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
   const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
@@ -132,7 +134,7 @@ export function ExternalLinksSettings() {
                       <button
                       <button
                         onClick={() => setEditingLink(link)}
                         onClick={() => setEditingLink(link)}
                         className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
                         className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
-                        title="Edit"
+                        title={t('common.edit')}
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
@@ -140,7 +142,7 @@ export function ExternalLinksSettings() {
                         onClick={() => handleDelete(link)}
                         onClick={() => handleDelete(link)}
                         disabled={deleteMutation.isPending}
                         disabled={deleteMutation.isPending}
                         className="p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50"
                         className="p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50"
-                        title="Delete"
+                        title={t('externalLinks.deleteLink')}
                       >
                       >
                         <Trash2 className="w-4 h-4" />
                         <Trash2 className="w-4 h-4" />
                       </button>
                       </button>
@@ -152,7 +154,7 @@ export function ExternalLinksSettings() {
           ) : (
           ) : (
             <div className="text-center py-8 text-bambu-gray">
             <div className="text-center py-8 text-bambu-gray">
               <Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
               <Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
-              <p>No external links configured</p>
+              <p>{t('externalLinks.noLinksConfigured')}</p>
               <p className="text-sm">Click "Add Link" to add one</p>
               <p className="text-sm">Click "Add Link" to add one</p>
             </div>
             </div>
           )}
           )}

+ 5 - 1
frontend/src/components/FilamentHoverCard.tsx

@@ -10,6 +10,7 @@ interface FilamentData {
   kFactor: string;
   kFactor: string;
   fillLevel: number | null; // null = unknown
   fillLevel: number | null; // null = unknown
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
+  fillSource?: 'ams' | 'spoolman'; // Source of fill level data
 }
 }
 
 
 interface SpoolmanConfig {
 interface SpoolmanConfig {
@@ -229,8 +230,11 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     <Droplets className="w-3 h-3" />
                     <Droplets className="w-3 h-3" />
                     {t('ams.fill')}
                     {t('ams.fill')}
                   </span>
                   </span>
-                  <span className="text-xs text-white font-semibold">
+                  <span className="text-xs text-white font-semibold flex items-center gap-1">
                     {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
                     {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
+                    {data.fillSource === 'spoolman' && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
+                    )}
                   </span>
                   </span>
                 </div>
                 </div>
                 {/* Fill bar */}
                 {/* Fill bar */}

+ 5 - 8
frontend/src/components/FilamentTrends.tsx

@@ -61,10 +61,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
 
 
       const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
       const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
-      const qty = archive.quantity || 1;
-      existing.filament += (archive.filament_used_grams || 0) * qty;
+      existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
       existing.cost += archive.cost || 0;
-      existing.prints += qty;
+      existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
       dataMap.set(key, existing);
     });
     });
 
 
@@ -90,10 +89,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
       const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
 
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
-      const qty = archive.quantity || 1;
-      existing.filament += (archive.filament_used_grams || 0) * qty;
+      existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
       existing.cost += archive.cost || 0;
-      existing.prints += qty;
+      existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
       dataMap.set(key, existing);
     });
     });
 
 
@@ -112,11 +110,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
 
 
     filteredArchives.forEach(archive => {
     filteredArchives.forEach(archive => {
       const type = archive.filament_type || 'Unknown';
       const type = archive.filament_type || 'Unknown';
-      const qty = archive.quantity || 1;
       // Handle multiple types (e.g., "PLA, PETG")
       // Handle multiple types (e.g., "PLA, PETG")
       const types = type.split(', ');
       const types = type.split(', ');
       types.forEach(t => {
       types.forEach(t => {
-        const grams = ((archive.filament_used_grams || 0) * qty) / types.length;
+        const grams = (archive.filament_used_grams || 0) / types.length;
         dataMap.set(t, (dataMap.get(t) || 0) + grams);
         dataMap.set(t, (dataMap.get(t) || 0) + grams);
       });
       });
     });
     });

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

@@ -549,7 +549,7 @@ export function GitHubBackupSettings() {
                   />
                   />
                   <div>
                   <div>
                     <span className="text-white text-sm">App Settings</span>
                     <span className="text-white text-sm">App Settings</span>
-                    <p className="text-xs text-bambu-gray">Bambuddy configuration (excludes sensitive data)</p>
+                    <p className="text-xs text-bambu-gray">Bambuddy configuration (complete database)</p>
                   </div>
                   </div>
                 </label>
                 </label>
               </div>
               </div>

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

@@ -469,7 +469,7 @@ export function Layout() {
                         <img
                         <img
                           src={`/api/v1/external-links/${link.id}/icon`}
                           src={`/api/v1/external-links/${link.id}/icon`}
                           alt=""
                           alt=""
-                          className={`w-5 h-5 flex-shrink-0 ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
+                          className="w-5 h-5 flex-shrink-0"
                         />
                         />
                       ) : (
                       ) : (
                         LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
                         LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />

+ 479 - 0
frontend/src/components/LocalProfilesView.tsx

@@ -0,0 +1,479 @@
+import { useState, useMemo, useCallback } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  Upload,
+  Loader2,
+  Search,
+  Trash2,
+  ChevronDown,
+  ChevronUp,
+  HardDrive,
+  Droplet,
+  Settings2,
+  Layers,
+  AlertCircle,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { LocalPreset } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+
+// Known material types for name-parsing fallback
+const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'PVA', 'HIPS', 'PP', 'PET', 'NYLON'];
+
+const FILAMENT_TYPE_COLORS: Record<string, string> = {
+  PLA: 'E8E8E8', PETG: '4A90D9', ABS: 'E67E22', ASA: 'D35400',
+  TPU: '9B59B6', PC: 'BDC3C7', PA: '2ECC71', NYLON: '2ECC71',
+  PVA: 'F1C40F', HIPS: '95A5A6', PP: 'ECF0F1', PET: '3498DB',
+};
+
+// Extract material type from preset name as fallback
+function parseMaterialFromName(name: string): string | null {
+  const upper = name.toUpperCase();
+  for (const mat of MATERIAL_TYPES) {
+    if (new RegExp(`\\b${mat}\\b`).test(upper)) return mat;
+  }
+  return null;
+}
+
+// Extract vendor from preset name (text before the material type)
+function parseVendorFromName(name: string): string | null {
+  // Strip printer/nozzle suffix first (e.g. "@BBL X1C")
+  const clean = name.replace(/@.+$/, '').trim();
+  const upper = clean.toUpperCase();
+  for (const mat of MATERIAL_TYPES) {
+    const idx = upper.indexOf(mat);
+    if (idx > 0) {
+      const vendor = clean.slice(0, idx).trim();
+      // Skip if vendor looks like a generic prefix (e.g., "Generic", "Bambu")
+      if (vendor && vendor.length > 1) return vendor;
+    }
+  }
+  return null;
+}
+
+function PresetCard({
+  preset,
+  onDelete,
+  onExpand,
+  isExpanded,
+}: {
+  preset: LocalPreset;
+  onDelete: (id: number) => void;
+  onExpand: (id: number | null) => void;
+  isExpanded: boolean;
+}) {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+
+  // Resolve material type: DB field → parse from name
+  const material = preset.filament_type || parseMaterialFromName(preset.name);
+
+  // Resolve vendor: DB field → parse from name
+  const vendor = preset.filament_vendor || parseVendorFromName(preset.name);
+
+  // Parse colour for swatch — try explicit colour, then fall back to material type
+  let colourHex: string | null = null;
+  let hasExplicitColour = false;
+  if (preset.default_filament_colour) {
+    try {
+      const parsed = JSON.parse(preset.default_filament_colour);
+      const raw = Array.isArray(parsed) ? parsed[0] : parsed;
+      if (typeof raw === 'string' && /^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {
+        colourHex = raw.replace('#', '').slice(0, 6);
+        hasExplicitColour = true;
+      }
+    } catch {
+      const raw = preset.default_filament_colour;
+      if (/^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {
+        colourHex = raw.replace('#', '').slice(0, 6);
+        hasExplicitColour = true;
+      }
+    }
+  }
+  if (!colourHex && material) {
+    colourHex = FILAMENT_TYPE_COLORS[material.toUpperCase()] || null;
+  }
+
+  return (
+    <Card className="bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80 transition-colors">
+      <CardContent className="p-3">
+        <div className="flex items-start justify-between gap-2">
+          <div className="flex-1 min-w-0">
+            <div className="flex items-center gap-2 mb-1">
+              {/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */}
+              {preset.preset_type === 'filament' && (
+                <div
+                  className={`w-4 h-4 rounded-full border border-white/20 flex-shrink-0 ${
+                    !hasExplicitColour && !colourHex ? 'opacity-25' : !hasExplicitColour ? 'opacity-50' : ''
+                  }`}
+                  style={{ backgroundColor: colourHex ? `#${colourHex}` : '#666' }}
+                />
+              )}
+              <span className="text-sm font-medium text-white truncate">{preset.name}</span>
+            </div>
+
+            <div className="flex items-center gap-2 flex-wrap">
+              {/* 2) Material tag — fallback to name parsing */}
+              {material && (
+                <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
+                  {material}
+                </span>
+              )}
+              {/* 3) Vendor — fallback to name parsing */}
+              {vendor && (
+                <span className="text-xs text-bambu-gray">{vendor}</span>
+              )}
+              <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
+                {t('profiles.localProfiles.badge')}
+              </span>
+            </div>
+          </div>
+
+          <div className="flex items-center gap-1 flex-shrink-0">
+            {/* 4) Only delete, no edit */}
+            {hasPermission('settings:update') && (
+              <button
+                onClick={() => onDelete(preset.id)}
+                className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
+                title={t('profiles.localProfiles.delete')}
+              >
+                <Trash2 className="w-3.5 h-3.5" />
+              </button>
+            )}
+            <button
+              onClick={() => onExpand(isExpanded ? null : preset.id)}
+              className="p-1 text-bambu-gray hover:text-white transition-colors"
+            >
+              {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
+            </button>
+          </div>
+        </div>
+
+        {/* 5) Expanded detail — show meaningful fields, hide self-inherits */}
+        {isExpanded && (
+          <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary text-xs space-y-1.5">
+            {material && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.filamentType')}</span>
+                <span className="text-white">{material}</span>
+              </div>
+            )}
+            {vendor && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.vendor')}</span>
+                <span className="text-white">{vendor}</span>
+              </div>
+            )}
+            {preset.nozzle_temp_min != null && preset.nozzle_temp_max != null && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.nozzleTemp')}</span>
+                <span className="text-white">{preset.nozzle_temp_min}–{preset.nozzle_temp_max}°C</span>
+              </div>
+            )}
+            {preset.filament_cost && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.cost')}</span>
+                <span className="text-white">{preset.filament_cost}</span>
+              </div>
+            )}
+            {preset.filament_density && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.density')}</span>
+                <span className="text-white">{preset.filament_density} g/cm³</span>
+              </div>
+            )}
+            {preset.pressure_advance && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.pressureAdvance')}</span>
+                <span className="text-white">{preset.pressure_advance}</span>
+              </div>
+            )}
+            {preset.compatible_printers && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.compatiblePrinters')}</span>
+                <span className="text-white truncate ml-2">
+                  {(() => { try { return JSON.parse(preset.compatible_printers).join(', '); } catch { return preset.compatible_printers; } })()}
+                </span>
+              </div>
+            )}
+            {/* Only show inherits if different from own name */}
+            {preset.inherits && preset.inherits !== preset.name && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.inheritsFrom')}</span>
+                <span className="text-white truncate ml-2">{preset.inherits}</span>
+              </div>
+            )}
+            <div className="flex justify-between">
+              <span className="text-bambu-gray">{t('profiles.localProfiles.source')}</span>
+              <span className="text-white capitalize">{preset.source}</span>
+            </div>
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+export function LocalProfilesView() {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [searchQuery, setSearchQuery] = useState('');
+  const [expandedId, setExpandedId] = useState<number | null>(null);
+  const [isDragging, setIsDragging] = useState(false);
+  const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
+
+  const { data: presets, isLoading } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+  });
+
+  const importMutation = useMutation({
+    mutationFn: async (files: FileList) => {
+      const results = [];
+      for (const file of Array.from(files)) {
+        const formData = new FormData();
+        formData.append('file', file);
+        results.push(await api.importLocalPresets(formData));
+      }
+      return results;
+    },
+    onSuccess: (results) => {
+      queryClient.invalidateQueries({ queryKey: ['localPresets'] });
+      let totalImported = 0;
+      let totalSkipped = 0;
+      let totalErrors = 0;
+      for (const r of results) {
+        totalImported += r.imported;
+        totalSkipped += r.skipped;
+        totalErrors += r.errors.length;
+      }
+
+      if (totalImported > 0) {
+        showToast(t('profiles.localProfiles.toast.importSuccess', { count: totalImported }));
+      }
+      if (totalSkipped > 0) {
+        showToast(t('profiles.localProfiles.toast.importSkipped', { count: totalSkipped }), 'warning');
+      }
+      if (totalErrors > 0) {
+        showToast(t('profiles.localProfiles.toast.importError', { count: totalErrors }), 'error');
+      }
+    },
+    onError: (err: Error) => {
+      showToast(err.message, 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteLocalPreset(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['localPresets'] });
+      setDeleteConfirm(null);
+      showToast(t('profiles.localProfiles.toast.deleted'));
+    },
+  });
+
+  const handleFiles = useCallback((files: FileList | null) => {
+    if (!files || files.length === 0) return;
+    importMutation.mutate(files);
+  }, [importMutation]);
+
+  const handleDrop = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(false);
+    handleFiles(e.dataTransfer.files);
+  }, [handleFiles]);
+
+  const filterPresets = useCallback((list: LocalPreset[]) => {
+    if (!searchQuery) return list;
+    const q = searchQuery.toLowerCase();
+    return list.filter(p =>
+      p.name.toLowerCase().includes(q) ||
+      p.filament_type?.toLowerCase().includes(q) ||
+      p.filament_vendor?.toLowerCase().includes(q)
+    );
+  }, [searchQuery]);
+
+  const filaments = useMemo(() => filterPresets(presets?.filament || []), [presets?.filament, filterPresets]);
+  const printers = useMemo(() => filterPresets(presets?.printer || []), [presets?.printer, filterPresets]);
+  const processes = useMemo(() => filterPresets(presets?.process || []), [presets?.process, filterPresets]);
+  const totalCount = filaments.length + printers.length + processes.length;
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-16">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Import Zone */}
+      {hasPermission('settings:update') && (
+        <div
+          onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
+          onDragLeave={() => setIsDragging(false)}
+          onDrop={handleDrop}
+          className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
+            isDragging
+              ? 'border-bambu-green bg-bambu-green/10'
+              : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+          }`}
+        >
+          <input
+            type="file"
+            accept=".json,.zip,.orca_filament,.bbscfg,.bbsflmt"
+            multiple
+            className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+            onChange={(e) => handleFiles(e.target.files)}
+          />
+          {importMutation.isPending ? (
+            <div className="flex items-center justify-center gap-2">
+              <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
+              <span className="text-bambu-gray">{t('profiles.localProfiles.importing')}</span>
+            </div>
+          ) : (
+            <>
+              <Upload className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
+              <p className="text-sm text-white font-medium">{t('profiles.localProfiles.import')}</p>
+              <p className="text-xs text-bambu-gray mt-1">{t('profiles.localProfiles.importDesc')}</p>
+            </>
+          )}
+        </div>
+      )}
+
+      {/* Search Bar */}
+      {totalCount > 0 && (
+        <div className="relative">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+          <input
+            type="text"
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            placeholder={t('profiles.localProfiles.search')}
+            className="w-full pl-9 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+          />
+        </div>
+      )}
+
+      {/* No Presets */}
+      {totalCount === 0 && !isLoading && (
+        <div className="text-center py-12">
+          <HardDrive className="w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50" />
+          <p className="text-bambu-gray">{t('profiles.localProfiles.noPresets')}</p>
+          <p className="text-xs text-bambu-gray/60 mt-1">{t('profiles.localProfiles.importDesc')}</p>
+        </div>
+      )}
+
+      {/* 3-Column Preset Lists */}
+      {totalCount > 0 && (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+          {/* Filament Column */}
+          {filaments.length > 0 && (
+            <div>
+              <div className="flex items-center gap-2 mb-3">
+                <Droplet className="w-4 h-4 text-bambu-green" />
+                <h3 className="text-sm font-medium text-white">
+                  {t('profiles.localProfiles.filament')}
+                </h3>
+                <span className="text-xs text-bambu-gray">({filaments.length})</span>
+              </div>
+              <div className="space-y-2">
+                {filaments.map(p => (
+                  <PresetCard
+                    key={p.id}
+                    preset={p}
+                    onDelete={(id) => setDeleteConfirm(id)}
+                    onExpand={setExpandedId}
+                    isExpanded={expandedId === p.id}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Process Column */}
+          {processes.length > 0 && (
+            <div>
+              <div className="flex items-center gap-2 mb-3">
+                <Layers className="w-4 h-4 text-blue-400" />
+                <h3 className="text-sm font-medium text-white">
+                  {t('profiles.localProfiles.process')}
+                </h3>
+                <span className="text-xs text-bambu-gray">({processes.length})</span>
+              </div>
+              <div className="space-y-2">
+                {processes.map(p => (
+                  <PresetCard
+                    key={p.id}
+                    preset={p}
+                    onDelete={(id) => setDeleteConfirm(id)}
+                    onExpand={setExpandedId}
+                    isExpanded={expandedId === p.id}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Printer Column */}
+          {printers.length > 0 && (
+            <div>
+              <div className="flex items-center gap-2 mb-3">
+                <Settings2 className="w-4 h-4 text-orange-400" />
+                <h3 className="text-sm font-medium text-white">
+                  {t('profiles.localProfiles.printer')}
+                </h3>
+                <span className="text-xs text-bambu-gray">({printers.length})</span>
+              </div>
+              <div className="space-y-2">
+                {printers.map(p => (
+                  <PresetCard
+                    key={p.id}
+                    preset={p}
+                    onDelete={(id) => setDeleteConfirm(id)}
+                    onExpand={setExpandedId}
+                    isExpanded={expandedId === p.id}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deleteConfirm !== null && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+          <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-6 max-w-sm mx-4">
+            <div className="flex items-center gap-2 mb-3">
+              <AlertCircle className="w-5 h-5 text-red-400" />
+              <h3 className="text-white font-medium">{t('profiles.localProfiles.deleteConfirmTitle')}</h3>
+            </div>
+            <p className="text-sm text-bambu-gray mb-4">{t('profiles.localProfiles.deleteConfirm')}</p>
+            <div className="flex justify-end gap-2">
+              <Button variant="secondary" size="sm" onClick={() => setDeleteConfirm(null)}>
+                {t('profiles.localProfiles.cancel')}
+              </Button>
+              <Button
+                variant="danger"
+                size="sm"
+                onClick={() => deleteMutation.mutate(deleteConfirm)}
+                disabled={deleteMutation.isPending}
+              >
+                {deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
+                {t('profiles.localProfiles.delete')}
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 6 - 3
frontend/src/components/ModelViewerModal.tsx

@@ -1,11 +1,12 @@
 import { useState, useEffect, useRef, useMemo } from 'react';
 import { useState, useEffect, useRef, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
 import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
 import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { openInSlicer } from '../utils/slicer';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
 type ViewTab = '3d' | 'gcode';
 type ViewTab = '3d' | 'gcode';
@@ -28,6 +29,8 @@ interface Capabilities {
 
 
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
   const isLibrary = libraryFileId != null;
   const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -266,11 +269,11 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     const filename = title || 'model';
     const filename = title || 'model';
     if (isLibrary) {
     if (isLibrary) {
       const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
       const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-      openInSlicer(downloadUrl);
+      openInSlicer(downloadUrl, preferredSlicer);
       return;
       return;
     }
     }
     const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
     const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-    openInSlicer(downloadUrl);
+    openInSlicer(downloadUrl, preferredSlicer);
   };
   };
 
 
   return (
   return (

+ 12 - 10
frontend/src/components/RichTextEditor.tsx

@@ -18,6 +18,7 @@ import {
   Link as LinkIcon,
   Link as LinkIcon,
   Unlink,
   Unlink,
 } from 'lucide-react';
 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 
 
 interface RichTextEditorProps {
 interface RichTextEditorProps {
   content: string;
   content: string;
@@ -26,6 +27,7 @@ interface RichTextEditorProps {
 }
 }
 
 
 export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
 export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
+  const { t } = useTranslation();
   const editor = useEditor({
   const editor = useEditor({
     extensions: [
     extensions: [
       StarterKit.configure({
       StarterKit.configure({
@@ -105,21 +107,21 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().toggleBold().run()}
           onClick={() => editor.chain().focus().toggleBold().run()}
           isActive={editor.isActive('bold')}
           isActive={editor.isActive('bold')}
-          title="Bold"
+          title={t('richTextEditor.bold')}
         >
         >
           <Bold className="w-4 h-4" />
           <Bold className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().toggleItalic().run()}
           onClick={() => editor.chain().focus().toggleItalic().run()}
           isActive={editor.isActive('italic')}
           isActive={editor.isActive('italic')}
-          title="Italic"
+          title={t('richTextEditor.italic')}
         >
         >
           <Italic className="w-4 h-4" />
           <Italic className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().toggleUnderline().run()}
           onClick={() => editor.chain().focus().toggleUnderline().run()}
           isActive={editor.isActive('underline')}
           isActive={editor.isActive('underline')}
-          title="Underline"
+          title={t('richTextEditor.underline')}
         >
         >
           <UnderlineIcon className="w-4 h-4" />
           <UnderlineIcon className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
@@ -129,14 +131,14 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().toggleBulletList().run()}
           onClick={() => editor.chain().focus().toggleBulletList().run()}
           isActive={editor.isActive('bulletList')}
           isActive={editor.isActive('bulletList')}
-          title="Bullet List"
+          title={t('richTextEditor.bulletList')}
         >
         >
           <List className="w-4 h-4" />
           <List className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().toggleOrderedList().run()}
           onClick={() => editor.chain().focus().toggleOrderedList().run()}
           isActive={editor.isActive('orderedList')}
           isActive={editor.isActive('orderedList')}
-          title="Numbered List"
+          title={t('richTextEditor.numberedList')}
         >
         >
           <ListOrdered className="w-4 h-4" />
           <ListOrdered className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
@@ -146,21 +148,21 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().setTextAlign('left').run()}
           onClick={() => editor.chain().focus().setTextAlign('left').run()}
           isActive={editor.isActive({ textAlign: 'left' })}
           isActive={editor.isActive({ textAlign: 'left' })}
-          title="Align Left"
+          title={t('richTextEditor.alignLeft')}
         >
         >
           <AlignLeft className="w-4 h-4" />
           <AlignLeft className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().setTextAlign('center').run()}
           onClick={() => editor.chain().focus().setTextAlign('center').run()}
           isActive={editor.isActive({ textAlign: 'center' })}
           isActive={editor.isActive({ textAlign: 'center' })}
-          title="Align Center"
+          title={t('richTextEditor.alignCenter')}
         >
         >
           <AlignCenter className="w-4 h-4" />
           <AlignCenter className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
         <ToolbarButton
         <ToolbarButton
           onClick={() => editor.chain().focus().setTextAlign('right').run()}
           onClick={() => editor.chain().focus().setTextAlign('right').run()}
           isActive={editor.isActive({ textAlign: 'right' })}
           isActive={editor.isActive({ textAlign: 'right' })}
-          title="Align Right"
+          title={t('richTextEditor.alignRight')}
         >
         >
           <AlignRight className="w-4 h-4" />
           <AlignRight className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
@@ -170,14 +172,14 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
         <ToolbarButton
         <ToolbarButton
           onClick={setLink}
           onClick={setLink}
           isActive={editor.isActive('link')}
           isActive={editor.isActive('link')}
-          title="Add Link"
+          title={t('richTextEditor.addLink')}
         >
         >
           <LinkIcon className="w-4 h-4" />
           <LinkIcon className="w-4 h-4" />
         </ToolbarButton>
         </ToolbarButton>
         {editor.isActive('link') && (
         {editor.isActive('link') && (
           <ToolbarButton
           <ToolbarButton
             onClick={() => editor.chain().focus().unsetLink().run()}
             onClick={() => editor.chain().focus().unsetLink().run()}
-            title="Remove Link"
+            title={t('richTextEditor.removeLink')}
           >
           >
             <Unlink className="w-4 h-4" />
             <Unlink className="w-4 h-4" />
           </ToolbarButton>
           </ToolbarButton>

+ 271 - 0
frontend/src/components/SkipObjectsModal.tsx

@@ -0,0 +1,271 @@
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { X, Loader2, Monitor, AlertCircle, Box } from 'lucide-react';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+
+// Custom Skip Objects icon - arrow jumping over boxes
+export const SkipObjectsIcon = ({ className }: { className?: string }) => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
+    {/* Three boxes at the bottom */}
+    <rect x="2" y="15" width="5" height="5" rx="0.5" />
+    <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
+    <rect x="17" y="15" width="5" height="5" rx="0.5" />
+    {/* Curved arrow jumping over first box */}
+    <path d="M4 12 C4 6, 14 6, 14 12" />
+    <polyline points="12,10 14,12 12,14" />
+  </svg>
+);
+
+interface SkipObjectsModalProps {
+  printerId: number;
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    refetchInterval: 30000,
+    enabled: isOpen,
+  });
+
+  const { data: objectsData, refetch: refetchObjects } = useQuery({
+    queryKey: ['printableObjects', printerId],
+    queryFn: () => api.getPrintableObjects(printerId),
+    enabled: isOpen,
+    refetchInterval: isOpen ? 5000 : false,
+  });
+
+  const skipObjectsMutation = useMutation({
+    mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
+    onSuccess: (data) => {
+      showToast(data.message || t('printers.skipObjects.objectsSkipped'));
+      refetchObjects();
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
+  });
+
+  if (!isOpen) return null;
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center"
+      onClick={onClose}
+      onKeyDown={(e) => e.key === 'Escape' && onClose()}
+      tabIndex={-1}
+      ref={(el) => el?.focus()}
+    >
+      {/* Backdrop */}
+      <div className="absolute inset-0 bg-black/50 z-0" />
+      {/* Modal */}
+      <div
+        className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
+          <div className="flex items-center gap-2">
+            <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
+            <span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+
+        {!objectsData ? (
+          <div className="flex items-center justify-center py-12">
+            <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
+          </div>
+        ) : objectsData.objects.length === 0 ? (
+          <div className="text-center py-8 px-4 text-bambu-gray">
+            <p className="text-sm">{t('printers.noObjectsFound')}</p>
+            <p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
+          </div>
+        ) : (
+          <div className="flex flex-col overflow-hidden">
+            {/* Info Banner */}
+            <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+              <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
+                <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
+              </div>
+              <div className="flex-1 min-w-0">
+                <p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
+                <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
+              </div>
+              <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
+                {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
+              </div>
+            </div>
+
+            {/* Layer Warning */}
+            {(status?.layer_num ?? 0) <= 1 && (
+              <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+                <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
+                <p className="text-xs text-amber-600 dark:text-amber-400">
+                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
+                </p>
+              </div>
+            )}
+
+            {/* Content: Image + List side by side */}
+            <div className="flex flex-1 overflow-hidden">
+              {/* Left: Preview Image with object markers */}
+              <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
+                <div className="relative">
+                  {status?.cover_url ? (
+                    <img
+                      src={`${status.cover_url}?view=top`}
+                      alt={t('printers.printPreview')}
+                      className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
+                    />
+                  ) : (
+                    <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
+                      <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
+                    </div>
+                  )}
+                  {/* Object ID markers overlay - positioned based on object data */}
+                  {objectsData.objects.length > 0 && (
+                    <div className="absolute inset-0 pointer-events-none">
+                      {objectsData.objects.map((obj, idx) => {
+                        let x: number, y: number;
+
+                        // Use position data if available, otherwise fall back to grid
+                        if (obj.x != null && obj.y != null && objectsData.bbox_all) {
+                          // bbox_all defines the visible area in the top_N.png image
+                          // Format: [x_min, y_min, x_max, y_max] in mm
+                          const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
+                          const bboxWidth = xMax - xMin;
+                          const bboxHeight = yMax - yMin;
+
+                          // The image shows bbox_all area with some padding (~5-10%)
+                          const padding = 8;
+                          const contentArea = 100 - (padding * 2);
+
+                          // Map object position to image percentage
+                          x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
+                          // Y axis: image Y increases downward, but 3D Y increases toward back
+                          y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
+
+                          // Clamp to valid range
+                          x = Math.max(5, Math.min(95, x));
+                          y = Math.max(5, Math.min(95, y));
+                        } else if (obj.x != null && obj.y != null) {
+                          // Fallback: use full build plate (256mm)
+                          const buildPlate = 256;
+                          x = (obj.x / buildPlate) * 100;
+                          y = 100 - (obj.y / buildPlate) * 100;
+                          x = Math.max(5, Math.min(95, x));
+                          y = Math.max(5, Math.min(95, y));
+                        } else {
+                          // Fallback: arrange in a grid pattern over the build plate area
+                          const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
+                          const row = Math.floor(idx / cols);
+                          const col = idx % cols;
+                          const rows = Math.ceil(objectsData.objects.length / cols);
+                          x = 15 + (col * (70 / cols)) + (35 / cols);
+                          y = 15 + (row * (70 / rows)) + (35 / rows);
+                        }
+
+                        return (
+                          <div
+                            key={obj.id}
+                            className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
+                              obj.skipped
+                                ? 'bg-red-500 text-white line-through'
+                                : 'bg-bambu-green text-black'
+                            }`}
+                            style={{
+                              left: `${x}%`,
+                              top: `${y}%`,
+                              transform: 'translate(-50%, -50%)'
+                            }}
+                            title={obj.name}
+                          >
+                            {obj.id}
+                          </div>
+                        );
+                      })}
+                    </div>
+                  )}
+                  {/* Object count overlay */}
+                  <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
+                    {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
+                  </div>
+                </div>
+              </div>
+
+              {/* Right: Object List with prominent IDs */}
+              <div className="flex-1 min-w-0 overflow-y-auto">
+                {objectsData.objects.map((obj) => (
+                  <div
+                    key={obj.id}
+                    className={`
+                      flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
+                      ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
+                    `}
+                  >
+                    {/* Large prominent ID badge */}
+                    <div className={`
+                      w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
+                      ${obj.skipped
+                        ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
+                        : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
+                    `}>
+                      <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
+                        {obj.id}
+                      </span>
+                      <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
+                        ID
+                      </span>
+                    </div>
+
+                    {/* Object name and status */}
+                    <div className="flex-1 min-w-0">
+                      <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
+                        {obj.name}
+                      </span>
+                      {obj.skipped && (
+                        <span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
+                      )}
+                    </div>
+
+                    {/* Skip button */}
+                    {!obj.skipped ? (
+                      <button
+                        onClick={() => skipObjectsMutation.mutate([obj.id])}
+                        disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
+                        className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
+                          (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
+                            ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
+                            : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
+                        }`}
+                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
+                      >
+                        {t('printers.skipObjects.skip')}
+                      </button>
+                    ) : (
+                      <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
+                        {t('printers.skipObjects.skipped')}
+                      </span>
+                    )}
+                  </div>
+                ))}
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 10 - 8
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
 import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -14,6 +15,7 @@ interface SmartPlugCardProps {
 }
 }
 
 
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -171,7 +173,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               ) : (
               ) : (
                 <div className="flex items-center gap-1 text-sm text-status-error">
                 <div className="flex items-center gap-1 text-sm text-status-error">
                   <WifiOff className="w-4 h-4" />
                   <WifiOff className="w-4 h-4" />
-                  <span>Offline</span>
+                  <span>{t('smartPlugs.offline')}</span>
                 </div>
                 </div>
               )}
               )}
               {/* Admin page link - only for Tasmota */}
               {/* Admin page link - only for Tasmota */}
@@ -181,10 +183,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   target="_blank"
                   target="_blank"
                   rel="noopener noreferrer"
                   rel="noopener noreferrer"
                   className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
                   className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
-                  title="Open plug admin page"
+                  title={t('smartPlugs.openPlugAdminPage')}
                 >
                 >
                   <ExternalLink className="w-3 h-3" />
                   <ExternalLink className="w-3 h-3" />
-                  Admin
+                  {t('smartPlugs.admin')}
                 </a>
                 </a>
               )}
               )}
             </div>
             </div>
@@ -449,7 +451,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       {/* Delete Confirmation */}
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
       {showDeleteConfirm && (
         <ConfirmModal
         <ConfirmModal
-          title="Delete Smart Plug"
+          title={t('smartPlugs.deleteSmartPlug')}
           message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
           message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
           confirmText="Delete"
           confirmText="Delete"
           variant="danger"
           variant="danger"
@@ -464,9 +466,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       {/* Power On Confirmation */}
       {/* Power On Confirmation */}
       {showPowerOnConfirm && (
       {showPowerOnConfirm && (
         <ConfirmModal
         <ConfirmModal
-          title="Turn On Smart Plug"
+          title={t('smartPlugs.turnOnSmartPlug')}
           message={`Are you sure you want to turn on "${plug.name}"?`}
           message={`Are you sure you want to turn on "${plug.name}"?`}
-          confirmText="Turn On"
+          confirmText={t('smartPlugs.turnOn')}
           variant="default"
           variant="default"
           onConfirm={() => {
           onConfirm={() => {
             controlMutation.mutate('on');
             controlMutation.mutate('on');
@@ -479,9 +481,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       {/* Power Off Confirmation */}
       {/* Power Off Confirmation */}
       {showPowerOffConfirm && (
       {showPowerOffConfirm && (
         <ConfirmModal
         <ConfirmModal
-          title="Turn Off Smart Plug"
+          title={t('smartPlugs.turnOffSmartPlug')}
           message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
           message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
-          confirmText="Turn Off"
+          confirmText={t('smartPlugs.turnOff')}
           variant="danger"
           variant="danger"
           onConfirm={() => {
           onConfirm={() => {
             controlMutation.mutate('off');
             controlMutation.mutate('off');

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