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
This commit is contained in:
maziggy
2026-02-02 15:55:55 +01:00
parent ef64687080
commit 38d99143c7
13 changed files with 1514 additions and 570 deletions
+2 -2
View File
@@ -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/
+60 -14
View File
@@ -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"},
)
# 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"},
)
# 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"},
)
# 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
View File
@@ -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
View File
@@ -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()
@@ -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
View File
@@ -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',
+268 -117
View File
@@ -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"
>
{showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{/* 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>
<Button
onClick={handleAccessCodeChange}
disabled={!localAccessCode || pendingAction === 'accessCode'}
variant="primary"
>
{pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
</Button>
<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"
>
{pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
</Button>
</div>
<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>
{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">Model</div>
<div className="text-white">{status.model_name || status.model}</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>
<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>
</div>
)}
</CardContent>
</Card>
)}
+91
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>