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

Merge branch '0.1.9b' into main

MartinNYHC 3 месяцев назад
Родитель
Сommit
991f03ca69
66 измененных файлов с 5793 добавлено и 897 удалено
  1. 6 0
      .env.example
  2. 43 0
      CHANGELOG.md
  3. 3 0
      Dockerfile
  4. 6 5
      README.md
  5. 5 0
      backend/app/api/routes/archives.py
  6. 4 0
      backend/app/api/routes/discovery.py
  7. 14 0
      backend/app/api/routes/printers.py
  8. 41 0
      backend/app/api/routes/settings.py
  9. 14 7
      backend/app/api/routes/smart_plugs.py
  10. 61 6
      backend/app/api/routes/spoolman.py
  11. 258 3
      backend/app/api/routes/support.py
  12. 1 1
      backend/app/core/config.py
  13. 35 5
      backend/app/main.py
  14. 1 1
      backend/app/models/printer.py
  15. 21 2
      backend/app/schemas/printer.py
  16. 7 0
      backend/app/schemas/settings.py
  17. 24 5
      backend/app/services/bambu_mqtt.py
  18. 2 0
      backend/app/services/printer_manager.py
  19. 5 13
      backend/app/services/smart_plug_manager.py
  20. 89 18
      backend/app/services/spoolman.py
  21. 28 10
      backend/app/services/virtual_printer/certificate.py
  22. 113 43
      backend/app/services/virtual_printer/ftp_server.py
  23. 21 5
      backend/app/services/virtual_printer/manager.py
  24. 85 67
      backend/app/services/virtual_printer/mqtt_server.py
  25. 78 8
      backend/app/services/virtual_printer/ssdp_server.py
  26. 570 3
      backend/app/services/virtual_printer/tcp_proxy.py
  27. 26 0
      backend/tests/integration/test_discovery_api.py
  28. 52 0
      backend/tests/integration/test_printers_api.py
  29. 248 0
      backend/tests/integration/test_settings_api.py
  30. 70 2
      backend/tests/integration/test_spoolman_api.py
  31. 99 0
      backend/tests/unit/services/conftest.py
  32. 240 0
      backend/tests/unit/services/mock_ftp_server.py
  33. 864 0
      backend/tests/unit/services/test_bambu_ftp.py
  34. 203 1
      backend/tests/unit/services/test_spoolman_service.py
  35. 5 5
      backend/tests/unit/services/test_virtual_printer.py
  36. 229 0
      backend/tests/unit/test_homeassistant_settings.py
  37. 428 0
      backend/tests/unit/test_support_helpers.py
  38. 13 1
      docker-compose.yml
  39. 182 0
      frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx
  40. 168 0
      frontend/src/__tests__/components/FilamentHoverCard.test.tsx
  41. 13 0
      frontend/src/__tests__/mocks/handlers.ts
  42. 8 5
      frontend/src/__tests__/pages/CameraPage.test.tsx
  43. 22 0
      frontend/src/__tests__/pages/SystemInfoPage.test.tsx
  44. 78 0
      frontend/src/__tests__/utils/getSpoolmanFillLevel.test.ts
  45. 20 1
      frontend/src/api/client.ts
  46. 2 5
      frontend/src/components/AddExternalLinkModal.tsx
  47. 83 4
      frontend/src/components/EmbeddedCameraViewer.tsx
  48. 5 1
      frontend/src/components/FilamentHoverCard.tsx
  49. 5 8
      frontend/src/components/FilamentTrends.tsx
  50. 1 1
      frontend/src/components/Layout.tsx
  51. 271 0
      frontend/src/components/SkipObjectsModal.tsx
  52. 14 1
      frontend/src/i18n/locales/de.ts
  53. 14 1
      frontend/src/i18n/locales/en.ts
  54. 572 361
      frontend/src/i18n/locales/ja.ts
  55. 81 5
      frontend/src/pages/CameraPage.tsx
  56. 155 268
      frontend/src/pages/PrintersPage.tsx
  57. 67 19
      frontend/src/pages/SettingsPage.tsx
  58. 6 0
      frontend/src/pages/SystemInfoPage.tsx
  59. 3 0
      requirements-dev.txt
  60. 0 0
      static/assets/index-BTJM8cN7.css
  61. 0 0
      static/assets/index-CBPKqOAD.js
  62. 0 0
      static/assets/index-togsBDt6.css
  63. 2 2
      static/index.html
  64. 1 1
      test_all.sh
  65. 6 1
      test_backend.sh
  66. 2 2
      update_website_wiki.sh

+ 6 - 0
.env.example

@@ -10,3 +10,9 @@ LOG_LEVEL=INFO
 
 # Enable file logging (logs written to logs/bambutrack.log)
 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

+ 43 - 0
CHANGELOG.md

@@ -2,6 +2,49 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.9b] - Not released
+
+### New Features
+- **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
+- **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
+- **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.
+- **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.
+
+### Documentation
+- **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
+
 ## [0.1.8.1] - 2026-02-07
 
 ### Fixed

+ 3 - 0
Dockerfile

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

+ 6 - 5
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:
 
-- 🔒 **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
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
@@ -147,7 +148,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Queue events (waiting, skipped, failed)
 
 ### 🔧 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.
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management
@@ -163,7 +164,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Send prints directly from Bambu Studio/Orca Slicer
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - 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)
 - Secure TLS/MQTT/FTP communication
 
 ### 🛠️ Maintenance & Support
@@ -174,7 +175,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Firmware update helper (LAN-only printers)
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
-- Support bundle generator (privacy-filtered)
+- Support bundle generator with comprehensive diagnostics (privacy-filtered)
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
@@ -452,7 +453,7 @@ services:
     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>
 

+ 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 = 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
         for plug in plugs:
             if plug.plug_type == "tasmota":

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

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

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

@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
     AMSUnit,
     HMSErrorResponse,
     NozzleInfoResponse,
+    NozzleRackSlot,
     PrinterCreate,
     PrinterResponse,
     PrinterStatus,
@@ -360,6 +361,18 @@ async def get_printer_status(
         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"),
+        )
+        for n in (state.nozzle_rack or [])
+    ]
+
     # Convert print options to response format
     print_options = PrintOptionsResponse(
         spaghetti_detector=state.print_options.spaghetti_detector,
@@ -423,6 +436,7 @@ async def get_printer_status(
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
         nozzles=nozzles,
+        nozzle_rack=nozzle_rack,
         print_options=print_options,
         stg_cur=state.stg_cur,
         stg_cur_name=get_derived_status_name(state, printer.model),

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

@@ -101,6 +101,10 @@ async def get_settings(
             else:
                 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)
 
 
@@ -247,6 +251,43 @@ async def update_spoolman_settings(
     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")
 async def create_backup(
     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.
     """
-    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:
         raise HTTPException(
@@ -376,8 +379,11 @@ async def list_ha_sensor_entities(
     Returns sensors with power/energy units (W, kW, kWh, Wh).
     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:
         raise HTTPException(
@@ -546,9 +552,10 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
     """
     if plug.plug_type == "homeassistant":
         # 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 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__}",
         )
 
+    # 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:
         if not isinstance(ams_unit, dict):
             continue
@@ -257,9 +270,20 @@ async def sync_printer_ams(
                 current_tray_uuids.add(spool_tag.upper())
 
             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:
                     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(
                         "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
     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:
             logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
     except Exception as e:
@@ -320,6 +346,19 @@ async def sync_all_printers(
     # Track tray UUIDs per printer (for clearing removed spools)
     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:
         state = printer_manager.get_status(printer.id)
         if not state or not state.raw_data:
@@ -394,17 +433,28 @@ async def sync_all_printers(
 
                 try:
                     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:
                         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:
                     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
     for printer_name, current_tray_uuids in printer_tray_uuids.items():
         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:
                 logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
         except Exception as e:
@@ -548,7 +598,7 @@ async def get_linked_spools(
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
     spools = await client.get_spools()
-    linked: dict[str, int] = {}
+    linked: dict[str, dict] = {}
 
     for spool in spools:
         # 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)
             clean_tag = tag.strip('"').upper()
             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}
 

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

@@ -1,6 +1,9 @@
 """Support endpoints for debug logging and support bundle generation."""
 
+import asyncio
+import importlib.metadata
 import io
+import ipaddress
 import json
 import logging
 import os
@@ -8,24 +11,30 @@ import platform
 import re
 import zipfile
 from datetime import datetime
+from pathlib import Path
 
 from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
-from sqlalchemy import func, select
+from sqlalchemy import func, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import async_session
 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.filament import Filament
+from backend.app.models.notification import NotificationProvider
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 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"])
 logger = logging.getLogger(__name__)
@@ -313,8 +322,71 @@ def _sanitize_path(path: str) -> str:
     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:
     """Collect all support information."""
+    in_docker = is_running_in_docker()
+
     info = {
         "generated_at": datetime.now().isoformat(),
         "app": {
@@ -329,15 +401,29 @@ async def _collect_support_info() -> dict:
             "python_version": platform.python_version(),
         },
         "environment": {
-            "docker": os.path.exists("/.dockerenv"),
+            "docker": in_docker,
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
+            "timezone": os.environ.get("TZ", ""),
         },
         "database": {},
         "printers": [],
         "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:
         # Database stats
         result = await db.execute(select(func.count(PrintArchive.id)))
@@ -358,15 +444,52 @@ async def _collect_support_info() -> dict:
         result = await db.execute(select(func.count(SmartPlug.id)))
         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))
         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):
+            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, dict) and "ams" in ams_data:
+                    ams_units = ams_data["ams"]
+                    if isinstance(ams_units, list):
+                        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(
                 {
                     "index": i + 1,
                     "model": printer.model or "Unknown",
                     "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 +519,138 @@ async def _collect_support_info() -> dict:
                 continue
             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
 
 

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

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

+ 35 - 5
backend/app/main.py

@@ -260,11 +260,10 @@ async def _get_plug_energy(plug, db) -> dict | None:
     For MQTT plugs, returns data from the subscription service.
     """
     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)
     elif plug.plug_type == "mqtt":
         # MQTT plugs report "today" energy, not lifetime total
@@ -557,6 +556,20 @@ async def on_ams_change(printer_id: int, ams_data: list):
             printer = result.scalar_one_or_none()
             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
             synced = 0
             for ams_unit in ams_data:
@@ -569,9 +582,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         continue  # Empty tray
 
                     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:
                             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:
                         logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
 

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

@@ -12,7 +12,7 @@ class Printer(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))
     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))
     model: Mapped[str | None] = mapped_column(String(50))
     location: Mapped[str | None] = mapped_column(String(100))  # Group/location name

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

@@ -6,7 +6,11 @@ from pydantic import BaseModel, Field
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     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)
     model: str | None = None
     location: str | None = None  # Group/location name
@@ -31,7 +35,11 @@ class PlateDetectionROI(BaseModel):
 
 class PrinterUpdate(BaseModel):
     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
     model: str | None = None
     location: str | None = None
@@ -137,6 +145,16 @@ class NozzleInfoResponse(BaseModel):
     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)
+
+
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
 
@@ -183,6 +201,7 @@ class PrinterStatus(BaseModel):
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     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
     # Calibration stage tracking
     stg_cur: int = -1  # Current stage number (-1 = not calibrating)

+ 7 - 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_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_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
     library_archive_mode: str = Field(

+ 24 - 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}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     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)
     last_ams_update: float = 0.0
     # Printable objects for skip object functionality: {identify_id: object_name}
@@ -1740,12 +1742,24 @@ class BambuMQTTClient:
         if "nozzle_diameter_2" in data:
             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):
             device = data["device"]
             nozzle_data = device.get("nozzle", {})
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
+                # H2C tool-changer: >2 entries means nozzle rack (6 dock + 1 mounted = 7)
+                if len(nozzle_info) > 2:
+                    self.state.nozzle_rack = [
+                        {
+                            "id": n.get("id", i),
+                            "type": str(n.get("type", "")),
+                            "diameter": str(n.get("diameter", "")),
+                            "wear": n.get("wear"),
+                            "stat": n.get("stat"),
+                        }
+                        for i, n in enumerate(nozzle_info)
+                    ]
                 for nozzle in nozzle_info:
                     idx = nozzle.get("id", 0)
                     if idx < len(self.state.nozzles):
@@ -2049,8 +2063,10 @@ class BambuMQTTClient:
                         slot_id = tray_id % 4
                         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")
 
             command = {
@@ -2068,7 +2084,7 @@ class BambuMQTTClient:
                     "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,
                     "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",
                     "extrude_cali_flag": 0,
                     "extrude_cali_manual_mode": 0,
@@ -2082,7 +2098,10 @@ class BambuMQTTClient:
             }
 
             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 printer doesn't support vibration calibration like X1/P1 series

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

@@ -647,6 +647,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "chamber_light": state.chamber_light,
         # Active extruder for dual-nozzle printers (0=right, 1=left)
         "active_extruder": state.active_extruder,
+        # H2C nozzle rack (tool-changer dock positions)
+        "nozzle_rack": state.nozzle_rack or [],
     }
     # 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

+ 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):
         """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:
             if db:
                 # 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:
                 # Create new session
                 from backend.app.core.database import async_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:
             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."""
 
+import asyncio
 import logging
 from dataclasses import dataclass
 from datetime import datetime, timezone
@@ -68,9 +69,22 @@ class SpoolmanClient:
         self._connected = False
 
     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:
-            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
 
     async def close(self):
@@ -101,19 +115,59 @@ class SpoolmanClient:
         return self._connected
 
     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:
             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]:
         """Get all internal filaments from Spoolman.
@@ -387,16 +441,18 @@ class SpoolmanClient:
             logger.error("Failed to record spool usage in Spoolman: %s", e)
             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.
 
         Args:
             tag_uid: The RFID tag UID to search for
+            cached_spools: Optional pre-fetched list of spools to search (avoids API call)
 
         Returns:
             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)
         search_tag = tag_uid.strip('"').upper()
 
@@ -412,16 +468,20 @@ class SpoolmanClient:
                         return spool
         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.
 
         Args:
             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:
             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 = []
         for spool in spools:
             location = spool.get("location", "")
@@ -433,6 +493,7 @@ class SpoolmanClient:
         self,
         printer_name: str,
         current_tray_uuids: set[str],
+        cached_spools: list[dict] | None = None,
     ) -> int:
         """Clear location for spools that are no longer in the AMS.
 
@@ -443,12 +504,13 @@ class SpoolmanClient:
         Args:
             printer_name: The printer name used as location prefix
             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:
             Number of spools whose location was cleared.
         """
         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
 
         for spool in spools_at_printer:
@@ -662,7 +724,13 @@ class SpoolmanClient:
         """
         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.
 
         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
             disable_weight_sync: If True, skip updating remaining_weight for existing spools.
                 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:
             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)}"
 
         # 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:
             # Update existing spool
             logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])

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

@@ -193,13 +193,39 @@ class CertificateService:
 
         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).
 
         Creates a certificate chain mimicking real Bambu printers:
         - CA certificate (reused if exists and valid, otherwise generated)
         - 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:
             Tuple of (cert_path, key_path)
         """
@@ -245,15 +271,7 @@ class CertificateService:
                 critical=True,
             )
             .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,
             )
             .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 logging
+import os
 import random
 import ssl
 from collections.abc import Callable
@@ -31,6 +32,8 @@ class FTPSession:
         access_code: str,
         ssl_context: ssl.SSLContext,
         on_file_received: Callable[[Path, str], None] | None,
+        passive_port_range: tuple[int, int] = (50000, 50100),
+        pasv_address: str = "",
     ):
         self.reader = reader
         self.writer = writer
@@ -38,6 +41,8 @@ class FTPSession:
         self.access_code = access_code
         self.ssl_context = ssl_context
         self.on_file_received = on_file_received
+        self.passive_port_range = passive_port_range
+        self.pasv_address = pasv_address
 
         self.authenticated = False
         self.username: str | None = None
@@ -50,6 +55,7 @@ class FTPSession:
         self._data_reader: asyncio.StreamReader | None = None
         self._data_writer: asyncio.StreamWriter | None = None
         self._data_connected = asyncio.Event()
+        self._transfer_done = asyncio.Event()
 
         peername = writer.get_extra_info("peername")
         self.remote_ip = peername[0] if peername else "unknown"
@@ -113,6 +119,9 @@ class FTPSession:
 
     async def _cleanup(self) -> None:
         """Clean up session resources."""
+        # Release any waiting data connection callback
+        self._transfer_done.set()
+
         if self.data_server:
             self.data_server.close()
             try:
@@ -159,6 +168,7 @@ class FTPSession:
         features = [
             "211-Features:",
             " PASV",
+            " EPSV",
             " UTF8",
             " SIZE",
             "211 End",
@@ -196,6 +206,28 @@ class FTPSession:
         else:
             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:
         """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
         if not self.authenticated:
@@ -205,29 +237,18 @@ class FTPSession:
         # Close any existing data connection/server
         await self._close_data_connection()
 
-        # Reset connection state
+        # Reset connection state for the new transfer
         self._data_connected.clear()
         self._data_reader = 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|)
             await self.send(229, f"Entering Extended Passive Mode (|||{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")
 
     async def cmd_PASV(self, arg: str) -> None:
@@ -239,27 +260,24 @@ class FTPSession:
         # Close any existing data connection/server
         await self._close_data_connection()
 
-        # Reset connection state
+        # Reset connection state for the new transfer
         self._data_connected.clear()
         self._data_reader = 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":
+                    ip = "127.0.0.1"
 
             # Format IP and port for PASV response
             ip_parts = ip.split(".")
@@ -270,14 +288,30 @@ class FTPSession:
                 227,
                 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")
 
     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
         ssl_obj = writer.get_extra_info("ssl_object")
         if ssl_obj:
@@ -291,13 +325,26 @@ class FTPSession:
         logger.info("FTP data connection established from %s", self.remote_ip)
         self._data_reader = reader
         self._data_writer = writer
+
+        # Stop accepting further connections on the passive port
+        if self.data_server:
+            self.data_server.close()
+
         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:
         """Close the data connection and server."""
         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:
             try:
                 self._data_writer.close()
@@ -325,7 +372,7 @@ class FTPSession:
             await self.send(530, "Not logged in")
             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")
             return
 
@@ -352,20 +399,28 @@ class FTPSession:
 
         # Receive data
         data_content: list[bytes] = []
+        total_received = 0
         try:
             while True:
                 chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
                 if not chunk:
                     break
                 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:
-            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._close_data_connection()
             return
         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._close_data_connection()
             return
@@ -458,6 +513,9 @@ class FTPSession:
 class VirtualPrinterFTPServer:
     """Implicit FTPS server that accepts uploads from slicers."""
 
+    PASSIVE_PORT_MIN = 50000
+    PASSIVE_PORT_MAX = 50100
+
     def __init__(
         self,
         upload_dir: Path,
@@ -487,6 +545,8 @@ class VirtualPrinterFTPServer:
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
         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:
         """Start the implicit FTPS server."""
@@ -504,6 +564,7 @@ class VirtualPrinterFTPServer:
         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.minimum_version = ssl.TLSVersion.TLSv1_2
+        self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
         # Use standard TLS settings for compatibility
         self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
@@ -521,6 +582,13 @@ class VirtualPrinterFTPServer:
             self._running = True
 
             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:
                 await self._server.serve_forever()
@@ -549,6 +617,8 @@ class VirtualPrinterFTPServer:
             access_code=self.access_code,
             ssl_context=self._ssl_context,
             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

+ 21 - 5
backend/app/services/virtual_printer/manager.py

@@ -296,8 +296,12 @@ class VirtualPrinterManager:
         self._cert_service.serial = proxy_serial
 
         # 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()
-        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)
 
         # Initialize TLS proxy with our certificates
@@ -359,9 +363,11 @@ class VirtualPrinterManager:
         )
 
         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:
@@ -500,6 +506,11 @@ class VirtualPrinterManager:
             # "review" mode (or legacy "queue" mode)
             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:
         """Handle print command from MQTT.
 
@@ -584,7 +595,12 @@ class VirtualPrinterManager:
 
         # Only queue 3MF files
         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
 
         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._sequence_id = 0
 
+        # Dynamic state for status reports
+        self._gcode_state = "IDLE"
+        self._current_file = ""
+        self._prepare_percent = "0"
+
     async def start(self) -> None:
         """Start the MQTT server."""
         if self._running:
@@ -521,10 +526,10 @@ class SimpleMQTTServer:
                     "sequence_id": str(self._sequence_id),
                     "command": "push_status",
                     "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_percent": 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:
             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:
-            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:
         """Handle MQTT PUBLISH packet."""
@@ -776,11 +788,17 @@ class SimpleMQTTServer:
                         print_data = data["print"]
                         command = print_data.get("command", "")
                         filename = print_data.get("subtask_name", "")
+                        sequence_id = print_data.get("sequence_id", "0")
 
                         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:
                     pass  # Non-JSON payloads on request topic are safely ignored

+ 78 - 8
backend/app/services/virtual_printer/ssdp_server.py

@@ -328,8 +328,13 @@ class SSDPProxy:
             pass  # Return partial headers if parsing fails; malformed packets are common
         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:
             text = data.decode("utf-8", errors="ignore")
             original = text
@@ -340,11 +345,25 @@ class SSDPProxy:
                 text,
                 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:
-                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:
-                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")
         except Exception as e:
             logger.error("Failed to rewrite SSDP: %s", e)
@@ -453,13 +472,26 @@ class SSDPProxy:
         self._remote_socket = 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]
 
-        # 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 b"M-SEARCH" in data:
+                await self._respond_to_msearch(data, addr)
             return
 
+        # Below: NOTIFY handling from the real printer
+
         # Check if it's a NOTIFY message
         if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
             return
@@ -478,6 +510,44 @@ class SSDPProxy:
         self._last_printer_ssdp = data
         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:
         """Broadcast cached printer SSDP on remote interface (LAN B)."""
         if not self._remote_socket or not self._last_printer_ssdp:
@@ -485,7 +555,7 @@ class SSDPProxy:
 
         try:
             # 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
             # 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 logging
+import random
+import re
 import ssl
+import subprocess
 from collections.abc import Callable
 from pathlib import Path
 
 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:
     """TLS terminating proxy that forwards data between client and target.
 
@@ -115,6 +160,17 @@ class TLSProxy:
         except OSError as e:
             if e.errno == 98:  # Address already in use
                 logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
+            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:
                 logger.error("%s proxy error: %s", self.name, e)
         except asyncio.CancelledError:
@@ -284,6 +340,503 @@ class TLSProxy:
         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"
+        if local_ip in ("0.0.0.0", "::"):
+            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:
     """Manages FTP and MQTT TLS proxies for a single printer target."""
 
@@ -324,10 +877,24 @@ class SlicerProxyManager:
         """Start FTP and MQTT TLS proxies."""
         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",
-            listen_port=self.LOCAL_FTP_PORT,
+            listen_port=ftp_listen_port,
             target_host=self.target_host,
             target_port=self.PRINTER_FTP_PORT,
             server_cert_path=self.cert_path,

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

@@ -25,9 +25,24 @@ class TestDiscoveryAPI:
         assert "is_docker" in data
         assert "ssdp_running" in data
         assert "scan_running" in data
+        assert "subnets" in data
         assert isinstance(data["is_docker"], bool)
         assert isinstance(data["ssdp_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
@@ -140,3 +155,14 @@ class TestDiscoveryService:
         assert response1.status_code == 200
         assert response2.status_code == 200
         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["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.integration
     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.
 """
 
+import os
+
 import pytest
 from httpx import AsyncClient
 
@@ -393,6 +395,252 @@ class TestSettingsAPI:
         # Default is False as defined in schema
         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:
     """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,
             "remaining_weight": 800,
             "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])
 
@@ -291,7 +291,10 @@ class TestSpoolmanAPI:
         assert isinstance(data["linked"], dict)
         # Tag should be uppercase and stripped of quotes
         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.integration
@@ -337,6 +340,71 @@ class TestSpoolmanAPI:
         data = response.json()
         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
     # =========================================================================

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

+ 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.
 """
 
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, Mock, patch
 
 import pytest
 
@@ -172,3 +172,205 @@ class TestSpoolmanClient:
                 assert call_kwargs["remaining_weight"] == expected, (
                     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

+ 5 - 5
backend/tests/unit/services/test_virtual_printer.py

@@ -507,7 +507,7 @@ class TestSSDPProxy:
         """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"
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
         # Location should be changed to remote interface IP
         assert b"Location: 10.0.0.100" in rewritten
@@ -519,7 +519,7 @@ class TestSSDPProxy:
         """Verify SSDP Location rewrite is case insensitive."""
         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
 
@@ -527,10 +527,10 @@ class TestSSDPProxy:
         """Verify packet without Location header is returned unchanged."""
         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):
         """Verify SSDP message parsing extracts headers."""

+ 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

+ 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
     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
     network_mode: host
     #
@@ -18,6 +23,9 @@ services:
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #  - "${PORT:-8000}:8000"
+    #  - "8883:8883"                  # Virtual printer MQTT
+    #  - "9990:9990"                  # Virtual printer FTP control
+    #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
@@ -26,10 +34,14 @@ services:
       # This ensures the slicer only needs to trust one CA certificate.
       - ./virtual_printer:/app/data/virtual_printer
     environment:
-      - TZ=Europe/Berlin
+      - TZ=${TZ:-Europe/Berlin}
       # Port BamBuddy runs on (default: 8000)
       # Usage: PORT=8080 docker compose up -d
       - 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
 
 volumes:

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

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

@@ -364,6 +364,19 @@ export const handlers = [
     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
   // ========================================================================

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

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

@@ -281,6 +281,28 @@ describe('SystemInfoPage', () => {
     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 () => {
     const criticalDiskUsage = {
       ...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);
+  });
+});

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

@@ -129,6 +129,14 @@ export interface NozzleInfo {
   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)
+}
+
 export interface PrintOptions {
   // Core AI detectors
   spaghetti_detector: boolean;
@@ -186,6 +194,7 @@ export interface PrinterStatus {
   ipcam: boolean;  // Live view enabled
   wifi_signal: number | null;  // WiFi signal strength in dBm
   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
   // Calibration stage tracking
   stg_cur: number;  // Current stage number (-1 = not calibrating)
@@ -751,6 +760,9 @@ export interface AppSettings {
   ha_enabled: boolean;
   ha_url: string;
   ha_token: string;
+  ha_url_from_env: boolean;
+  ha_token_from_env: boolean;
+  ha_env_managed: boolean;
   // File Manager / Library settings
   library_archive_mode: 'always' | 'never' | 'ask';
   library_disk_warning_gb: number;
@@ -1619,8 +1631,14 @@ export interface UnlinkedSpool {
   location: string | null;
 }
 
+export interface LinkedSpoolInfo {
+  id: number;
+  remaining_weight: number | null;
+  filament_weight: number | null;
+}
+
 export interface LinkedSpoolsMap {
-  linked: Record<string, number>; // tag (uppercase) -> spool_id
+  linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
 }
 
 // Update types
@@ -3925,6 +3943,7 @@ export interface DiscoveryInfo {
   is_docker: boolean;
   ssdp_running: boolean;
   scan_running: boolean;
+  subnets: string[];
 }
 
 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 { Button } from './Button';
 import { IconPicker, getIconByName } from './IconPicker';
-import { useTheme } from '../contexts/ThemeContext';
-
 interface AddExternalLinkModalProps {
   link?: ExternalLink | null;
   onClose: () => void;
@@ -14,7 +12,6 @@ interface AddExternalLinkModalProps {
 
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
   const queryClient = useQueryClient();
-  const { mode } = useTheme();
   const isEditing = !!link;
   const fileInputRef = useRef<HTMLInputElement>(null);
 
@@ -166,7 +163,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
           <div className="flex items-center gap-3">
             <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
               {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" />
               )}
@@ -233,7 +230,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
                 />
                 {useCustomIcon && customIconPreview ? (
                   <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
                       type="button"
                       onClick={handleRemoveCustomIcon}

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

@@ -1,7 +1,12 @@
 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 { 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 {
   printerId: number;
@@ -31,6 +36,11 @@ const DEFAULT_STATE: CameraState = {
 };
 
 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
   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 stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
+  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+
   // Fetch printer info
   const { data: printer } = useQuery({
     queryKey: ['printer', printerId],
@@ -94,6 +106,39 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 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)
   useEffect(() => {
     const saveTimeout = setTimeout(() => {
@@ -111,7 +156,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     const sendStopOnce = () => {
       if (printerId > 0 && !stopSentRef.current) {
         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 (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 = '';
     setTimeout(() => setImageKey(Date.now()), 100);
@@ -482,6 +533,28 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           <span className="truncate">{printer?.name || printerName}</span>
         </div>
         <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
             onClick={refresh}
             disabled={streamLoading || isReconnecting}
@@ -625,6 +698,12 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           )}
         </div>
       )}
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={printerId}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
     </div>
   );
 }

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

@@ -10,6 +10,7 @@ interface FilamentData {
   kFactor: string;
   fillLevel: number | null; // null = unknown
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
+  fillSource?: 'ams' | 'spoolman'; // Source of fill level data
 }
 
 interface SpoolmanConfig {
@@ -229,8 +230,11 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     <Droplets className="w-3 h-3" />
                     {t('ams.fill')}
                   </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.fillSource === 'spoolman' && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
+                    )}
                   </span>
                 </div>
                 {/* 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 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.prints += qty;
+      existing.prints += archive.quantity || 1;
       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 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.prints += qty;
+      existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
     });
 
@@ -112,11 +110,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
 
     filteredArchives.forEach(archive => {
       const type = archive.filament_type || 'Unknown';
-      const qty = archive.quantity || 1;
       // Handle multiple types (e.g., "PLA, PETG")
       const types = type.split(', ');
       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);
       });
     });

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

@@ -469,7 +469,7 @@ export function Layout() {
                         <img
                           src={`/api/v1/external-links/${link.id}/icon`}
                           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" />

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

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

@@ -115,7 +115,7 @@ export default {
     deletePrinter: 'Drucker löschen',
     printerName: 'Druckername',
     serialNumber: 'Seriennummer',
-    ipAddress: 'IP-Adresse',
+    ipAddress: 'IP-Adresse / Hostname',
     accessCode: 'Zugangscode',
     model: 'Modell',
     nozzleCount: 'Düsenanzahl',
@@ -207,6 +207,7 @@ export default {
     reconnect: 'Neu verbinden',
     mqttDebug: 'MQTT-Debug',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
+    nozzleRack: 'Düsenhalter',
     // Firmware
     firmwareUpdate: 'Firmware-Update',
     firmwareInstructions: 'Gehen Sie auf dem Touchscreen des Druckers zu',
@@ -1093,6 +1094,10 @@ export default {
     enableRetry: 'Wiederholung aktivieren',
     // Home Assistant
     homeAssistantDescription: 'Smart Plugs über Home Assistant steuern',
+    environmentManagedLabel: '(Umgebungsvariable)',
+    autoEnabledViaEnv: 'Automatisch über Umgebungsvariablen aktiviert',
+    urlFromEnvReadOnly: 'Wert wird über HA_URL Umgebungsvariable gesetzt (schreibgeschützt)',
+    tokenFromEnvReadOnly: 'Wert wird über HA_TOKEN Umgebungsvariable gesetzt (schreibgeschützt)',
     // MQTT
     mqttConnectedTo: 'Verbunden mit',
     // Prometheus
@@ -1506,6 +1511,7 @@ export default {
     recording: 'Aufnahme',
     startRecording: 'Aufnahme starten',
     stopRecording: 'Aufnahme stoppen',
+    chamberLight: 'Kammerbeleuchtung umschalten',
   },
 
   // Groups management
@@ -1803,6 +1809,12 @@ export default {
   support: {
     debugLoggingActive: 'Debug-Protokollierung ist aktiv',
     manageLogs: 'Verwalten',
+    collectItem7: 'Drucker-Verbindungsstatus und Firmware-Versionen',
+    collectItem8: 'Integrationsstatus (Spoolman, MQTT, HA)',
+    collectItem9: 'Netzwerkschnittstellen (nur Subnetze)',
+    collectItem10: 'Python-Paketversionen',
+    collectItem11: 'Datenbankzustandsprüfungen',
+    collectItem12: 'Docker-Umgebungsdetails',
   },
 
   // File manager
@@ -2179,6 +2191,7 @@ export default {
     linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft',
     linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen',
     spoolId: 'Spulen-ID',
+    fillSourceLabel: '(Spoolman)',
     weight: 'Gewicht',
     remaining: 'Verbleibend',
     disableWeightSync: 'AMS-Gewichtsschätzung deaktivieren',

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

@@ -115,7 +115,7 @@ export default {
     deletePrinter: 'Delete Printer',
     printerName: 'Printer Name',
     serialNumber: 'Serial Number',
-    ipAddress: 'IP Address',
+    ipAddress: 'IP Address / Hostname',
     accessCode: 'Access Code',
     model: 'Model',
     nozzleCount: 'Nozzle Count',
@@ -207,6 +207,7 @@ export default {
     reconnect: 'Reconnect',
     mqttDebug: 'MQTT Debug',
     activeNozzle: 'Active: {{nozzle}} nozzle',
+    nozzleRack: 'Nozzle Rack',
     // Firmware
     firmwareUpdate: 'Firmware Update',
     firmwareInstructions: 'On the printer\'s touchscreen, go to',
@@ -1093,6 +1094,10 @@ export default {
     enableRetry: 'Enable retry',
     // Home Assistant
     homeAssistantDescription: 'Control smart plugs via Home Assistant',
+    environmentManagedLabel: '(Environment Managed)',
+    autoEnabledViaEnv: 'Automatically enabled via environment variables',
+    urlFromEnvReadOnly: 'Value set by HA_URL environment variable (read-only)',
+    tokenFromEnvReadOnly: 'Value set by HA_TOKEN environment variable (read-only)',
     // MQTT
     mqttConnectedTo: 'Connected to',
     // Prometheus
@@ -1506,6 +1511,7 @@ export default {
     recording: 'Recording',
     startRecording: 'Start Recording',
     stopRecording: 'Stop Recording',
+    chamberLight: 'Toggle chamber light',
   },
 
   // Groups management
@@ -1803,6 +1809,12 @@ export default {
   support: {
     debugLoggingActive: 'Debug logging is active',
     manageLogs: 'Manage',
+    collectItem7: 'Printer connectivity and firmware versions',
+    collectItem8: 'Integration status (Spoolman, MQTT, HA)',
+    collectItem9: 'Network interfaces (subnets only)',
+    collectItem10: 'Python package versions',
+    collectItem11: 'Database health checks',
+    collectItem12: 'Docker environment details',
   },
 
   // File manager
@@ -2179,6 +2191,7 @@ export default {
     linkSuccess: 'Spool linked to Spoolman successfully',
     linkFailed: 'Failed to link spool',
     spoolId: 'Spool ID',
+    fillSourceLabel: '(Spoolman)',
     weight: 'Weight',
     remaining: 'Remaining',
     disableWeightSync: 'Disable AMS Estimated Weight Sync',

Разница между файлами не показана из-за своего большого размера
+ 572 - 361
frontend/src/i18n/locales/ja.ts


+ 81 - 5
frontend/src/pages/CameraPage.tsx

@@ -1,9 +1,13 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } 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 '../components/icons/ChamberLight';
+import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
@@ -12,10 +16,14 @@ const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
 
 export function CameraPage() {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
 
   const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
+  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [streamError, setStreamError] = useState(false);
   const [streamLoading, setStreamLoading] = useState(true);
   const [imageKey, setImageKey] = useState(Date.now());
@@ -43,6 +51,39 @@ export function CameraPage() {
     enabled: id > 0,
   });
 
+  // Fetch printer status for light toggle and skip objects
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', id],
+    queryFn: () => api.getPrinterStatus(id),
+    refetchInterval: 30000,
+    enabled: id > 0,
+  });
+
+  // Chamber light mutation with optimistic update
+  const chamberLightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(id, on),
+    onMutate: async (on) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', id] });
+      const previousStatus = queryClient.getQueryData(['printerStatus', id]);
+      queryClient.setQueryData(['printerStatus', id], (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', id], 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;
+
   // Update document title
   useEffect(() => {
     if (printer) {
@@ -64,11 +105,14 @@ export function CameraPage() {
     const sendStopOnce = () => {
       if (id > 0 && !stopSentRef.current) {
         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(() => {});
       }
     };
 
-    // Handle page unload/close with sendBeacon (more reliable than fetch on unload)
+    // Handle page unload/close with keepalive fetch (more reliable than sendBeacon, supports auth)
     const handleBeforeUnload = () => {
       sendStopOnce();
     };
@@ -303,7 +347,10 @@ export function CameraPage() {
 
   const stopStream = () => {
     if (id > 0) {
-      fetch(`/api/v1/printers/${id}/camera/stop`).catch(() => {});
+      const headers: Record<string, string> = {};
+      const token = getAuthToken();
+      if (token) headers['Authorization'] = `Bearer ${token}`;
+      fetch(`/api/v1/printers/${id}/camera/stop`, { method: 'POST', headers }).catch(() => {});
     }
   };
 
@@ -577,6 +624,28 @@ export function CameraPage() {
               {t('camera.snapshot')}
             </button>
           </div>
+          <button
+            onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
+            disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
+            className={`p-1.5 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-4 h-4" />
+          </button>
+          <button
+            onClick={() => setShowSkipObjectsModal(true)}
+            disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
+            className={`p-1.5 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-4 h-4 text-bambu-gray" />
+          </button>
           <button
             onClick={refresh}
             disabled={isDisabled}
@@ -700,6 +769,13 @@ export function CameraPage() {
           </div>
         </div>
       </div>
+
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={id}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
     </div>
   );
 }

+ 155 - 268
frontend/src/pages/PrintersPage.tsx

@@ -33,7 +33,6 @@ import {
   Pause,
   Play,
   X,
-  Monitor,
   Fan,
   Wind,
   AirVent,
@@ -45,22 +44,10 @@ import {
   Home,
 } from 'lucide-react';
 
-// Custom Skip Objects icon - arrow jumping over boxes
-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>
-);
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { formatDateOnly } from '../utils/date';
-import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
+import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -75,6 +62,7 @@ import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
+import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -422,6 +410,55 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   );
 }
 
+// H2C Nozzle Rack Card — 2×3 grid showing 6-position tool-changer dock
+function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSlot[] }) {
+  const { t } = useTranslation();
+  // Filter to dock slots only (exclude the mounted/active entry which is typically id=0 or stat indicates mounted)
+  // Show up to 6 dock positions
+  const dockSlots = slots.slice(0, 6);
+
+  return (
+    <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" style={{ minWidth: '80px' }}>
+      <p className="text-[9px] text-bambu-gray mb-0.5">{t('printers.nozzleRack')}</p>
+      <div className="grid grid-cols-3 gap-0.5">
+        {dockSlots.map((slot, i) => {
+          const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
+          const isMounted = slot.stat === 1;
+          // Type abbreviation: S=stainless, H=hardened
+          const typeAbbr = slot.nozzle_type?.includes('hardened') ? 'H' : slot.nozzle_type?.includes('stainless') ? 'S' : '';
+
+          return (
+            <div
+              key={slot.id ?? i}
+              className={`rounded px-0.5 py-0.5 text-center ${
+                isEmpty
+                  ? 'bg-bambu-dark-tertiary/30 opacity-40'
+                  : isMounted
+                    ? 'bg-green-900/40 ring-1 ring-green-500/60'
+                    : 'bg-bambu-dark-tertiary/50'
+              }`}
+              title={isEmpty ? `Slot ${i + 1}: empty` : `Slot ${i + 1}: ${slot.nozzle_diameter}mm ${slot.nozzle_type || ''} ${isMounted ? '(mounted)' : ''}`}
+            >
+              {isEmpty ? (
+                <p className="text-[9px] text-bambu-gray">—</p>
+              ) : (
+                <>
+                  <p className={`text-[10px] font-medium ${isMounted ? 'text-green-400' : 'text-white'}`}>
+                    {slot.nozzle_diameter || '?'}
+                  </p>
+                  {typeAbbr && (
+                    <p className="text-[8px] text-bambu-gray leading-none">{typeAbbr}</p>
+                  )}
+                </>
+              )}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
 // Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)
 function WaterDropEmpty({ className }: { className?: string }) {
   return (
@@ -649,6 +686,17 @@ function getFillBarColor(fillLevel: number): string {
   return '#ef4444'; // Red - critical (< 15%)
 }
 
+// Calculate fill level from Spoolman weight data (used as fallback when AMS reports 0%)
+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
+  ));
+}
+
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
@@ -935,7 +983,7 @@ function PrinterCard({
   };
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
-  linkedSpools?: Record<string, number>;
+  linkedSpools?: Record<string, LinkedSpoolInfo>;
   spoolmanUrl?: string | null;
   timeFormat?: 'system' | '12h' | '24h';
   cameraViewMode?: 'window' | 'embedded';
@@ -1265,23 +1313,13 @@ function PrinterCard({
   // Query for printable objects (for skip functionality)
   // Fetch when printing with 2+ objects OR when modal is open
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
-  const { data: objectsData, refetch: refetchObjects } = useQuery({
+  const { data: objectsData } = useQuery({
     queryKey: ['printableObjects', printer.id],
     queryFn: () => api.getPrintableObjects(printer.id),
     enabled: showSkipObjectsModal || isPrintingWithObjects,
     refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise
   });
 
-  // Skip objects mutation
-  const skipObjectsMutation = useMutation({
-    mutationFn: (objectIds: number[]) => api.skipObjects(printer.id, objectIds),
-    onSuccess: (data) => {
-      showToast(data.message || t('printers.skipObjects.objectsSkipped'));
-      refetchObjects();
-    },
-    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
-  });
-
   // State for tracking which AMS slot is being refreshed
   const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
   // Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
@@ -2020,6 +2058,10 @@ function PrinterCard({
                       <p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
                     </div>
                   )}
+                  {/* H2C nozzle rack (tool-changer dock) */}
+                  {status.nozzle_rack && status.nozzle_rack.length > 0 && (
+                    <NozzleRackCard slots={status.nozzle_rack} />
+                  )}
                 </div>
               );
             })()}
@@ -2218,6 +2260,15 @@ function PrinterCard({
                                 // Get saved slot preset mapping (for user-configured slots)
                                 const slotPreset = slotPresets?.[globalTrayId];
 
+                                // Spoolman fill level fallback (when AMS reports 0%)
+                                const trayTag = tray?.tray_uuid?.toUpperCase();
+                                const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
+                                const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
+                                const effectiveFill = hasFillLevel && tray.remain > 0
+                                  ? tray.remain
+                                  : (spoolmanFill ?? (hasFillLevel ? tray.remain : null));
+                                const fillSource = (hasFillLevel && tray.remain === 0 && spoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
+
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2225,8 +2276,9 @@ function PrinterCard({
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
-                                  fillLevel: hasFillLevel ? tray.remain : null,
+                                  fillLevel: effectiveFill,
                                   trayUuid: tray.tray_uuid || null,
+                                  fillSource,
                                 } : null;
 
                                 // Check if this specific slot is being refreshed
@@ -2251,12 +2303,12 @@ function PrinterCard({
                                     </div>
                                     {/* Fill bar */}
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                                      {hasFillLevel && tray ? (
+                                      {effectiveFill !== null && effectiveFill >= 0 && tray ? (
                                         <div
                                           className="h-full rounded-full transition-all"
                                           style={{
-                                            width: `${tray.remain}%`,
-                                            backgroundColor: getFillBarColor(tray.remain),
+                                            width: `${effectiveFill}%`,
+                                            backgroundColor: getFillBarColor(effectiveFill),
                                           }}
                                         />
                                       ) : tray?.tray_type ? (
@@ -2322,7 +2374,7 @@ function PrinterCard({
                                         spoolman={{
                                           enabled: spoolmanEnabled,
                                           hasUnlinkedSpools,
-                                          linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
+                                          linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
                                           spoolmanUrl,
                                           onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                             setLinkSpoolModal({
@@ -2396,6 +2448,15 @@ function PrinterCard({
                         // Get saved slot preset mapping (for user-configured slots)
                         const slotPreset = slotPresets?.[globalTrayId];
 
+                        // Spoolman fill level fallback (when AMS reports 0%)
+                        const htTrayTag = tray?.tray_uuid?.toUpperCase();
+                        const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
+                        const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
+                        const htEffectiveFill = hasFillLevel && tray.remain > 0
+                          ? tray.remain
+                          : (htSpoolmanFill ?? (hasFillLevel ? tray.remain : null));
+                        const htFillSource = (hasFillLevel && tray.remain === 0 && htSpoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
+
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2403,8 +2464,9 @@ function PrinterCard({
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
-                          fillLevel: hasFillLevel ? tray.remain : null,
+                          fillLevel: htEffectiveFill,
                           trayUuid: tray.tray_uuid || null,
+                          fillSource: htFillSource,
                         } : null;
 
                         const htSlotId = tray?.id ?? 0;
@@ -2430,12 +2492,12 @@ function PrinterCard({
                             </div>
                             {/* Fill bar */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              {hasFillLevel ? (
+                              {htEffectiveFill !== null && htEffectiveFill >= 0 ? (
                                 <div
                                   className="h-full rounded-full transition-all"
                                   style={{
-                                    width: `${tray.remain}%`,
-                                    backgroundColor: getFillBarColor(tray.remain),
+                                    width: `${htEffectiveFill}%`,
+                                    backgroundColor: getFillBarColor(htEffectiveFill),
                                   }}
                                 />
                               ) : tray?.tray_type ? (
@@ -2513,7 +2575,7 @@ function PrinterCard({
                                     spoolman={{
                                       enabled: spoolmanEnabled,
                                       hasUnlinkedSpools,
-                                      linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
+                                      linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
                                       spoolmanUrl,
                                       onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                         setLinkSpoolModal({
@@ -2601,6 +2663,11 @@ function PrinterCard({
                         // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
                         const extSlotPreset = slotPresets?.[255 * 4 + 0];
 
+                        // Spoolman fill level for external spool
+                        const extTrayTag = extTray.tray_uuid?.toUpperCase();
+                        const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
+                        const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
+
                         // Build filament data for hover card
                         const extFilamentData = {
                           vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2608,8 +2675,9 @@ function PrinterCard({
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
-                          fillLevel: null, // External spool has unknown fill level
+                          fillLevel: extSpoolmanFill, // Use Spoolman data if available
                           trayUuid: extTray.tray_uuid || null,
+                          fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
                         };
 
                         const extSlotContent = (
@@ -2624,9 +2692,19 @@ function PrinterCard({
                             <div className="text-[9px] text-white font-bold truncate">
                               {extTray.tray_type || 'Spool'}
                             </div>
-                            {/* Unknown fill level - subtle bar */}
+                            {/* Fill bar - use Spoolman data if available */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                              {extSpoolmanFill !== null ? (
+                                <div
+                                  className="h-full rounded-full transition-all"
+                                  style={{
+                                    width: `${extSpoolmanFill}%`,
+                                    backgroundColor: getFillBarColor(extSpoolmanFill),
+                                  }}
+                                />
+                              ) : (
+                                <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                              )}
                             </div>
                           </div>
                         );
@@ -2643,7 +2721,7 @@ function PrinterCard({
                               spoolman={{
                                 enabled: spoolmanEnabled,
                                 hasUnlinkedSpools,
-                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()] : undefined,
+                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
                                 spoolmanUrl,
                                 onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
                                   setLinkSpoolModal({
@@ -3250,221 +3328,12 @@ function PrinterCard({
         />
       )}
 
-      {/* Skip Objects Popup */}
-      {showSkipObjectsModal && (
-        <div
-          className="fixed inset-0 z-50 flex items-center justify-center"
-          onClick={() => setShowSkipObjectsModal(false)}
-          onKeyDown={(e) => e.key === 'Escape' && setShowSkipObjectsModal(false)}
-          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={() => setShowSkipObjectsModal(false)}
-              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>
-      )}
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={printer.id}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
 
       {/* HMS Error Modal */}
       {showHMSModal && (
@@ -3569,13 +3438,18 @@ function AddPrinterModal({
   const [discoveryError, setDiscoveryError] = useState('');
   const [hasScanned, setHasScanned] = useState(false);
   const [isDocker, setIsDocker] = useState(false);
-  const [subnet, setSubnet] = useState('192.168.1.0/24');
+  const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
+  const [subnet, setSubnet] = useState('');
   const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
 
   // Fetch discovery info on mount
   useEffect(() => {
     discoveryApi.getInfo().then(info => {
       setIsDocker(info.is_docker);
+      if (info.subnets.length > 0) {
+        setDetectedSubnets(info.subnets);
+        setSubnet(info.subnets[0]);
+      }
     }).catch(() => {
       // Ignore errors, assume not Docker
     });
@@ -3740,14 +3614,27 @@ function AddPrinterModal({
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('printers.discovery.subnetToScan')}
                 </label>
-                <input
-                  type="text"
-                  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 text-sm"
-                  value={subnet}
-                  onChange={(e) => setSubnet(e.target.value)}
-                  placeholder="192.168.1.0/24"
-                  disabled={discovering}
-                />
+                {detectedSubnets.length > 0 ? (
+                  <select
+                    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 text-sm"
+                    value={subnet}
+                    onChange={(e) => setSubnet(e.target.value)}
+                    disabled={discovering}
+                  >
+                    {detectedSubnets.map(s => (
+                      <option key={s} value={s}>{s}</option>
+                    ))}
+                  </select>
+                ) : (
+                  <input
+                    type="text"
+                    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 text-sm"
+                    value={subnet}
+                    onChange={(e) => setSubnet(e.target.value)}
+                    placeholder="192.168.1.0/24"
+                    disabled={discovering}
+                  />
+                )}
                 <p className="mt-1 text-xs text-bambu-gray">
                   {t('printers.discovery.dockerNote')}
                 </p>
@@ -3846,11 +3733,11 @@ function AddPrinterModal({
               <input
                 type="text"
                 required
-                pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
+                pattern="(\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])?)*)"
                 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"
                 value={form.ip_address}
                 onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
-                placeholder="192.168.1.100"
+                placeholder="192.168.1.100 or printer.local"
               />
             </div>
             <div>
@@ -4223,11 +4110,11 @@ function EditPrinterModal({
               <input
                 type="text"
                 required
-                pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
+                pattern="(\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])?)*)"
                 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"
                 value={form.ip_address}
                 onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
-                placeholder="192.168.1.100"
+                placeholder="192.168.1.100 or printer.local"
               />
             </div>
             <div>

+ 67 - 19
frontend/src/pages/SettingsPage.tsx

@@ -1974,18 +1974,29 @@ export function SettingsPage() {
               </p>
 
               <div className="flex items-center justify-between">
-                <div>
+                <div className="flex-1">
                   <p className="text-white">{t('settings.enableHomeAssistant')}</p>
                   <p className="text-xs text-bambu-gray">{t('settings.homeAssistantDescription')}</p>
+                  {localSettings.ha_env_managed && (
+                    <div className="flex items-center gap-1 mt-1">
+                      <Lock className="w-3 h-3 text-bambu-green" />
+                      <span className="text-xs text-bambu-green">
+                        {t('settings.autoEnabledViaEnv')}
+                      </span>
+                    </div>
+                  )}
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                     type="checkbox"
                     checked={localSettings.ha_enabled ?? false}
                     onChange={(e) => updateSetting('ha_enabled', e.target.checked)}
+                    disabled={localSettings.ha_env_managed}
                     className="sr-only peer"
                   />
-                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                  <div className={`w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green ${
+                    localSettings.ha_env_managed ? 'opacity-60 cursor-not-allowed' : ''
+                  }`}></div>
                 </label>
               </div>
 
@@ -1994,30 +2005,67 @@ export function SettingsPage() {
                   <div>
                     <label className="block text-sm text-bambu-gray mb-1">
                       Home Assistant URL
+                      {localSettings.ha_url_from_env && (
+                        <span className="ml-2 text-xs text-bambu-green">
+                          {t('settings.environmentManagedLabel')}
+                        </span>
+                      )}
                     </label>
-                    <input
-                      type="text"
-                      value={localSettings.ha_url ?? ''}
-                      onChange={(e) => updateSetting('ha_url', e.target.value)}
-                      placeholder="http://192.168.1.100:8123"
-                      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 className="relative">
+                      <input
+                        type="text"
+                        value={localSettings.ha_url ?? ''}
+                        onChange={(e) => updateSetting('ha_url', e.target.value)}
+                        placeholder="http://192.168.1.100:8123"
+                        disabled={localSettings.ha_url_from_env}
+                        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 ${
+                          localSettings.ha_url_from_env ? 'opacity-60 cursor-not-allowed' : ''
+                        }`}
+                      />
+                      {localSettings.ha_url_from_env && (
+                        <Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
+                      )}
+                    </div>
+                    {localSettings.ha_url_from_env && (
+                      <p className="text-xs text-bambu-gray mt-1">
+                        {t('settings.urlFromEnvReadOnly')}
+                      </p>
+                    )}
                   </div>
 
                   <div>
                     <label className="block text-sm text-bambu-gray mb-1">
                       Long-Lived Access Token
+                      {localSettings.ha_token_from_env && (
+                        <span className="ml-2 text-xs text-bambu-green">
+                          {t('settings.environmentManagedLabel')}
+                        </span>
+                      )}
                     </label>
-                    <input
-                      type="password"
-                      value={localSettings.ha_token ?? ''}
-                      onChange={(e) => updateSetting('ha_token', e.target.value)}
-                      placeholder="eyJ0eXAiOiJKV1QiLC..."
-                      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"
-                    />
-                    <p className="text-xs text-bambu-gray mt-1">
-                      Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
-                    </p>
+                    <div className="relative">
+                      <input
+                        type="password"
+                        value={localSettings.ha_token ?? ''}
+                        onChange={(e) => updateSetting('ha_token', e.target.value)}
+                        placeholder="eyJ0eXAiOiJKV1QiLC..."
+                        disabled={localSettings.ha_token_from_env}
+                        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 ${
+                          localSettings.ha_token_from_env ? 'opacity-60 cursor-not-allowed' : ''
+                        }`}
+                      />
+                      {localSettings.ha_token_from_env && (
+                        <Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
+                      )}
+                    </div>
+                    {localSettings.ha_token_from_env ? (
+                      <p className="text-xs text-bambu-gray mt-1">
+                        {t('settings.tokenFromEnvReadOnly')}
+                      </p>
+                    ) : (
+                      <p className="text-xs text-bambu-gray mt-1">
+                        Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
+                      </p>
+                    )}
                   </div>
 
                   {localSettings.ha_url && localSettings.ha_token && (

+ 6 - 0
frontend/src/pages/SystemInfoPage.tsx

@@ -324,6 +324,12 @@ export function SystemInfoPage() {
                   <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
                   <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
                   <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
+                  <li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
+                  <li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
+                  <li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
+                  <li>• {t('support.collectItem10', 'Python package versions')}</li>
+                  <li>• {t('support.collectItem11', 'Database health checks')}</li>
+                  <li>• {t('support.collectItem12', 'Docker environment details')}</li>
                 </ul>
               </div>
               <div>

+ 3 - 0
requirements-dev.txt

@@ -6,6 +6,9 @@ pytest-xdist>=3.5.0
 httpx>=0.27.0
 ruff>=0.8.0
 
+# Required by pyftpdlib TLS_FTPHandler for mock FTP server tests
+pyOpenSSL>=24.0.0
+
 # Security scanning
 bandit[sarif]>=1.7.0
 pip-audit>=2.7.0

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BTJM8cN7.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CBPKqOAD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-togsBDt6.css


+ 2 - 2
static/index.html

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

+ 1 - 1
test_all.sh

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

+ 6 - 1
test_backend.sh

@@ -2,5 +2,10 @@
 
 cd backend
 ruff check && ruff format --check
-../venv/bin/python3 -m pytest tests/ -v -n 14
+
+if [ "$1" = "--full" ]; then
+  ../venv/bin/python3 -m pytest tests/ -v -n 14
+else
+  ../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py
+fi
 cd ..

+ 2 - 2
update_website_wiki.sh

@@ -10,9 +10,9 @@ git add .
 git commit -m "Updated Wiki"
 git push
 
-cd ../bambuddy-languages
+cd ../bambuddy-telemetry/
 git add .
-git commit -m "Updated Bambuddy Languages"
+git commit -m "Updated Stats"
 git push
 
 cd ../spoolbuddy-website

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