ソースを参照

Merge branch '0.1.9b' into feature/home-assistant-env-vars

MartinNYHC 3 ヶ月 前
コミット
2cff40f2f1

+ 28 - 0
CHANGELOG.md

@@ -2,6 +2,34 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.9b] - Not released
+
+### Documentation
+- **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.
+
+### Added
+- **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.
+
+### Fixed
+- **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.
+- **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.
+
+### 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 \

+ 3 - 2
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
@@ -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

+ 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":

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

+ 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

+ 10 - 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

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

+ 60 - 35
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
@@ -159,6 +164,7 @@ class FTPSession:
         features = [
             "211-Features:",
             " PASV",
+            " EPSV",
             " UTF8",
             " SIZE",
             "211 End",
@@ -196,6 +202,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:
@@ -210,24 +238,12 @@ class FTPSession:
         self._data_reader = None
         self._data_writer = None
 
-        # 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:
@@ -244,22 +260,18 @@ class FTPSession:
         self._data_reader = None
         self._data_writer = None
 
-        # 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,10 +282,9 @@ 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:
@@ -458,6 +469,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 +501,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."""
@@ -521,6 +537,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 +572,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:

+ 81 - 68
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,74 @@ 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
-
-            # Message payload
-            packet += message_bytes
+        except OSError as e:
+            logger.error("Failed to send version response: %s", e)
 
-            writer.write(packet)
-            await writer.drain()
+    def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None:
+        """Update the gcode state reported to connected slicers.
 
-            logger.info("Sent version response on %s", topic)
+        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)
+        await writer.drain()
+
+    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 +783,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,

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

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

+ 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."""

+ 12 - 0
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
@@ -30,6 +38,10 @@ services:
       # 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:

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

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

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

+ 2 - 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',
@@ -1510,6 +1510,7 @@ export default {
     recording: 'Aufnahme',
     startRecording: 'Aufnahme starten',
     stopRecording: 'Aufnahme stoppen',
+    chamberLight: 'Kammerbeleuchtung umschalten',
   },
 
   // Groups management

+ 2 - 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',
@@ -1510,6 +1510,7 @@ export default {
     recording: 'Recording',
     startRecording: 'Start Recording',
     stopRecording: 'Stop Recording',
+    chamberLight: 'Toggle chamber light',
   },
 
   // Groups management

+ 3 - 2
frontend/src/i18n/locales/ja.ts

@@ -95,7 +95,7 @@ export default {
     deletePrinter: 'プリンターを削除',
     printerName: 'プリンター名',
     serialNumber: 'シリアル番号',
-    ipAddress: 'IPアドレス',
+    ipAddress: 'IPアドレス / ホスト名',
     accessCode: 'アクセスコード',
     model: 'モデル',
     nozzleCount: 'ノズル数',
@@ -240,7 +240,7 @@ export default {
     form: {
       name: '名前',
       namePlaceholder: 'マイプリンター',
-      ipAddress: 'IPアドレス',
+      ipAddress: 'IPアドレス / ホスト名',
       serialNumber: 'シリアル番号',
       serialCannotChange: 'シリアル番号は変更できません',
       accessCode: 'アクセスコード',
@@ -2642,6 +2642,7 @@ export default {
     skipForward: '5秒進む',
     refreshStream: 'ストリームを更新',
     dragToResize: 'ドラッグしてリサイズ',
+    chamberLight: 'チャンバーライト切替',
   },
 
   // アーカイブカードラベル

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

+ 12 - 243
frontend/src/pages/PrintersPage.tsx

@@ -33,7 +33,6 @@ import {
   Pause,
   Play,
   X,
-  Monitor,
   Fan,
   Wind,
   AirVent,
@@ -45,18 +44,6 @@ 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';
@@ -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
@@ -1265,23 +1253,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)
@@ -3250,221 +3228,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 && (
@@ -3846,11 +3615,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 +3992,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>

+ 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-ueO9Fu53.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- 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>
+    <script type="module" crossorigin src="/assets/index-ueO9Fu53.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
   </head>
   <body>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません