Browse Source

Fix virtual printer IP override ignored in server mode (#52)

The remote_interface_ip setting only worked in proxy mode but was
completely ignored in server modes (immediate/review/print_queue).
Users with multiple NICs (LAN + Tailscale, Docker bridges) got wrong
auto-detected IP in SSDP broadcasts and TLS certificates.
maziggy 3 months ago
parent
commit
516841a7f5

+ 1 - 0
CHANGELOG.md

@@ -26,6 +26,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **H2C Firmware Update Downloads Wrong Firmware** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — H2C printers were mapped to the H2D firmware API key (`h2d`), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate `h2c` API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.
 - **Sidebar Links Custom Icons Have Inverted Colors** ([#308](https://github.com/maziggy/bambuddy/issues/308)) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS `invert()` filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.
 - **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.
+- **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
 - **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
 - **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.

+ 1 - 0
README.md

@@ -166,6 +166,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Archive mode, Review mode, Queue mode, or Proxy mode
 - SSDP discovery (same LAN) or manual IP entry (VPN/remote)
+- Network interface override for multi-NIC/Docker/VPN setups
 - Secure TLS/MQTT/FTP communication
 
 ### 🛠️ Maintenance & Support

+ 8 - 2
backend/app/services/virtual_printer/manager.py

@@ -249,7 +249,8 @@ class VirtualPrinterManager:
         needs_restart = (
             model_changed
             or mode_changed
-            or (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
+            or remote_iface_changed
+            or (mode == "proxy" and (target_changed or serial_changed))
         )
 
         if enabled and not self._enabled:
@@ -392,8 +393,12 @@ class VirtualPrinterManager:
         self._cert_service.serial = current_serial
 
         # Regenerate printer cert if serial changed (CA is preserved)
+        # Include remote interface IP in SAN so slicer TLS succeeds on that interface
+        additional_ips = []
+        if self._remote_interface_ip:
+            additional_ips.append(self._remote_interface_ip)
         self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates()
+        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
         logger.info("Generated certificate for serial: %s", current_serial)
 
         # Create directories
@@ -405,6 +410,7 @@ class VirtualPrinterManager:
             name=self.PRINTER_NAME,
             serial=self.printer_serial,
             model=self._model,
+            advertise_ip=self._remote_interface_ip,
         )
 
         self._ftp = VirtualPrinterFTPServer(

+ 3 - 1
backend/app/services/virtual_printer/ssdp_server.py

@@ -33,6 +33,7 @@ class VirtualPrinterSSDPServer:
         name: str = "Bambuddy",
         serial: str = "00M09A391800001",  # X1C serial format for compatibility
         model: str = "BL-P001",  # X1C model code for best compatibility
+        advertise_ip: str = "",
     ):
         """Initialize the SSDP server.
 
@@ -40,13 +41,14 @@ class VirtualPrinterSSDPServer:
             name: Display name shown in slicer discovery
             serial: Unique serial number for this virtual printer (must match cert CN)
             model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
+            advertise_ip: Override IP to advertise instead of auto-detecting
         """
         self.name = name
         self.serial = serial
         self.model = model
         self._running = False
         self._socket: socket.socket | None = None
-        self._local_ip: str | None = None
+        self._local_ip: str | None = advertise_ip or None
 
     def _get_local_ip(self) -> str:
         """Get the local IP address to advertise."""

+ 281 - 1
backend/tests/unit/services/test_virtual_printer.py

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

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

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

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

@@ -42,11 +42,11 @@ export function VirtualPrinterSettings() {
     queryFn: api.getPrinters,
   });
 
-  // Fetch network interfaces for SSDP proxy (only in proxy mode)
+  // Fetch network interfaces for IP override (all modes when enabled)
   const { data: networkInterfaces } = useQuery({
     queryKey: ['network-interfaces'],
     queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
-    enabled: localMode === 'proxy',
+    enabled: localEnabled,
   });
 
   // Initialize local state from settings
@@ -365,8 +365,8 @@ export function VirtualPrinterSettings() {
             </div>
           )}
 
-          {/* Remote Interface - only for proxy mode (SSDP proxy) */}
-          {localMode === 'proxy' && (
+          {/* Remote Interface - available for all modes (IP override / SSDP proxy) */}
+          {localEnabled && (
             <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">

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

@@ -2543,11 +2543,11 @@ export default {
       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.',
+      title: 'Netzwerkschnittstelle überschreiben',
+      configured: 'Schnittstellenüberschreibung aktiv',
+      optional: 'Optional - verwenden wenn die automatisch erkannte IP falsch ist (z.B. mehrere NICs, Docker, VPN)',
+      placeholder: 'Automatisch erkennen (Standard)...',
+      hint: 'Überschreibt die per SSDP beworbene und im TLS-Zertifikat verwendete IP-Adresse. Nützlich wenn Bambuddy mehrere Netzwerkschnittstellen hat.',
     },
     mode: {
       title: 'Modus',

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

@@ -2543,11 +2543,11 @@ export default {
       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.',
+      title: 'Network Interface Override',
+      configured: 'Interface override active',
+      optional: 'Optional - use if auto-detected IP is wrong (e.g. multiple NICs, Docker, VPN)',
+      placeholder: 'Auto-detect (default)...',
+      hint: 'Override the IP address advertised via SSDP and used in the TLS certificate. Useful when Bambuddy has multiple network interfaces.',
     },
     mode: {
       title: 'Mode',

+ 5 - 5
frontend/src/i18n/locales/it.ts

@@ -2543,11 +2543,11 @@ export default {
       noPrinters: 'Nessuna stampante configurata. Aggiungi una stampante per usare la modalita proxy.',
     },
     remoteInterface: {
-      title: 'Interfaccia rete slicer',
-      configured: 'Proxy SSDP abilitato',
-      optional: 'Opzionale - per discovery SSDP tra reti',
-      placeholder: 'Seleziona interfaccia per rete slicer...',
-      hint: 'Seleziona l\'interfaccia di rete connessa allo slicer. Abilita scoperta automatica in Bambu Studio.',
+      title: 'Sovrascrittura interfaccia di rete',
+      configured: 'Sovrascrittura interfaccia attiva',
+      optional: 'Opzionale - usare se l\'IP rilevato automaticamente e sbagliato (es. piu NIC, Docker, VPN)',
+      placeholder: 'Rilevamento automatico (predefinito)...',
+      hint: 'Sovrascrive l\'indirizzo IP pubblicizzato via SSDP e usato nel certificato TLS. Utile quando Bambuddy ha piu interfacce di rete.',
     },
     mode: {
       title: 'Modalita',

+ 5 - 5
frontend/src/i18n/locales/ja.ts

@@ -2413,11 +2413,11 @@ export default {
       title: '仮想プリンターを有効化',
     },
     remoteInterface: {
-      optional: 'オプション',
-      title: 'スライサーネットワークインターフェース',
-      configured: 'SSDPプロキシ有効',
-      placeholder: 'スライサーネットワーク用インターフェースを選択...',
-      hint: 'スライサーに接続されたネットワークインターフェースを選択。Bambu Studioでの自動プリンター検出を有効にします。',
+      optional: 'オプション — 自動検出IPが間違っている場合に使用(複数NIC、Docker、VPNなど)',
+      title: 'ネットワークインターフェース上書き',
+      configured: 'インターフェース上書き有効',
+      placeholder: '自動検出(デフォルト)...',
+      hint: 'SSDPで広告され、TLS証明書に使用されるIPアドレスを上書きします。Bambuddyに複数のネットワークインターフェースがある場合に便利です。',
     },
     howItWorks: {
       step5: '設定したアクセスコードで接続する',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BJkEGpKa.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-CbUXXMeN.js"></script>
+    <script type="module" crossorigin src="/assets/index-BJkEGpKa.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Csb7GscS.css">
   </head>
   <body>

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