Przeglądaj źródła

Virtual Printer Proxy Mode Improvements
- SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
- FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990
- For systemd: requires `AmbientCapabilities=CAP_NET_BIND_SERVICE` capability
- Automatic directory permission checking at startup with clear error messages for Docker/bare metal

maziggy 3 miesięcy temu
rodzic
commit
81d18cb8bd

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.1.8b] - Not released
 
+### Enhanced
+- **Virtual Printer Proxy Mode Improvements**:
+  - SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
+  - FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990
+  - For systemd: requires `AmbientCapabilities=CAP_NET_BIND_SERVICE` capability
+  - Automatic directory permission checking at startup with clear error messages for Docker/bare metal
+  - Updated translations for proxy mode steps in English, German, and Japanese
+
 ### Fixed
 - **Filament Statistics Incorrectly Multiplied by Quantity** (Issue #229):
   - Fixed filament totals being inflated by incorrectly multiplying by quantity

+ 25 - 1
backend/app/api/routes/settings.py

@@ -434,6 +434,17 @@ async def restore_backup(
             )
 
 
+@router.get("/network-interfaces")
+async def get_network_interfaces(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Get available network interfaces for SSDP proxy configuration."""
+    from backend.app.services.network_utils import get_network_interfaces
+
+    interfaces = get_network_interfaces()
+    return {"interfaces": interfaces}
+
+
 @router.get("/virtual-printer/models")
 async def get_virtual_printer_models(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
@@ -466,6 +477,7 @@ async def get_virtual_printer_settings(
     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")
+    remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
 
     return {
         "enabled": enabled == "true" if enabled else False,
@@ -473,6 +485,7 @@ async def get_virtual_printer_settings(
         "mode": mode or "immediate",
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
+        "remote_interface_ip": remote_interface_ip or "",
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -484,10 +497,16 @@ async def update_virtual_printer_settings(
     mode: str = None,
     model: str = None,
     target_printer_id: int = None,
+    remote_interface_ip: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Update virtual printer settings and restart services if needed."""
+    """Update virtual printer settings and restart services if needed.
+
+    For proxy mode with SSDP proxy (dual-homed setup):
+    - remote_interface_ip: IP of interface on slicer's network (LAN B)
+    - Local interface is auto-detected based on target printer IP
+    """
     from sqlalchemy import select
 
     from backend.app.models.printer import Printer
@@ -504,6 +523,7 @@ async def update_virtual_printer_settings(
     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
+    current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
@@ -511,6 +531,7 @@ async def update_virtual_printer_settings(
     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
+    new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
 
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
@@ -587,6 +608,8 @@ async def update_virtual_printer_settings(
         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))
+    if remote_interface_ip is not None:
+        await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     await db.commit()
     db.expire_all()
 
@@ -599,6 +622,7 @@ async def update_virtual_printer_settings(
             model=new_model,
             target_printer_ip=target_printer_ip,
             target_printer_serial=target_printer_serial,
+            remote_interface_ip=new_remote_iface,
         )
     except ValueError as e:
         logger.warning(f"Virtual printer configuration validation error: {e}")

+ 2 - 0
backend/app/main.py

@@ -2501,6 +2501,7 @@ async def lifespan(app: FastAPI):
             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")
+            vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
 
             # Look up printer IP and serial if in proxy mode
             vp_target_ip = ""
@@ -2526,6 +2527,7 @@ async def lifespan(app: FastAPI):
                         model=vp_model,
                         target_printer_ip=vp_target_ip,
                         target_printer_serial=vp_target_serial,
+                        remote_interface_ip=vp_remote_iface,
                     )
                     if vp_mode == "proxy":
                         logging.info(f"Virtual printer proxy started (target={vp_target_ip})")

+ 119 - 0
backend/app/services/network_utils.py

@@ -0,0 +1,119 @@
+"""Network utility functions for interface detection."""
+
+import ipaddress
+import logging
+import socket
+import struct
+
+logger = logging.getLogger(__name__)
+
+# Interfaces to exclude from selection
+EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
+
+
+def get_network_interfaces() -> list[dict]:
+    """Get all network interfaces with their IPs and subnets.
+
+    Returns:
+        List of dicts with name, ip, netmask, subnet, broadcast
+    """
+    interfaces = []
+
+    try:
+        import fcntl
+
+        for iface in socket.if_nameindex():
+            name = iface[1]
+
+            # Skip excluded interfaces
+            if any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES):
+                continue
+
+            try:
+                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+                # Get IP address
+                ip_bytes = fcntl.ioctl(
+                    s.fileno(),
+                    0x8915,  # SIOCGIFADDR
+                    struct.pack("256s", name[:15].encode()),
+                )[20:24]
+                ip = socket.inet_ntoa(ip_bytes)
+
+                # Get netmask
+                netmask_bytes = fcntl.ioctl(
+                    s.fileno(),
+                    0x891B,  # SIOCGIFNETMASK
+                    struct.pack("256s", name[:15].encode()),
+                )[20:24]
+                netmask = socket.inet_ntoa(netmask_bytes)
+
+                # Calculate subnet
+                network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
+
+                interfaces.append(
+                    {
+                        "name": name,
+                        "ip": ip,
+                        "netmask": netmask,
+                        "subnet": str(network),
+                    }
+                )
+
+                s.close()
+            except OSError:
+                # Interface doesn't have an IP or other error
+                pass
+            except Exception as e:
+                logger.debug(f"Error getting info for interface {name}: {e}")
+
+    except ImportError:
+        # fcntl not available (Windows)
+        logger.warning("fcntl not available, interface detection limited")
+    except Exception as e:
+        logger.error(f"Error enumerating interfaces: {e}")
+
+    return interfaces
+
+
+def find_interface_for_ip(target_ip: str) -> dict | None:
+    """Find which interface is on the same subnet as the target IP.
+
+    Args:
+        target_ip: IP address to find the matching interface for
+
+    Returns:
+        Interface dict or None if not found
+    """
+    try:
+        target = ipaddress.IPv4Address(target_ip)
+    except ValueError:
+        logger.error(f"Invalid target IP: {target_ip}")
+        return None
+
+    interfaces = get_network_interfaces()
+
+    for iface in interfaces:
+        try:
+            network = ipaddress.IPv4Network(iface["subnet"], strict=False)
+            if target in network:
+                logger.debug(f"Found interface {iface['name']} ({iface['ip']}) for target {target_ip}")
+                return iface
+        except ValueError:
+            continue
+
+    logger.warning(f"No interface found for target IP {target_ip}")
+    return None
+
+
+def get_other_interfaces(exclude_ip: str) -> list[dict]:
+    """Get all interfaces except the one with the given IP.
+
+    Args:
+        exclude_ip: IP address of interface to exclude
+
+    Returns:
+        List of interface dicts
+    """
+    interfaces = get_network_interfaces()
+    return [iface for iface in interfaces if iface["ip"] != exclude_ip]

+ 116 - 19
backend/app/services/virtual_printer/manager.py

@@ -17,7 +17,7 @@ from backend.app.core.config import settings as app_settings
 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.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 logger = logging.getLogger(__name__)
@@ -93,9 +93,11 @@ class VirtualPrinterManager:
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
         self._target_printer_ip = ""  # For proxy mode
         self._target_printer_serial = ""  # For proxy mode (real printer's serial)
+        self._remote_interface_ip = ""  # For proxy mode SSDP (LAN B - slicer network)
 
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
         self._proxy: SlicerProxyManager | None = None  # For proxy mode
@@ -108,12 +110,54 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         self._cert_dir = self._base_dir / "certs"
 
+        # Create directories early to avoid permission issues later
+        # If running in Docker, these need to be on a writable volume
+        self._ensure_directories()
+
         # Certificate service
         self._cert_service = CertificateService(self._cert_dir)
 
         # Track pending uploads for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
+    def _ensure_directories(self) -> None:
+        """Create and verify virtual printer directories are writable.
+
+        Creates all required directories at startup to catch permission
+        issues early rather than when the user tries to enable features.
+        """
+        dirs_to_create = [
+            self._base_dir,
+            self._upload_dir,
+            self._upload_dir / "cache",
+            self._cert_dir,
+        ]
+
+        logger.info(f"Checking virtual printer directories in {self._base_dir}")
+
+        for dir_path in dirs_to_create:
+            try:
+                dir_path.mkdir(parents=True, exist_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Cannot create directory {dir_path}: Permission denied. "
+                    f"For Docker: ensure the data volume is writable by the container user. "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+                continue
+
+            # Verify directory is writable by attempting to create a test file
+            test_file = dir_path / ".write_test"
+            try:
+                test_file.touch()
+                test_file.unlink(missing_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Directory {dir_path} exists but is not writable. "
+                    f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+
     def _get_serial_for_model(self, model: str) -> str:
         """Get appropriate serial number for the given model.
 
@@ -157,6 +201,7 @@ class VirtualPrinterManager:
         model: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",
+        remote_interface_ip: str = "",
     ) -> None:
         """Configure and start/stop virtual printer.
 
@@ -167,6 +212,7 @@ class VirtualPrinterManager:
             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
+            remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
         """
         # Proxy mode has different requirements
         if mode == "proxy":
@@ -183,12 +229,14 @@ class VirtualPrinterManager:
         mode_changed = mode != self._mode
         target_changed = target_printer_ip != self._target_printer_ip
         serial_changed = target_printer_serial != self._target_printer_serial
+        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
         old_mode = self._mode
 
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
             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}"
+            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
+            f"remote_interface_ip={remote_interface_ip}"
         )
 
         self._access_code = access_code
@@ -196,8 +244,13 @@ class VirtualPrinterManager:
         self._model = new_model
         self._target_printer_ip = target_printer_ip
         self._target_printer_serial = target_printer_serial
+        self._remote_interface_ip = remote_interface_ip
 
-        needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
+        needs_restart = (
+            model_changed
+            or mode_changed
+            or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
+        )
 
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
@@ -247,13 +300,6 @@ class VirtualPrinterManager:
         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,
@@ -269,21 +315,68 @@ class VirtualPrinterManager:
             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",
-            ),
+        self._tasks = []
+
+        # SSDP setup: use SSDPProxy if remote interface is configured
+        # Local interface is auto-detected from target printer IP
+        if self._remote_interface_ip:
+            # Auto-detect local interface based on target printer IP
+            from backend.app.services.network_utils import find_interface_for_ip
+
+            local_iface = find_interface_for_ip(self._target_printer_ip)
+            if local_iface:
+                local_interface_ip = local_iface["ip"]
+                logger.info(
+                    f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
+                )
+                self._ssdp_proxy = SSDPProxy(
+                    local_interface_ip=local_interface_ip,
+                    remote_interface_ip=self._remote_interface_ip,
+                    target_printer_ip=self._target_printer_ip,
+                )
+                self._tasks.append(
+                    asyncio.create_task(
+                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
+                        name="virtual_printer_ssdp_proxy",
+                    )
+                )
+            else:
+                logger.warning(
+                    f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
+                    "falling back to single-interface SSDP"
+                )
+                self._start_fallback_ssdp(proxy_serial, run_with_logging)
+        else:
+            # Single interface: broadcast SSDP on same network (fallback)
+            self._start_fallback_ssdp(proxy_serial, run_with_logging)
+
+        # Add TLS proxy task
+        self._tasks.append(
             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}"
+            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}"
+        )
+
+    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
+        """Start single-interface SSDP server as fallback."""
+        logger.info("SSDP broadcast mode (single interface)")
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.PRINTER_NAME} (Proxy)",
+            serial=proxy_serial,
+            model=self._model,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name="virtual_printer_ssdp",
+            )
         )
 
     async def _start_server_mode(self) -> None:
@@ -361,6 +454,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             self._ssdp = None
 
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+
         if self._proxy:
             await self._proxy.stop()
             self._proxy = None

+ 245 - 20
backend/app/services/virtual_printer/ssdp_server.py

@@ -2,18 +2,23 @@
 
 Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
 announcements so the virtual printer appears as a discoverable Bambu printer.
+
+Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
+between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
 """
 
 import asyncio
 import logging
+import re
 import socket
 import struct
-from datetime import datetime
 
 logger = logging.getLogger(__name__)
 
-# SSDP multicast address - Bambu uses port 2021
-SSDP_ADDR = "239.255.255.250"
+# SSDP addresses - Bambu uses port 2021
+# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
+SSDP_MULTICAST_ADDR = "239.255.255.250"
+SSDP_BROADCAST_ADDR = "255.255.255.255"
 SSDP_PORT = 2021
 
 # Bambu service target
@@ -60,44 +65,49 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
 
     def _build_notify_message(self) -> bytes:
-        """Build SSDP NOTIFY message for periodic announcements."""
+        """Build SSDP NOTIFY message for periodic announcements.
+
+        Format matches real Bambu printer SSDP broadcasts observed on the network.
+        Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
+        """
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match exact format of real Bambu printers (captured via tcpdump)
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            "Cache-Control: max-age=1800\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:alive\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
+            "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
             f"DevName.bambu.com: {self.name}\r\n"
             "DevSignal.bambu.com: -44\r\n"
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
         )
         return message.encode()
 
     def _build_response_message(self) -> bytes:
-        """Build SSDP response message for M-SEARCH requests."""
+        """Build SSDP response message for M-SEARCH requests.
+
+        Format matches real Bambu printer SSDP responses.
+        """
         ip = self._get_local_ip()
-        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Match format of real Bambu printers
         # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
-        # Added: Devseclink, DevVersion, DevCap for better compatibility
         message = (
             "HTTP/1.1 200 OK\r\n"
-            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
-            f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
+            "Server: UPnP/1.0\r\n"
             f"Location: {ip}\r\n"
             f"ST: {BAMBU_SEARCH_TARGET}\r\n"
-            "EXT:\r\n"
             f"USN: {self.serial}\r\n"
             "Cache-Control: max-age=1800\r\n"
             f"DevModel.bambu.com: {self.model}\r\n"
@@ -106,7 +116,9 @@ class VirtualPrinterSSDPServer:
             "DevConnect.bambu.com: lan\r\n"
             "DevBind.bambu.com: free\r\n"
             "Devseclink.bambu.com: secure\r\n"
+            "DevInf.bambu.com: eth0\r\n"
             "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "DevCap.bambu.com: 1\r\n"
             "\r\n"
         )
         return message.encode()
@@ -137,7 +149,7 @@ class VirtualPrinterSSDPServer:
             self._socket.bind(("", SSDP_PORT))
 
             # Join multicast group
-            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
             # Enable broadcast
@@ -212,13 +224,14 @@ class VirtualPrinterSSDPServer:
             self._socket = None
 
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message."""
+        """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
         if not self._socket:
             return
 
         try:
             msg = self._build_notify_message()
-            self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
+            # Real Bambu printers broadcast to 255.255.255.255, not multicast
+            self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug(f"Sent SSDP NOTIFY for {self.name}")
         except Exception as e:
             logger.debug(f"Failed to send NOTIFY: {e}")
@@ -230,7 +243,7 @@ class VirtualPrinterSSDPServer:
 
         message = (
             "NOTIFY * HTTP/1.1\r\n"
-            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+            f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
             f"NT: {BAMBU_SEARCH_TARGET}\r\n"
             "NTS: ssdp:byebye\r\n"
             f"USN: {self.serial}\r\n"
@@ -238,7 +251,7 @@ class VirtualPrinterSSDPServer:
         )
 
         try:
-            self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
+            self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP byebye")
         except Exception:
             pass
@@ -268,3 +281,215 @@ class VirtualPrinterSSDPServer:
                 logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
             except Exception as e:
                 logger.debug(f"Failed to send SSDP response: {e}")
+
+
+class SSDPProxy:
+    """SSDP proxy that re-broadcasts printer discovery from one network to another.
+
+    Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
+    then re-broadcasts them on the remote interface (LAN B) with the Location
+    header changed to point to Bambuddy's IP on LAN B.
+
+    This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
+    """
+
+    def __init__(
+        self,
+        local_interface_ip: str,
+        remote_interface_ip: str,
+        target_printer_ip: str,
+    ):
+        """Initialize the SSDP proxy.
+
+        Args:
+            local_interface_ip: IP of interface on printer's network (LAN A)
+            remote_interface_ip: IP of interface on slicer's network (LAN B)
+            target_printer_ip: IP of the real printer to proxy SSDP for
+        """
+        self.local_interface_ip = local_interface_ip
+        self.remote_interface_ip = remote_interface_ip
+        self.target_printer_ip = target_printer_ip
+        self._running = False
+        self._local_socket: socket.socket | None = None
+        self._remote_socket: socket.socket | None = None
+        self._last_printer_ssdp: bytes | None = None
+        self._printer_info: dict[str, str] = {}
+
+    def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
+        """Parse SSDP message into header dict."""
+        headers = {}
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            for line in text.split("\r\n"):
+                if ":" in line:
+                    key, value = line.split(":", 1)
+                    headers[key.strip().lower()] = value.strip()
+        except Exception:
+            pass
+        return headers
+
+    def _rewrite_ssdp_location(self, data: bytes) -> bytes:
+        """Rewrite SSDP message with Bambuddy's remote IP as Location."""
+        try:
+            text = data.decode("utf-8", errors="ignore")
+            original = text
+            # Replace Location header with our remote interface IP
+            text = re.sub(
+                r"(Location:\s*)[\d.]+",
+                f"\\g<1>{self.remote_interface_ip}",
+                text,
+                flags=re.IGNORECASE,
+            )
+            if text != original:
+                logger.debug(f"Rewrote SSDP Location to {self.remote_interface_ip}")
+                logger.debug(f"Rewritten SSDP packet:\n{text}")
+            else:
+                logger.warning(f"SSDP Location rewrite had no effect. Packet:\n{original}")
+            return text.encode("utf-8")
+        except Exception as e:
+            logger.error(f"Failed to rewrite SSDP: {e}")
+            return data
+
+    async def start(self) -> None:
+        """Start the SSDP proxy."""
+        if self._running:
+            return
+
+        logger.info(
+            f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
+            f"broadcasting on {self.remote_interface_ip} (LAN B), "
+            f"proxying printer {self.target_printer_ip}"
+        )
+        self._running = True
+
+        try:
+            # Create socket for listening on LAN A (printer network)
+            # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
+            # We filter by source IP in the handler
+            self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._local_socket.setblocking(False)
+            # Bind to all interfaces to receive broadcasts
+            self._local_socket.bind(("", SSDP_PORT))
+
+            # Join multicast group on local interface (for multicast SSDP if used)
+            mreq = struct.pack(
+                "4s4s",
+                socket.inet_aton(SSDP_MULTICAST_ADDR),
+                socket.inet_aton(self.local_interface_ip),
+            )
+            self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            # Create socket for broadcasting on LAN B (slicer network)
+            self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            try:
+                self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+            self._remote_socket.setblocking(False)
+            # Bind to remote interface
+            self._remote_socket.bind((self.remote_interface_ip, 0))
+            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info(f"SSDP proxy listening on 0.0.0.0:{SSDP_PORT} (filtering for printer {self.target_printer_ip})")
+            logger.info(f"SSDP proxy will broadcast on {self.remote_interface_ip}")
+
+            # Main loop
+            last_broadcast = 0.0
+            broadcast_interval = 30.0  # Re-broadcast every 30 seconds
+
+            while self._running:
+                # Listen for SSDP from printer on LAN A
+                try:
+                    data, addr = self._local_socket.recvfrom(4096)
+                    await self._handle_local_packet(data, addr)
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    if self._running:
+                        logger.debug(f"SSDP proxy receive error: {e}")
+
+                # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
+                # For now, we periodically re-broadcast cached printer SSDP
+                now = asyncio.get_event_loop().time()
+                if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
+                    await self._broadcast_to_remote()
+                    last_broadcast = now
+
+                await asyncio.sleep(0.1)
+
+        except OSError as e:
+            logger.error(f"SSDP proxy error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("SSDP proxy cancelled")
+        except Exception as e:
+            logger.error(f"SSDP proxy error: {e}")
+        finally:
+            await self._cleanup()
+
+    async def stop(self) -> None:
+        """Stop the SSDP proxy."""
+        logger.info("Stopping SSDP proxy")
+        self._running = False
+        await self._cleanup()
+
+    async def _cleanup(self) -> None:
+        """Clean up resources."""
+        for sock in [self._local_socket, self._remote_socket]:
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+        self._local_socket = None
+        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)."""
+        sender_ip = addr[0]
+
+        # Only process packets from the target printer
+        if sender_ip != self.target_printer_ip:
+            return
+
+        # Check if it's a NOTIFY message
+        if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
+            return
+
+        # Check if it's a Bambu printer SSDP
+        if b"bambulab-com:device:3dprinter" not in data:
+            return
+
+        # Parse and store printer info
+        headers = self._parse_ssdp_message(data)
+        if headers:
+            self._printer_info = headers
+            logger.debug(f"Received SSDP from printer {sender_ip}: {headers.get('devname.bambu.com', 'unknown')}")
+
+        # Store and immediately broadcast
+        self._last_printer_ssdp = data
+        await self._broadcast_to_remote()
+
+    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:
+            return
+
+        try:
+            # Rewrite Location to point to Bambuddy's remote interface
+            rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
+
+            # Calculate broadcast address for remote network
+            # Use 255.255.255.255 for simplicity (works across subnets)
+            self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+
+            printer_name = self._printer_info.get("devname.bambu.com", "unknown")
+            logger.debug(f"Broadcast SSDP for '{printer_name}' on LAN B ({self.remote_interface_ip})")
+        except Exception as e:
+            logger.debug(f"Failed to broadcast SSDP on remote: {e}")

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

@@ -289,8 +289,9 @@ class SlicerProxyManager:
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
 
-    # Local listen ports (same as virtual printer)
-    LOCAL_FTP_PORT = 9990
+    # Local listen ports - must match what Bambu Studio expects
+    # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
+    LOCAL_FTP_PORT = 990
     LOCAL_MQTT_PORT = 8883
 
     def __init__(

+ 163 - 2
backend/tests/unit/services/test_virtual_printer.py

@@ -464,7 +464,8 @@ class TestSlicerProxyManager:
 
     def test_proxy_manager_initializes_ports(self, proxy_manager):
         """Verify proxy manager has correct port constants."""
-        assert proxy_manager.LOCAL_FTP_PORT == 9990
+        # FTP proxy uses privileged port 990 to match what Bambu Studio expects
+        assert proxy_manager.LOCAL_FTP_PORT == 990
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
@@ -482,6 +483,125 @@ class TestSlicerProxyManager:
         assert status["mqtt_connections"] == 0
 
 
+class TestSSDPProxy:
+    """Tests for SSDPProxy (cross-network SSDP relay)."""
+
+    @pytest.fixture
+    def ssdp_proxy(self):
+        """Create an SSDPProxy instance."""
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+        )
+
+    def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
+        """Verify SSDPProxy stores interface IPs correctly."""
+        assert ssdp_proxy.local_interface_ip == "192.168.1.100"
+        assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
+        assert ssdp_proxy.target_printer_ip == "192.168.1.50"
+
+    def test_rewrite_ssdp_location(self, ssdp_proxy):
+        """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)
+
+        # Location should be changed to remote interface IP
+        assert b"Location: 10.0.0.100" in rewritten
+        assert b"Location: 192.168.1.50" not in rewritten
+        # Other headers should be preserved
+        assert b"DevName.bambu.com: TestPrinter" in rewritten
+
+    def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
+        """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)
+
+        assert b"10.0.0.100" in rewritten
+
+    def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
+        """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)
+
+        # Should be unchanged (no Location header to rewrite)
+        assert rewritten == original_packet
+
+    def test_parse_ssdp_message(self, ssdp_proxy):
+        """Verify SSDP message parsing extracts headers."""
+        packet = (
+            b"NOTIFY * HTTP/1.1\r\n"
+            b"Location: 192.168.1.50\r\n"
+            b"DevName.bambu.com: TestPrinter\r\n"
+            b"DevModel.bambu.com: BL-P001\r\n"
+            b"\r\n"
+        )
+
+        headers = ssdp_proxy._parse_ssdp_message(packet)
+
+        assert headers["location"] == "192.168.1.50"
+        assert headers["devname.bambu.com"] == "TestPrinter"
+        assert headers["devmodel.bambu.com"] == "BL-P001"
+
+
+class TestVirtualPrinterManagerDirectories:
+    """Tests for VirtualPrinterManager directory management."""
+
+    def test_ensure_directories_creates_subdirs(self, tmp_path):
+        """Verify _ensure_directories creates all required subdirectories."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        # Create a manager and manually call _ensure_directories with our tmp path
+        manager = VirtualPrinterManager()
+        # Override the paths
+        manager._base_dir = tmp_path / "virtual_printer"
+        manager._upload_dir = manager._base_dir / "uploads"
+        manager._cert_dir = manager._base_dir / "certs"
+
+        # Call the method
+        manager._ensure_directories()
+
+        # All directories should be created
+        assert (tmp_path / "virtual_printer").exists()
+        assert (tmp_path / "virtual_printer" / "uploads").exists()
+        assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
+        assert (tmp_path / "virtual_printer" / "certs").exists()
+
+    def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
+        """Verify _ensure_directories logs error on permission failure."""
+        import logging
+        from unittest.mock import patch
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        # Create manager and override paths
+        manager = VirtualPrinterManager()
+        vp_dir = tmp_path / "virtual_printer"
+
+        manager._base_dir = vp_dir
+        manager._upload_dir = vp_dir / "uploads"
+        manager._cert_dir = vp_dir / "certs"
+
+        # Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
+        original_mkdir = type(vp_dir).mkdir
+
+        def mock_mkdir(self, *args, **kwargs):
+            if "virtual_printer" in str(self):
+                raise PermissionError("Permission denied")
+            return original_mkdir(self, *args, **kwargs)
+
+        with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
+            # This should log errors but not raise
+            manager._ensure_directories()
+            # Check that error was logged
+            assert "Permission denied" in caplog.text
+
+
 class TestVirtualPrinterManagerProxyMode:
     """Tests for VirtualPrinterManager proxy mode."""
 
@@ -528,7 +648,7 @@ class TestVirtualPrinterManagerProxyMode:
         mock_proxy = MagicMock()
         mock_proxy.get_status.return_value = {
             "running": True,
-            "ftp_port": 9990,
+            "ftp_port": 990,  # Privileged port for Bambu Studio compatibility
             "mqtt_port": 8883,
             "ftp_connections": 1,
             "mqtt_connections": 2,
@@ -541,5 +661,46 @@ class TestVirtualPrinterManagerProxyMode:
         assert status["mode"] == "proxy"
         assert status["target_printer_ip"] == "192.168.1.100"
         assert "proxy" in status
+        assert status["proxy"]["ftp_port"] == 990  # Privileged port for Bambu Studio compatibility
+        assert status["proxy"]["mqtt_port"] == 8883
         assert status["proxy"]["ftp_connections"] == 1
         assert status["proxy"]["mqtt_connections"] == 2
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_with_remote_interface(self, manager):
+        """Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.50",
+        )
+
+        assert manager._mode == "proxy"
+        assert manager._target_printer_ip == "192.168.1.100"
+        assert manager._remote_interface_ip == "10.0.0.50"
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
+        """Verify changing remote_interface_ip restarts services."""
+        # Simulate running state
+        manager._enabled = True
+        manager._mode = "proxy"
+        manager._target_printer_ip = "192.168.1.100"
+        manager._remote_interface_ip = "10.0.0.50"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.99",  # Changed
+        )
+
+        # Should have stopped and started
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()

+ 2 - 1
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -43,6 +43,7 @@ const createMockSettings = (overrides = {}) => ({
   mode: 'immediate' as const,
   model: '3DPrinter-X1-Carbon',
   target_printer_id: null as number | null,
+  remote_interface_ip: null as string | null,
   status: {
     enabled: false,
     running: false,
@@ -515,7 +516,7 @@ describe('VirtualPrinterSettings', () => {
             proxy: {
               running: true,
               target_host: '192.168.1.100',
-              ftp_port: 9990,
+              ftp_port: 990,  // Privileged port for Bambu Studio compatibility
               mqtt_port: 8883,
               ftp_connections: 1,
               mqtt_connections: 2,

+ 12 - 0
frontend/src/api/client.ts

@@ -2722,6 +2722,8 @@ export const api = {
   },
   checkFfmpeg: () =>
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
+  getNetworkInterfaces: () =>
+    request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
 
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -3975,9 +3977,17 @@ export interface VirtualPrinterSettings {
   mode: VirtualPrinterMode;
   model: string;
   target_printer_id: number | null;  // For proxy mode
+  remote_interface_ip: string | null;  // For SSDP proxy across networks
   status: VirtualPrinterStatus;
 }
 
+export interface NetworkInterface {
+  name: string;
+  ip: string;
+  netmask: string;
+  subnet: string;
+}
+
 export interface VirtualPrinterModels {
   models: Record<string, string>;  // SSDP code -> display name
   default: string;
@@ -4007,6 +4017,7 @@ export const virtualPrinterApi = {
     mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
     target_printer_id?: number;
+    remote_interface_ip?: string;
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4014,6 +4025,7 @@ export const virtualPrinterApi = {
     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));
+    if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',

+ 58 - 4
frontend/src/components/VirtualPrinterSettings.tsx

@@ -19,8 +19,9 @@ export function VirtualPrinterSettings() {
   const [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
+  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
   const [showAccessCode, setShowAccessCode] = useState(false);
-  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
+  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);
 
   // Fetch current settings
   const { data: settings, isLoading } = useQuery({
@@ -41,6 +42,13 @@ export function VirtualPrinterSettings() {
     queryFn: api.getPrinters,
   });
 
+  // Fetch network interfaces for SSDP proxy (only in proxy mode)
+  const { data: networkInterfaces } = useQuery({
+    queryKey: ['network-interfaces'],
+    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
+    enabled: localMode === 'proxy',
+  });
+
   // Initialize local state from settings
   useEffect(() => {
     if (settings) {
@@ -53,12 +61,13 @@ export function VirtualPrinterSettings() {
       setLocalMode(mode);
       setLocalModel(settings.model);
       setLocalTargetPrinterId(settings.target_printer_id);
+      setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
     }
   }, [settings]);
 
   // Update mutation
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -148,6 +157,12 @@ export function VirtualPrinterSettings() {
     updateMutation.mutate({ model });
   };
 
+  const handleRemoteInterfaceChange = (ip: string) => {
+    setLocalRemoteInterfaceIp(ip);
+    setPendingAction('remoteInterface');
+    updateMutation.mutate({ remote_interface_ip: ip });
+  };
+
   if (isLoading) {
     return (
       <Card>
@@ -350,6 +365,45 @@ export function VirtualPrinterSettings() {
             </div>
           )}
 
+          {/* Remote Interface - only for proxy mode (SSDP proxy) */}
+          {localMode === 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.remoteInterface.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {localRemoteInterfaceIp ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.configured')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-bambu-gray">
+                    <Info className="w-4 h-4" />
+                    {t('virtualPrinter.remoteInterface.optional')}
+                  </span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localRemoteInterfaceIp}
+                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
+                  disabled={pendingAction === 'remoteInterface'}
+                  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.remoteInterface.placeholder')}</option>
+                  {networkInterfaces?.map((iface) => (
+                    <option key={iface.ip} value={iface.ip}>
+                      {iface.name} ({iface.ip}) - {iface.subnet}
+                    </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.remoteInterface.hint')}
+              </p>
+            </div>
+          )}
+
           {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
             <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
@@ -504,11 +558,11 @@ export function VirtualPrinterSettings() {
                   </div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
-                    <div className="text-white">{status.proxy.ftp_connections}</div>
+                    <div className="text-white">{status.proxy.ftp_connections ?? 0}</div>
                   </div>
                   <div>
                     <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
-                    <div className="text-white">{status.proxy.mqtt_connections}</div>
+                    <div className="text-white">{status.proxy.mqtt_connections ?? 0}</div>
                   </div>
                 </div>
               ) : (

+ 13 - 5
frontend/src/i18n/locales/de.ts

@@ -2478,6 +2478,13 @@ export default {
       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.',
     },
+    remoteInterface: {
+      title: 'Slicer-Netzwerkschnittstelle',
+      configured: 'SSDP-Proxy aktiviert',
+      optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
+      placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
+      hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
+    },
     mode: {
       title: 'Modus',
       archive: 'Archivieren',
@@ -2503,11 +2510,12 @@ export default {
       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',
+      proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
+      proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
+      proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
+      proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
+      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
+      proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
     },
     status: {
       title: 'Status-Details',

+ 13 - 5
frontend/src/i18n/locales/en.ts

@@ -2478,6 +2478,13 @@ export default {
       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.',
     },
+    remoteInterface: {
+      title: 'Slicer Network Interface',
+      configured: 'SSDP proxy enabled',
+      optional: 'Optional - for SSDP discovery across networks',
+      placeholder: 'Select interface for slicer network...',
+      hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
+    },
     mode: {
       title: 'Mode',
       archive: 'Archive',
@@ -2503,11 +2510,12 @@ export default {
       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',
+      proxyStep1: 'Select the target printer (must be in LAN mode)',
+      proxyStep2: 'For cross-network: select the slicer network interface',
+      proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
+      proxyStep4: 'Connect using the printer\'s access code',
+      proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
+      proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
     },
     status: {
       title: 'Status Details',

+ 29 - 0
frontend/src/i18n/locales/ja.ts

@@ -1474,6 +1474,35 @@ export default {
       model: 'モデル',
       serialNumber: 'シリアルナンバー',
       pendingFiles: '保留中のファイル',
+      modeProxy: 'プロキシ',
+      modeProxyDesc: '実際のプリンターに中継',
+      descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
+      proxyingTo: '{{name}}にプロキシ中',
+      notActive: '非アクティブ',
+      targetPrinterTitle: 'ターゲットプリンター',
+      targetPrinterConfigured: 'プロキシターゲット設定済み',
+      targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
+      targetPrinterPlaceholder: 'プリンターを選択...',
+      targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
+      targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
+      remoteInterfaceTitle: 'スライサーネットワークインターフェース',
+      remoteInterfaceConfigured: 'SSDPプロキシ有効',
+      remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
+      remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
+      remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
+      targetPrinterRequired: '先にターゲットプリンターを選択してください',
+      ftpPort: 'FTPポート',
+      mqttPort: 'MQTTポート',
+      ftpConnections: 'FTP接続数',
+      mqttConnections: 'MQTT接続数',
+      targetPrinterStatus: 'ターゲットプリンター',
+      howItWorksProxy: '仕組み(プロキシモード)',
+      proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
+      proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
+      proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
+      proxyStep4: 'プリンターのアクセスコードで接続',
+      proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
+      proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
     },
     enterNewCodeToChange: '新しいコードを入力して変更',
     enter8CharCode: '8文字のコードを入力',

Plik diff jest za duży
+ 0 - 0
static/assets/index-CIJ4LJuv.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-Cqhunm67.js"></script>
+    <script type="module" crossorigin src="/assets/index-CIJ4LJuv.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-fdAEMOwp.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików