Browse Source

Virtual Printer: TLS proxy with real printer serial

  Proxy mode changes:
  - Replace transparent TCP proxy with TLS-terminating proxy
  - Slicer connects to Bambuddy cert, Bambuddy connects to printer
  - Use real printer's serial number for SSDP and certificate
  - This ensures MQTT topic subscriptions match the real printer

  The proxy now:
  1. Accepts TLS from slicer using Bambuddy's certificate
  2. Opens TLS connection to real printer
  3. Forwards decrypted data bidirectionally

  Also: Complete i18n localization for VirtualPrinterSettings component
maziggy 3 months ago
parent
commit
38d99143c7

+ 2 - 2
.gitignore

@@ -35,8 +35,8 @@ archive/
 # Firmware cache (downloaded firmware files)
 firmware/
 
-# Virtual printer (auto-generated certs and uploads)
-virtual_printer/
+# Virtual printer (auto-generated certs and uploads at repo root)
+/virtual_printer/
 
 # IDE
 .idea/

+ 61 - 15
backend/app/api/routes/settings.py

@@ -374,12 +374,14 @@ async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
     access_code = await get_setting(db, "virtual_printer_access_code")
     mode = await get_setting(db, "virtual_printer_mode")
     model = await get_setting(db, "virtual_printer_model")
+    target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
 
     return {
         "enabled": enabled == "true" if enabled else False,
         "access_code_set": bool(access_code),
         "mode": mode or "immediate",
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+        "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -390,9 +392,13 @@ async def update_virtual_printer_settings(
     access_code: str = None,
     mode: str = None,
     model: str = None,
+    target_printer_id: int = None,
     db: AsyncSession = Depends(get_db),
 ):
     """Update virtual printer settings and restart services if needed."""
+    from sqlalchemy import select
+
+    from backend.app.models.printer import Printer
     from backend.app.services.virtual_printer import (
         DEFAULT_VIRTUAL_PRINTER_MODEL,
         VIRTUAL_PRINTER_MODELS,
@@ -404,20 +410,24 @@ async def update_virtual_printer_settings(
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
     current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
+    current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
+    current_target_id = int(current_target_id_str) if current_target_id_str else None
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
     new_access_code = access_code if access_code is not None else current_access_code
     new_mode = mode if mode is not None else current_mode
     new_model = model if model is not None else current_model
+    new_target_id = target_printer_id if target_printer_id is not None else current_target_id
 
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
     # "print_queue" archives and adds to print queue (unassigned)
-    if new_mode not in ("immediate", "queue", "review", "print_queue"):
+    # "proxy" is transparent TCP proxy to a real printer
+    if new_mode not in ("immediate", "queue", "review", "print_queue", "proxy"):
         return JSONResponse(
             status_code=400,
-            content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
+            content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
         )
     # Normalize legacy "queue" to "review" for storage
     if new_mode == "queue":
@@ -430,19 +440,51 @@ async def update_virtual_printer_settings(
             content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
         )
 
-    # Validate access code when enabling
-    if new_enabled and not new_access_code:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Access code is required when enabling virtual printer"},
-        )
-
-    # Validate access code length (Bambu Studio requires exactly 8 characters)
-    if access_code is not None and len(access_code) != 8:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Access code must be exactly 8 characters"},
-        )
+    # Mode-specific validation and printer lookup
+    target_printer_ip = ""
+    target_printer_serial = ""
+    if new_mode == "proxy":
+        # Proxy mode requires target printer when enabling
+        if new_enabled and not new_target_id:
+            # If just switching to proxy mode (not explicitly enabling), auto-disable
+            if enabled is None:
+                new_enabled = False
+            else:
+                return JSONResponse(
+                    status_code=400,
+                    content={"detail": "Target printer is required for proxy mode"},
+                )
+
+        # Look up printer IP and serial if we have a target
+        if new_target_id:
+            result = await db.execute(select(Printer).where(Printer.id == new_target_id))
+            printer = result.scalar_one_or_none()
+            if not printer:
+                return JSONResponse(
+                    status_code=400,
+                    content={"detail": f"Printer with ID {new_target_id} not found"},
+                )
+            target_printer_ip = printer.ip_address
+            target_printer_serial = printer.serial_number
+        # Access code not required for proxy mode
+    else:
+        # Non-proxy modes require access code when enabling
+        if new_enabled and not new_access_code:
+            # If just switching modes (not explicitly enabling), auto-disable
+            if enabled is None:
+                new_enabled = False
+            else:
+                return JSONResponse(
+                    status_code=400,
+                    content={"detail": "Access code is required when enabling virtual printer"},
+                )
+
+        # Validate access code length (Bambu Studio requires exactly 8 characters)
+        if access_code is not None and access_code and len(access_code) != 8:
+            return JSONResponse(
+                status_code=400,
+                content={"detail": "Access code must be exactly 8 characters"},
+            )
 
     # Save settings
     await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
@@ -451,6 +493,8 @@ async def update_virtual_printer_settings(
     await set_setting(db, "virtual_printer_mode", new_mode)
     if model is not None:
         await set_setting(db, "virtual_printer_model", model)
+    if target_printer_id is not None:
+        await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
     await db.commit()
     db.expire_all()
 
@@ -461,6 +505,8 @@ async def update_virtual_printer_settings(
             access_code=new_access_code,
             mode=new_mode,
             model=new_model,
+            target_printer_ip=target_printer_ip,
+            target_printer_serial=target_printer_serial,
         )
     except ValueError as e:
         return JSONResponse(

+ 23 - 2
backend/app/main.py

@@ -2500,16 +2500,37 @@ async def lifespan(app: FastAPI):
             vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_model = await get_setting(db, "virtual_printer_model") or ""
+            vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
 
-            if vp_access_code:
+            # Look up printer IP and serial if in proxy mode
+            vp_target_ip = ""
+            vp_target_serial = ""
+            if vp_mode == "proxy" and vp_target_printer_id:
+                from backend.app.models.printer import Printer
+
+                result = await db.execute(select(Printer).where(Printer.id == int(vp_target_printer_id)))
+                printer = result.scalar_one_or_none()
+                if printer:
+                    vp_target_ip = printer.ip_address
+                    vp_target_serial = printer.serial_number
+
+            # Proxy mode requires target IP, other modes require access code
+            can_start = (vp_mode == "proxy" and vp_target_ip) or (vp_mode != "proxy" and vp_access_code)
+
+            if can_start:
                 try:
                     await virtual_printer_manager.configure(
                         enabled=True,
                         access_code=vp_access_code,
                         mode=vp_mode,
                         model=vp_model,
+                        target_printer_ip=vp_target_ip,
+                        target_printer_serial=vp_target_serial,
                     )
-                    logging.info(f"Virtual printer started (model={vp_model or 'default'})")
+                    if vp_mode == "proxy":
+                        logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
+                    else:
+                        logging.info(f"Virtual printer started (model={vp_model or 'default'})")
                 except Exception as e:
                     logging.warning(f"Failed to start virtual printer: {e}")
 

+ 125 - 15
backend/app/services/virtual_printer/manager.py

@@ -1,4 +1,11 @@
-"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services."""
+"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
+
+Supports multiple modes:
+- immediate: Archive uploads immediately
+- review: Queue uploads for user review before archiving
+- print_queue: Archive and add to print queue (unassigned)
+- proxy: Transparent TCP proxy to a real printer (for remote slicer access)
+"""
 
 import asyncio
 import logging
@@ -11,6 +18,7 @@ from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 logger = logging.getLogger(__name__)
 
@@ -83,11 +91,14 @@ class VirtualPrinterManager:
         self._access_code = ""
         self._mode = "immediate"
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
+        self._target_printer_ip = ""  # For proxy mode
+        self._target_printer_serial = ""  # For proxy mode (real printer's serial)
 
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
+        self._proxy: SlicerProxyManager | None = None  # For proxy mode
 
         # Background tasks
         self._tasks: list[asyncio.Task] = []
@@ -144,31 +155,49 @@ class VirtualPrinterManager:
         access_code: str = "",
         mode: str = "immediate",
         model: str = "",
+        target_printer_ip: str = "",
+        target_printer_serial: str = "",
     ) -> None:
         """Configure and start/stop virtual printer.
 
         Args:
             enabled: Whether to enable the virtual printer
             access_code: Authentication password for slicer connections
-            mode: Archive mode - 'immediate' or 'queue'
+            mode: Archive mode - 'immediate', 'review', 'print_queue', or 'proxy'
             model: SSDP model code (e.g., 'BL-P001' for X1C)
+            target_printer_ip: Target printer IP for proxy mode
+            target_printer_serial: Target printer serial for proxy mode
         """
-        if enabled and not access_code:
-            raise ValueError("Access code is required when enabling virtual printer")
+        # Proxy mode has different requirements
+        if mode == "proxy":
+            if enabled and not target_printer_ip:
+                raise ValueError("Target printer IP is required for proxy mode")
+            # Access code not required for proxy mode (uses printer's credentials)
+        else:
+            if enabled and not access_code:
+                raise ValueError("Access code is required when enabling virtual printer")
 
         # Validate model if provided
         new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
         model_changed = new_model != self._model
-        old_model = self._model
+        mode_changed = mode != self._mode
+        target_changed = target_printer_ip != self._target_printer_ip
+        serial_changed = target_printer_serial != self._target_printer_serial
+        old_mode = self._mode
 
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
-            f"model={model}, new_model={new_model}, old_model={old_model}, model_changed={model_changed}"
+            f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
+            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
         )
 
         self._access_code = access_code
         self._mode = mode
         self._model = new_model
+        self._target_printer_ip = target_printer_ip
+        self._target_printer_serial = target_printer_serial
+
+        needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
 
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
@@ -176,25 +205,89 @@ class VirtualPrinterManager:
         elif not enabled and self._enabled:
             logger.info("Stopping virtual printer (was enabled)")
             await self._stop()
-        elif enabled and self._enabled and model_changed:
-            # Model changed while running - restart services
-            logger.info(f"Model changed from {old_model} to {new_model}, restarting...")
+        elif enabled and self._enabled and needs_restart:
+            # Configuration changed while running - restart services
+            logger.info(f"Configuration changed (mode={old_mode}→{mode}), restarting...")
             await self._stop()
             # Give time for ports to be released
             await asyncio.sleep(0.5)
             await self._start()
-            logger.info("Virtual printer restarted with new model")
+            logger.info("Virtual printer restarted with new configuration")
         else:
-            logger.debug(
-                f"No state change needed (enabled={enabled}, self._enabled={self._enabled}, model_changed={model_changed})"
-            )
+            logger.debug(f"No state change needed (enabled={enabled}, self._enabled={self._enabled})")
 
         self._enabled = enabled
 
     async def _start(self) -> None:
         """Start all virtual printer services."""
-        logger.info("Starting virtual printer services...")
+        logger.info(f"Starting virtual printer services (mode={self._mode})...")
+
+        # Proxy mode uses different services
+        if self._mode == "proxy":
+            await self._start_proxy_mode()
+            return
+
+        # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
+        await self._start_server_mode()
+
+    async def _start_proxy_mode(self) -> None:
+        """Start virtual printer in proxy mode (TLS terminating relay)."""
+        logger.info(f"Starting proxy mode to {self._target_printer_ip}")
+
+        # In proxy mode, use the REAL printer's serial number
+        # This ensures MQTT topic subscriptions match the real printer's topics
+        proxy_serial = self._target_printer_serial or self.printer_serial
+        logger.info(f"Proxy mode using serial: {proxy_serial}")
+
+        # Update certificate service with the real printer's serial
+        self._cert_service.serial = proxy_serial
+
+        # Regenerate printer cert if needed (CA is preserved)
+        self._cert_service.delete_printer_certificate()
+        cert_path, key_path = self._cert_service.generate_certificates()
+        logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
+
+        # Initialize SSDP for local discovery using the real printer's serial
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.PRINTER_NAME} (Proxy)",
+            serial=proxy_serial,
+            model=self._model,
+        )
+
+        # Initialize TLS proxy with our certificates
+        self._proxy = SlicerProxyManager(
+            target_host=self._target_printer_ip,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_activity=self._on_proxy_activity,
+        )
+
+        # Start services as background tasks
+        async def run_with_logging(coro, name):
+            try:
+                await coro
+            except Exception as e:
+                logger.error(f"Virtual printer {name} failed: {e}")
 
+        self._tasks = [
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name="virtual_printer_ssdp",
+            ),
+            asyncio.create_task(
+                run_with_logging(self._proxy.start(), "Proxy"),
+                name="virtual_printer_proxy",
+            ),
+        ]
+
+        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}"
+        )
+
+    async def _start_server_mode(self) -> None:
+        """Start virtual printer in server mode (FTP/MQTT servers)."""
         # Update certificate service with current serial (based on model)
         current_serial = self.printer_serial
         self._cert_service.serial = current_serial
@@ -247,6 +340,10 @@ class VirtualPrinterManager:
 
         logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.printer_serial})")
 
+    def _on_proxy_activity(self, name: str, message: str) -> None:
+        """Handle proxy activity for logging."""
+        logger.info(f"Proxy {name}: {message}")
+
     async def _stop(self) -> None:
         """Stop all virtual printer services."""
         logger.info("Stopping virtual printer services...")
@@ -264,6 +361,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             self._ssdp = None
 
+        if self._proxy:
+            await self._proxy.stop()
+            self._proxy = None
+
         # Cancel remaining tasks with short timeout
         for task in self._tasks:
             task.cancel()
@@ -487,7 +588,7 @@ class VirtualPrinterManager:
         Returns:
             Status dictionary with enabled, running, mode, etc.
         """
-        return {
+        status = {
             "enabled": self._enabled,
             "running": self.is_running,
             "mode": self._mode,
@@ -498,6 +599,15 @@ class VirtualPrinterManager:
             "pending_files": len(self._pending_files),
         }
 
+        # Add proxy-specific status
+        if self._mode == "proxy":
+            status["target_printer_ip"] = self._target_printer_ip
+            if self._proxy:
+                proxy_status = self._proxy.get_status()
+                status["proxy"] = proxy_status
+
+        return status
+
 
 # Global instance
 virtual_printer_manager = VirtualPrinterManager()

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

@@ -0,0 +1,418 @@
+"""TLS proxy for slicer-to-printer communication.
+
+This module provides a TLS terminating proxy that forwards data between
+a slicer and a real Bambu printer, enabling remote printing over
+any network connection.
+
+Unlike a transparent TCP proxy, this terminates TLS on both ends:
+- Slicer connects to Bambuddy using Bambuddy's certificate
+- Bambuddy connects to printer using printer's certificate
+- Data is decrypted, forwarded, and re-encrypted
+"""
+
+import asyncio
+import logging
+import ssl
+from collections.abc import Callable
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+class TLSProxy:
+    """TLS terminating proxy that forwards data between client and target.
+
+    This proxy terminates TLS on both ends, allowing the slicer to connect
+    to Bambuddy's certificate while Bambuddy connects to the real printer.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        listen_port: int,
+        target_host: str,
+        target_port: int,
+        server_cert_path: Path,
+        server_key_path: Path,
+        on_connect: Callable[[str], None] | None = None,
+        on_disconnect: Callable[[str], None] | None = None,
+    ):
+        """Initialize the TLS proxy.
+
+        Args:
+            name: Friendly name for logging (e.g., "FTP", "MQTT")
+            listen_port: Port to listen on for incoming connections
+            target_host: Target printer IP/hostname
+            target_port: Target printer port
+            server_cert_path: Path to server certificate (for accepting slicer connections)
+            server_key_path: Path to server private key
+            on_connect: Optional callback when client connects (receives client_id)
+            on_disconnect: Optional callback when client disconnects (receives client_id)
+        """
+        self.name = name
+        self.listen_port = listen_port
+        self.target_host = target_host
+        self.target_port = target_port
+        self.server_cert_path = server_cert_path
+        self.server_key_path = server_key_path
+        self.on_connect = on_connect
+        self.on_disconnect = on_disconnect
+
+        self._server: asyncio.Server | None = None
+        self._running = False
+        self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
+        self._server_ssl_context: ssl.SSLContext | None = None
+        self._client_ssl_context: ssl.SSLContext | None = None
+
+    def _create_server_ssl_context(self) -> ssl.SSLContext:
+        """Create SSL context for accepting client (slicer) connections."""
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain(self.server_cert_path, self.server_key_path)
+        # Allow older TLS versions for compatibility with slicers
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Don't require client certificates
+        ctx.verify_mode = ssl.CERT_NONE
+        return ctx
+
+    def _create_client_ssl_context(self) -> ssl.SSLContext:
+        """Create SSL context for connecting to printer."""
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+        # Don't verify printer's certificate (self-signed)
+        ctx.check_hostname = False
+        ctx.verify_mode = ssl.CERT_NONE
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        return ctx
+
+    async def start(self) -> None:
+        """Start the TLS proxy server."""
+        if self._running:
+            return
+
+        logger.info(
+            f"Starting {self.name} TLS proxy: 0.0.0.0:{self.listen_port} → {self.target_host}:{self.target_port}"
+        )
+
+        try:
+            self._running = True
+
+            # Create SSL contexts
+            self._server_ssl_context = self._create_server_ssl_context()
+            self._client_ssl_context = self._create_client_ssl_context()
+
+            # Start server with TLS
+            self._server = await asyncio.start_server(
+                self._handle_client,
+                "0.0.0.0",
+                self.listen_port,
+                ssl=self._server_ssl_context,
+            )
+
+            logger.info(f"{self.name} TLS proxy listening on port {self.listen_port}")
+
+            async with self._server:
+                await self._server.serve_forever()
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.error(f"{self.name} proxy port {self.listen_port} is already in use")
+            else:
+                logger.error(f"{self.name} proxy error: {e}")
+        except asyncio.CancelledError:
+            logger.debug(f"{self.name} proxy task cancelled")
+        except Exception as e:
+            logger.error(f"{self.name} proxy error: {e}")
+        finally:
+            await self.stop()
+
+    async def stop(self) -> None:
+        """Stop the TLS proxy server."""
+        logger.info(f"Stopping {self.name} proxy")
+        self._running = False
+
+        # Cancel all active connection tasks
+        for client_id, (task1, task2) in list(self._active_connections.items()):
+            task1.cancel()
+            task2.cancel()
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass
+
+        self._active_connections.clear()
+
+        if self._server:
+            try:
+                self._server.close()
+                await self._server.wait_closed()
+            except Exception as e:
+                logger.debug(f"Error closing {self.name} proxy server: {e}")
+            self._server = None
+
+    async def _handle_client(
+        self,
+        client_reader: asyncio.StreamReader,
+        client_writer: asyncio.StreamWriter,
+    ) -> None:
+        """Handle a new client connection by proxying to target."""
+        peername = client_writer.get_extra_info("peername")
+        client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+
+        logger.info(f"{self.name} proxy: client connected from {client_id}")
+
+        if self.on_connect:
+            try:
+                self.on_connect(client_id)
+            except Exception:
+                pass
+
+        # 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(f"{self.name} proxy: connected to printer {self.target_host}:{self.target_port}")
+        except TimeoutError:
+            logger.error(f"{self.name} proxy: timeout connecting to {self.target_host}:{self.target_port}")
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except ssl.SSLError as e:
+            logger.error(f"{self.name} proxy: SSL error connecting to {self.target_host}:{self.target_port}: {e}")
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except Exception as e:
+            logger.error(f"{self.name} proxy: failed to connect to {self.target_host}:{self.target_port}: {e}")
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+
+        # Create bidirectional forwarding tasks
+        client_to_printer = asyncio.create_task(
+            self._forward(client_reader, printer_writer, f"{client_id}→printer"),
+            name=f"{self.name}_c2p_{client_id}",
+        )
+        printer_to_client = asyncio.create_task(
+            self._forward(printer_reader, client_writer, f"printer→{client_id}"),
+            name=f"{self.name}_p2c_{client_id}",
+        )
+
+        self._active_connections[client_id] = (client_to_printer, printer_to_client)
+
+        try:
+            # Wait for either direction to complete (connection closed)
+            done, pending = await asyncio.wait(
+                [client_to_printer, printer_to_client],
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+
+            # Cancel the other direction
+            for task in pending:
+                task.cancel()
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
+
+        except Exception as e:
+            logger.debug(f"{self.name} proxy connection error: {e}")
+        finally:
+            # Clean up
+            self._active_connections.pop(client_id, None)
+
+            for writer in [client_writer, printer_writer]:
+                try:
+                    writer.close()
+                    await writer.wait_closed()
+                except Exception:
+                    pass
+
+            logger.info(f"{self.name} proxy: client {client_id} disconnected")
+
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass
+
+    async def _forward(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        direction: str,
+    ) -> None:
+        """Forward data from reader to writer.
+
+        Args:
+            reader: Source stream (already TLS-decrypted)
+            writer: Destination stream (will be TLS-encrypted by the stream)
+            direction: Description for logging (e.g., "client→printer")
+        """
+        total_bytes = 0
+        try:
+            while self._running:
+                # Read chunk - use reasonable buffer size
+                data = await reader.read(65536)
+                if not data:
+                    # Connection closed
+                    break
+
+                # Forward to destination
+                writer.write(data)
+                await writer.drain()
+
+                total_bytes += len(data)
+                logger.debug(f"{self.name} proxy {direction}: {len(data)} bytes")
+
+        except asyncio.CancelledError:
+            pass
+        except ConnectionResetError:
+            logger.debug(f"{self.name} proxy {direction}: connection reset")
+        except BrokenPipeError:
+            logger.debug(f"{self.name} proxy {direction}: broken pipe")
+        except Exception as e:
+            logger.debug(f"{self.name} proxy {direction} error: {e}")
+
+        logger.debug(f"{self.name} proxy {direction}: total {total_bytes} bytes")
+
+
+class SlicerProxyManager:
+    """Manages FTP and MQTT TLS proxies for a single printer target."""
+
+    # Bambu printer ports
+    PRINTER_FTP_PORT = 990
+    PRINTER_MQTT_PORT = 8883
+
+    # Local listen ports (same as virtual printer)
+    LOCAL_FTP_PORT = 9990
+    LOCAL_MQTT_PORT = 8883
+
+    def __init__(
+        self,
+        target_host: str,
+        cert_path: Path,
+        key_path: Path,
+        on_activity: Callable[[str, str], None] | None = None,
+    ):
+        """Initialize the slicer proxy manager.
+
+        Args:
+            target_host: Target printer IP address
+            cert_path: Path to server certificate
+            key_path: Path to server private key
+            on_activity: Optional callback for activity logging (name, message)
+        """
+        self.target_host = target_host
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self.on_activity = on_activity
+
+        self._ftp_proxy: TLSProxy | None = None
+        self._mqtt_proxy: TLSProxy | None = None
+        self._tasks: list[asyncio.Task] = []
+
+    async def start(self) -> None:
+        """Start FTP and MQTT TLS proxies."""
+        logger.info(f"Starting slicer TLS proxy to {self.target_host}")
+
+        # Create proxies with TLS
+        self._ftp_proxy = TLSProxy(
+            name="FTP",
+            listen_port=self.LOCAL_FTP_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_FTP_PORT,
+            server_cert_path=self.cert_path,
+            server_key_path=self.key_path,
+            on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
+        )
+
+        self._mqtt_proxy = TLSProxy(
+            name="MQTT",
+            listen_port=self.LOCAL_MQTT_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_MQTT_PORT,
+            server_cert_path=self.cert_path,
+            server_key_path=self.key_path,
+            on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
+        )
+
+        # Start as background tasks
+        async def run_with_logging(proxy: TLSProxy) -> None:
+            try:
+                await proxy.start()
+            except Exception as e:
+                logger.error(f"Slicer proxy {proxy.name} failed: {e}")
+
+        self._tasks = [
+            asyncio.create_task(
+                run_with_logging(self._ftp_proxy),
+                name="slicer_proxy_ftp",
+            ),
+            asyncio.create_task(
+                run_with_logging(self._mqtt_proxy),
+                name="slicer_proxy_mqtt",
+            ),
+        ]
+
+        logger.info(f"Slicer TLS proxy started for {self.target_host}")
+
+    async def stop(self) -> None:
+        """Stop all proxies."""
+        logger.info("Stopping slicer proxy")
+
+        # Stop proxies
+        if self._ftp_proxy:
+            await self._ftp_proxy.stop()
+            self._ftp_proxy = None
+
+        if self._mqtt_proxy:
+            await self._mqtt_proxy.stop()
+            self._mqtt_proxy = None
+
+        # Cancel tasks
+        for task in self._tasks:
+            task.cancel()
+
+        if self._tasks:
+            try:
+                await asyncio.wait_for(
+                    asyncio.gather(*self._tasks, return_exceptions=True),
+                    timeout=2.0,
+                )
+            except TimeoutError:
+                logger.debug("Some proxy tasks didn't stop in time")
+
+        self._tasks = []
+        logger.info("Slicer proxy stopped")
+
+    def _log_activity(self, name: str, message: str) -> None:
+        """Log activity via callback if configured."""
+        if self.on_activity:
+            try:
+                self.on_activity(name, message)
+            except Exception:
+                pass
+
+    @property
+    def is_running(self) -> bool:
+        """Check if proxies are running."""
+        return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
+
+    def get_status(self) -> dict:
+        """Get proxy status."""
+        return {
+            "running": self.is_running,
+            "target_host": self.target_host,
+            "ftp_port": self.LOCAL_FTP_PORT,
+            "mqtt_port": self.LOCAL_MQTT_PORT,
+            "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
+            "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
+        }

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

@@ -3775,22 +3775,36 @@ export const discoveryApi = {
 };
 
 // Virtual Printer types
+export type VirtualPrinterMode = 'immediate' | 'queue' | 'review' | 'print_queue' | 'proxy';  // 'queue' is legacy, normalized to 'review'
+
+export interface VirtualPrinterProxyStatus {
+  running: boolean;
+  target_host: string;
+  ftp_port: number;
+  mqtt_port: number;
+  ftp_connections: number;
+  mqtt_connections: number;
+}
+
 export interface VirtualPrinterStatus {
   enabled: boolean;
   running: boolean;
-  mode: 'immediate' | 'queue' | 'review' | 'print_queue';  // 'queue' is legacy, normalized to 'review'
+  mode: VirtualPrinterMode;
   name: string;
   serial: string;
   model: string;
   model_name: string;
   pending_files: number;
+  target_printer_ip?: string;  // For proxy mode
+  proxy?: VirtualPrinterProxyStatus;  // For proxy mode
 }
 
 export interface VirtualPrinterSettings {
   enabled: boolean;
   access_code_set: boolean;
-  mode: 'immediate' | 'queue' | 'review' | 'print_queue';  // 'queue' is legacy, normalized to 'review'
+  mode: VirtualPrinterMode;
   model: string;
+  target_printer_id: number | null;  // For proxy mode
   status: VirtualPrinterStatus;
 }
 
@@ -3820,14 +3834,16 @@ export const virtualPrinterApi = {
   updateSettings: (data: {
     enabled?: boolean;
     access_code?: string;
-    mode?: 'immediate' | 'review' | 'print_queue';
+    mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
+    target_printer_id?: number;
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.access_code !== undefined) params.set('access_code', data.access_code);
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.model !== undefined) params.set('model', data.model);
+    if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',

+ 267 - 116
frontend/src/components/VirtualPrinterSettings.tsx

@@ -1,21 +1,26 @@
 import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink } from 'lucide-react';
-import { virtualPrinterApi } from '../api/client';
+import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink, ArrowRightLeft } from 'lucide-react';
+import { api, virtualPrinterApi } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 
+type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+
 export function VirtualPrinterSettings() {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
+  const [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
+  const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [showAccessCode, setShowAccessCode] = useState(false);
-  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
+  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
 
   // Fetch current settings
   const { data: settings, isLoading } = useQuery({
@@ -30,38 +35,46 @@ export function VirtualPrinterSettings() {
     queryFn: virtualPrinterApi.getModels,
   });
 
+  // Fetch printers for proxy mode dropdown
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
   // Initialize local state from settings
   useEffect(() => {
     if (settings) {
       setLocalEnabled(settings.enabled);
       // Map legacy 'queue' mode to 'review'
-      let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
-      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
+      let mode: LocalMode = settings.mode === 'queue' ? 'review' : settings.mode as LocalMode;
+      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue' && mode !== 'proxy') {
         mode = 'immediate'; // fallback
       }
       setLocalMode(mode);
       setLocalModel(settings.model);
+      setLocalTargetPrinterId(settings.target_printer_id);
     }
   }, [settings]);
 
   // Update mutation
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
-      showToast('Virtual printer settings updated');
+      showToast(t('virtualPrinter.toast.updated'));
       setPendingAction(null);
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Failed to update settings', 'error');
+      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
       // Revert local state on error
       if (settings) {
         setLocalEnabled(settings.enabled);
         // Map legacy 'queue' mode to 'review'
         const mode = settings.mode === 'queue' ? 'review' : settings.mode;
-        setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
+        setLocalMode(['immediate', 'review', 'print_queue', 'proxy'].includes(mode) ? mode as LocalMode : 'immediate');
         setLocalModel(settings.model);
+        setLocalTargetPrinterId(settings.target_printer_id);
       }
       setPendingAction(null);
     },
@@ -70,29 +83,41 @@ export function VirtualPrinterSettings() {
   const handleToggleEnabled = () => {
     const newEnabled = !localEnabled;
 
-    // If enabling, must have access code
-    if (newEnabled && !localAccessCode && !settings?.access_code_set) {
-      showToast('Please set an access code first', 'error');
-      return;
+    // Validation depends on mode
+    if (newEnabled) {
+      if (localMode === 'proxy') {
+        // Proxy mode requires target printer
+        if (!localTargetPrinterId) {
+          showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');
+          return;
+        }
+      } else {
+        // Other modes require access code
+        if (!localAccessCode && !settings?.access_code_set) {
+          showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');
+          return;
+        }
+      }
     }
 
     setLocalEnabled(newEnabled);
     setPendingAction('toggle');
     updateMutation.mutate({
       enabled: newEnabled,
-      access_code: localAccessCode || undefined,
+      access_code: localMode !== 'proxy' ? (localAccessCode || undefined) : undefined,
       mode: localMode,
+      target_printer_id: localMode === 'proxy' ? (localTargetPrinterId ?? undefined) : undefined,
     });
   };
 
   const handleAccessCodeChange = () => {
     if (!localAccessCode) {
-      showToast('Access code cannot be empty', 'error');
+      showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');
       return;
     }
 
     if (localAccessCode.length !== 8) {
-      showToast('Access code must be exactly 8 characters', 'error');
+      showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');
       return;
     }
 
@@ -103,12 +128,20 @@ export function VirtualPrinterSettings() {
     setLocalAccessCode(''); // Clear after saving
   };
 
-  const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
+  const handleModeChange = (mode: LocalMode) => {
     setLocalMode(mode);
     setPendingAction('mode');
     updateMutation.mutate({ mode });
   };
 
+  const handleTargetPrinterChange = (printerId: number) => {
+    setLocalTargetPrinterId(printerId);
+    setPendingAction('targetPrinter');
+    updateMutation.mutate({
+      target_printer_id: printerId,
+    });
+  };
+
   const handleModelChange = (model: string) => {
     setLocalModel(model);
     setPendingAction('model');
@@ -137,28 +170,33 @@ export function VirtualPrinterSettings() {
           <div className="flex items-center justify-between">
             <div className="flex items-center gap-2">
               <Printer className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-lg font-semibold text-white">Virtual Printer</h2>
+              <h2 className="text-lg font-semibold text-white">{t('virtualPrinter.title')}</h2>
             </div>
             {status && (
               <div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>
                 <span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
-                {isRunning ? 'Running' : 'Stopped'}
+                {isRunning ? t('virtualPrinter.running') : t('virtualPrinter.stopped')}
               </div>
             )}
           </div>
         </CardHeader>
         <CardContent className="space-y-4">
           <p className="text-sm text-bambu-gray">
-            Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer
-            will be archived directly without printing.
+            {localMode === 'proxy'
+              ? t('virtualPrinter.description.proxy')
+              : t('virtualPrinter.description.default')}
           </p>
 
           {/* Enable/Disable Toggle */}
           <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
             <div>
-              <div className="text-white font-medium">Enable Virtual Printer</div>
+              <div className="text-white font-medium">{t('virtualPrinter.enable.title')}</div>
               <div className="text-sm text-bambu-gray">
-                {isRunning ? 'Visible as "Bambuddy" in slicer discovery' : 'Not visible to slicers'}
+                {isRunning ? (
+                  localMode === 'proxy'
+                    ? t('virtualPrinter.enable.proxyingTo', { name: printers?.find(p => p.id === localTargetPrinterId)?.name || 'printer' })
+                    : t('virtualPrinter.enable.visibleInSlicer')
+                ) : t('virtualPrinter.enable.notActive')}
               </div>
             </div>
             <button
@@ -176,11 +214,12 @@ export function VirtualPrinterSettings() {
             </button>
           </div>
 
-          {/* Printer Model */}
+          {/* Printer Model - only for non-proxy modes */}
+          {localMode !== 'proxy' && (
           <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Printer Model</div>
+            <div className="text-white font-medium mb-2">{t('virtualPrinter.model.title')}</div>
             <div className="text-sm text-bambu-gray mb-3">
-              Select which printer model to emulate.
+              {t('virtualPrinter.model.description')}
             </div>
             <div className="relative">
               <select
@@ -202,66 +241,119 @@ export function VirtualPrinterSettings() {
             {localEnabled && isRunning && (
               <p className="text-xs text-bambu-gray mt-2">
                 <Info className="w-3 h-3 inline mr-1" />
-                Changing the model will restart the virtual printer
+                {t('virtualPrinter.model.restartWarning')}
               </p>
             )}
           </div>
+          )}
 
-          {/* Access Code */}
-          <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Access Code</div>
-            <div className="text-sm text-bambu-gray mb-3">
-              {settings?.access_code_set ? (
-                <span className="flex items-center gap-1 text-green-400">
-                  <Check className="w-4 h-4" />
-                  Access code is set
-                </span>
-              ) : (
-                <span className="flex items-center gap-1 text-yellow-400">
-                  <AlertTriangle className="w-4 h-4" />
-                  No access code set - required to enable
-                </span>
-              )}
-            </div>
-            <div className="flex gap-2">
-              <div className="relative flex-1">
-                <input
-                  type={showAccessCode ? 'text' : 'password'}
-                  value={localAccessCode}
-                  onChange={(e) => setLocalAccessCode(e.target.value)}
-                  placeholder={settings?.access_code_set ? 'Enter new code to change' : 'Enter 8-char code'}
-                  maxLength={8}
-                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
-                />
-                <button
-                  onClick={() => setShowAccessCode(!showAccessCode)}
-                  className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+          {/* Access Code - only for non-proxy modes */}
+          {localMode !== 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.accessCode.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {settings?.access_code_set ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.accessCode.isSet')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-yellow-400">
+                    <AlertTriangle className="w-4 h-4" />
+                    {t('virtualPrinter.accessCode.notSet')}
+                  </span>
+                )}
+              </div>
+              <div className="flex gap-2">
+                <div className="relative flex-1">
+                  <input
+                    type={showAccessCode ? 'text' : 'password'}
+                    value={localAccessCode}
+                    onChange={(e) => setLocalAccessCode(e.target.value)}
+                    placeholder={settings?.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
+                    maxLength={8}
+                    className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
+                  />
+                  <button
+                    onClick={() => setShowAccessCode(!showAccessCode)}
+                    className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                  >
+                    {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+                  </button>
+                </div>
+                <Button
+                  onClick={handleAccessCodeChange}
+                  disabled={!localAccessCode || pendingAction === 'accessCode'}
+                  variant="primary"
                 >
-                  {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
-                </button>
+                  {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
+                </Button>
               </div>
-              <Button
-                onClick={handleAccessCodeChange}
-                disabled={!localAccessCode || pendingAction === 'accessCode'}
-                variant="primary"
-              >
-                {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
-              </Button>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.accessCode.hint')}
+                {localAccessCode && (
+                  <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
+                    {' '}{t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
+                  </span>
+                )}
+              </p>
             </div>
-            <p className="text-xs text-bambu-gray mt-2">
-              Must be exactly 8 characters. Used by slicers to authenticate.
-              {localAccessCode && (
-                <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
-                  {' '}({localAccessCode.length}/8)
-                </span>
+          )}
+
+          {/* Target Printer - only for proxy mode */}
+          {localMode === 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.targetPrinter.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {localTargetPrinterId ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.targetPrinter.configured')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-yellow-400">
+                    <AlertTriangle className="w-4 h-4" />
+                    {t('virtualPrinter.targetPrinter.notConfigured')}
+                  </span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localTargetPrinterId ?? ''}
+                  onChange={(e) => {
+                    const id = parseInt(e.target.value, 10);
+                    if (!isNaN(id)) {
+                      handleTargetPrinterChange(id);
+                    }
+                  }}
+                  disabled={pendingAction === 'targetPrinter'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
+                >
+                  <option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
+                  {printers?.map((printer) => (
+                    <option key={printer.id} value={printer.id}>
+                      {printer.name} ({printer.ip_address})
+                    </option>
+                  ))}
+                </select>
+                <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+              </div>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.targetPrinter.hint')}
+              </p>
+              {!printers?.length && (
+                <p className="text-xs text-yellow-400 mt-2">
+                  <AlertTriangle className="w-3 h-3 inline mr-1" />
+                  {t('virtualPrinter.targetPrinter.noPrinters')}
+                </p>
               )}
-            </p>
-          </div>
+            </div>
+          )}
 
           {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Mode</div>
-            <div className="grid grid-cols-3 gap-3">
+            <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
+            <div className="grid grid-cols-2 gap-3">
               <button
                 onClick={() => handleModeChange('immediate')}
                 disabled={pendingAction === 'mode'}
@@ -271,8 +363,8 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Archive</div>
-                <div className="text-xs text-bambu-gray">Archive files immediately</div>
+                <div className="text-white font-medium">{t('virtualPrinter.mode.archive')}</div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.archiveDesc')}</div>
               </button>
               <button
                 onClick={() => handleModeChange('review')}
@@ -283,8 +375,8 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Review</div>
-                <div className="text-xs text-bambu-gray">Review and tag before archiving</div>
+                <div className="text-white font-medium">{t('virtualPrinter.mode.review')}</div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.reviewDesc')}</div>
               </button>
               <button
                 onClick={() => handleModeChange('print_queue')}
@@ -295,8 +387,23 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Queue</div>
-                <div className="text-xs text-bambu-gray">Archive and add to print queue</div>
+                <div className="text-white font-medium">{t('virtualPrinter.mode.queue')}</div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.queueDesc')}</div>
+              </button>
+              <button
+                onClick={() => handleModeChange('proxy')}
+                disabled={pendingAction === 'mode'}
+                className={`p-3 rounded-lg border text-left transition-colors ${
+                  localMode === 'proxy'
+                    ? 'border-blue-500 bg-blue-500/10'
+                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                <div className="flex items-center gap-1.5 text-white font-medium">
+                  <ArrowRightLeft className="w-4 h-4" />
+                  {t('virtualPrinter.mode.proxy')}
+                </div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.proxyDesc')}</div>
               </button>
             </div>
           </div>
@@ -313,11 +420,10 @@ export function VirtualPrinterSettings() {
               <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
               <div className="text-sm">
                 <p className="text-white font-medium mb-2">
-                  Setup Required
+                  {t('virtualPrinter.setupRequired.title')}
                 </p>
                 <p className="text-bambu-gray mb-3">
-                  The virtual printer feature requires additional system configuration before it will work.
-                  This includes port forwarding, firewall rules, and platform-specific settings.
+                  {t('virtualPrinter.setupRequired.description')}
                 </p>
                 <a
                   href="https://wiki.bambuddy.cool/features/virtual-printer/"
@@ -326,7 +432,7 @@ export function VirtualPrinterSettings() {
                   className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500/20 border border-yellow-500/50 rounded-md text-yellow-400 hover:bg-yellow-500/30 transition-colors"
                 >
                   <ExternalLink className="w-4 h-4" />
-                  Read the setup guide before enabling
+                  {t('virtualPrinter.setupRequired.readGuide')}
                 </a>
               </div>
             </div>
@@ -340,16 +446,26 @@ export function VirtualPrinterSettings() {
               <Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
               <div className="text-sm text-bambu-gray">
                 <p className="mb-2">
-                  <strong className="text-white">How it works:</strong>
+                  <strong className="text-white">{localMode === 'proxy' ? t('virtualPrinter.howItWorks.titleProxy') : t('virtualPrinter.howItWorks.title')}:</strong>
                 </p>
-                <ol className="list-decimal list-inside space-y-1">
-                  <li>Complete the setup guide for your platform</li>
-                  <li>Enable the virtual printer and set an access code</li>
-                  <li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
-                  <li>The "Bambuddy" printer should appear in the discovery list</li>
-                  <li>Connect using the access code you set</li>
-                  <li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
-                </ol>
+                {localMode === 'proxy' ? (
+                  <ol className="list-decimal list-inside space-y-1">
+                    <li>{t('virtualPrinter.howItWorks.proxyStep1')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep2')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep3')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep4')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep5')}</li>
+                  </ol>
+                ) : (
+                  <ol className="list-decimal list-inside space-y-1">
+                    <li>{t('virtualPrinter.howItWorks.step1')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step2')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step3')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step4')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step5')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step6')}</li>
+                  </ol>
+                )}
               </div>
             </div>
           </CardContent>
@@ -359,31 +475,66 @@ export function VirtualPrinterSettings() {
         {status && isRunning && (
           <Card>
             <CardHeader>
-              <h3 className="text-md font-semibold text-white">Status Details</h3>
+              <h3 className="text-md font-semibold text-white">{t('virtualPrinter.status.title')}</h3>
             </CardHeader>
             <CardContent>
-              <div className="grid grid-cols-2 gap-4 text-sm">
-                <div>
-                  <div className="text-bambu-gray">Printer Name</div>
-                  <div className="text-white">{status.name}</div>
-                </div>
-                <div>
-                  <div className="text-bambu-gray">Model</div>
-                  <div className="text-white">{status.model_name || status.model}</div>
+              {status.mode === 'proxy' && status.proxy ? (
+                <div className="grid grid-cols-2 gap-4 text-sm">
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.targetPrinter')}</div>
+                    <div className="text-white">
+                      {printers?.find(p => p.id === localTargetPrinterId)?.name || status.proxy.target_host}
+                    </div>
+                    <div className="text-xs text-bambu-gray font-mono">{status.proxy.target_host}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mode')}</div>
+                    <div className="text-white flex items-center gap-1.5">
+                      <ArrowRightLeft className="w-4 h-4" />
+                      {t('virtualPrinter.mode.proxy')}
+                    </div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.ftpPort')}</div>
+                    <div className="text-white font-mono">{status.proxy.ftp_port}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mqttPort')}</div>
+                    <div className="text-white font-mono">{status.proxy.mqtt_port}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
+                    <div className="text-white">{status.proxy.ftp_connections}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
+                    <div className="text-white">{status.proxy.mqtt_connections}</div>
+                  </div>
                 </div>
-                <div>
-                  <div className="text-bambu-gray">Serial Number</div>
-                  <div className="text-white font-mono">{status.serial}</div>
-                </div>
-                <div>
-                  <div className="text-bambu-gray">Mode</div>
-                  <div className="text-white capitalize">{status.mode}</div>
-                </div>
-                <div>
-                  <div className="text-bambu-gray">Pending Files</div>
-                  <div className="text-white">{status.pending_files}</div>
+              ) : (
+                <div className="grid grid-cols-2 gap-4 text-sm">
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.printerName')}</div>
+                    <div className="text-white">{status.name}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.model')}</div>
+                    <div className="text-white">{status.model_name || status.model}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.serialNumber')}</div>
+                    <div className="text-white font-mono">{status.serial}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mode')}</div>
+                    <div className="text-white capitalize">{status.mode}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.pendingFiles')}</div>
+                    <div className="text-white">{status.pending_files}</div>
+                  </div>
                 </div>
-              </div>
+              )}
             </CardContent>
           </Card>
         )}

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

@@ -2435,6 +2435,97 @@ export default {
     },
   },
 
+  // Virtual Printer
+  virtualPrinter: {
+    title: 'Virtueller Drucker',
+    running: 'Läuft',
+    stopped: 'Gestoppt',
+    description: {
+      default: 'Aktiviere einen virtuellen Drucker, der in Bambu Studio und OrcaSlicer erscheint. Dateien, die an diesen Drucker gesendet werden, werden direkt archiviert ohne zu drucken.',
+      proxy: 'Aktiviere einen Proxy, der Slicer-Datenverkehr an einen echten Drucker weiterleitet, um Ferndruck über jedes Netzwerk zu ermöglichen.',
+    },
+    enable: {
+      title: 'Virtuellen Drucker aktivieren',
+      visibleInSlicer: 'Sichtbar als "Bambuddy" in der Slicer-Erkennung',
+      proxyingTo: 'Proxy zu {{name}}',
+      notActive: 'Nicht aktiv',
+    },
+    model: {
+      title: 'Druckermodell',
+      description: 'Wähle welches Druckermodell emuliert werden soll.',
+      restartWarning: 'Das Ändern des Modells startet den virtuellen Drucker neu',
+    },
+    accessCode: {
+      title: 'Zugangscode',
+      isSet: 'Zugangscode ist gesetzt',
+      notSet: 'Kein Zugangscode gesetzt - erforderlich zum Aktivieren',
+      placeholder: '8-Zeichen-Code eingeben',
+      placeholderChange: 'Neuen Code eingeben zum Ändern',
+      hint: 'Muss genau 8 Zeichen lang sein. Wird von Slicern zur Authentifizierung verwendet.',
+      charCount: '({{count}}/8)',
+    },
+    targetPrinter: {
+      title: 'Zieldrucker',
+      configured: 'Proxy-Ziel konfiguriert',
+      notConfigured: 'Kein Zieldrucker ausgewählt - erforderlich für Proxy-Modus',
+      placeholder: 'Drucker auswählen...',
+      hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
+      noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
+    },
+    mode: {
+      title: 'Modus',
+      archive: 'Archivieren',
+      archiveDesc: 'Dateien sofort archivieren',
+      review: 'Überprüfen',
+      reviewDesc: 'Vor dem Archivieren überprüfen',
+      queue: 'Warteschlange',
+      queueDesc: 'Archivieren und zur Warteschlange hinzufügen',
+      proxy: 'Proxy',
+      proxyDesc: 'An echten Drucker weiterleiten',
+    },
+    setupRequired: {
+      title: 'Einrichtung erforderlich',
+      description: 'Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.',
+      readGuide: 'Lese die Einrichtungsanleitung vor dem Aktivieren',
+    },
+    howItWorks: {
+      title: 'So funktioniert es',
+      titleProxy: 'So funktioniert es (Proxy-Modus)',
+      step1: 'Schließe die Einrichtungsanleitung für deine Plattform ab',
+      step2: 'Aktiviere den virtuellen Drucker und setze einen Zugangscode',
+      step3: 'In Bambu Studio oder OrcaSlicer gehe zu "Drucker hinzufügen"',
+      step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
+      step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
+      step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
+      proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
+      proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
+      proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
+      proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
+      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
+    },
+    status: {
+      title: 'Status-Details',
+      printerName: 'Druckername',
+      model: 'Modell',
+      serialNumber: 'Seriennummer',
+      mode: 'Modus',
+      pendingFiles: 'Ausstehende Dateien',
+      targetPrinter: 'Zieldrucker',
+      ftpPort: 'FTP-Port',
+      mqttPort: 'MQTT-Port',
+      ftpConnections: 'FTP-Verbindungen',
+      mqttConnections: 'MQTT-Verbindungen',
+    },
+    toast: {
+      updated: 'Virtuelle Druckereinstellungen aktualisiert',
+      failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',
+      accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
+      targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',
+      accessCodeEmpty: 'Zugangscode darf nicht leer sein',
+      accessCodeLength: 'Zugangscode muss genau 8 Zeichen lang sein',
+    },
+  },
+
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
     lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen',

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

@@ -2435,6 +2435,97 @@ export default {
     },
   },
 
+  // Virtual Printer
+  virtualPrinter: {
+    title: 'Virtual Printer',
+    running: 'Running',
+    stopped: 'Stopped',
+    description: {
+      default: 'Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer will be archived directly without printing.',
+      proxy: 'Enable a proxy that relays slicer traffic to a real printer, allowing remote printing over any network.',
+    },
+    enable: {
+      title: 'Enable Virtual Printer',
+      visibleInSlicer: 'Visible as "Bambuddy" in slicer discovery',
+      proxyingTo: 'Proxying to {{name}}',
+      notActive: 'Not active',
+    },
+    model: {
+      title: 'Printer Model',
+      description: 'Select which printer model to emulate.',
+      restartWarning: 'Changing the model will restart the virtual printer',
+    },
+    accessCode: {
+      title: 'Access Code',
+      isSet: 'Access code is set',
+      notSet: 'No access code set - required to enable',
+      placeholder: 'Enter 8-char code',
+      placeholderChange: 'Enter new code to change',
+      hint: 'Must be exactly 8 characters. Used by slicers to authenticate.',
+      charCount: '({{count}}/8)',
+    },
+    targetPrinter: {
+      title: 'Target Printer',
+      configured: 'Proxy target configured',
+      notConfigured: 'No target printer selected - required for proxy mode',
+      placeholder: 'Select a printer...',
+      hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
+      noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
+    },
+    mode: {
+      title: 'Mode',
+      archive: 'Archive',
+      archiveDesc: 'Archive files immediately',
+      review: 'Review',
+      reviewDesc: 'Review before archiving',
+      queue: 'Queue',
+      queueDesc: 'Archive and add to queue',
+      proxy: 'Proxy',
+      proxyDesc: 'Relay to real printer',
+    },
+    setupRequired: {
+      title: 'Setup Required',
+      description: 'The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.',
+      readGuide: 'Read the setup guide before enabling',
+    },
+    howItWorks: {
+      title: 'How it works',
+      titleProxy: 'How it works (Proxy Mode)',
+      step1: 'Complete the setup guide for your platform',
+      step2: 'Enable the virtual printer and set an access code',
+      step3: 'In Bambu Studio or OrcaSlicer, go to "Add Printer"',
+      step4: 'The "Bambuddy" printer should appear in the discovery list',
+      step5: 'Connect using the access code you set',
+      step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
+      proxyStep1: 'Set the target printer IP (must be on your LAN)',
+      proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
+      proxyStep3: 'In your slicer, manually add a network printer',
+      proxyStep4: "Enter Bambuddy's external address and printer's access code",
+      proxyStep5: 'Print as normal - traffic is relayed to the real printer',
+    },
+    status: {
+      title: 'Status Details',
+      printerName: 'Printer Name',
+      model: 'Model',
+      serialNumber: 'Serial Number',
+      mode: 'Mode',
+      pendingFiles: 'Pending Files',
+      targetPrinter: 'Target Printer',
+      ftpPort: 'FTP Port',
+      mqttPort: 'MQTT Port',
+      ftpConnections: 'FTP Connections',
+      mqttConnections: 'MQTT Connections',
+    },
+    toast: {
+      updated: 'Virtual printer settings updated',
+      failedToUpdate: 'Failed to update settings',
+      accessCodeRequired: 'Please set an access code first',
+      targetPrinterRequired: 'Please select a target printer first',
+      accessCodeEmpty: 'Access code cannot be empty',
+      accessCodeLength: 'Access code must be exactly 8 characters',
+    },
+  },
+
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
     lubricateRails: 'Apply lubricant to linear rails for smooth motion',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CPqcJWwC.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DME4t7XG.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DvXO59gA.js


+ 2 - 2
static/index.html

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

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