mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 14:41:24 +02:00
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:
+2
-2
@@ -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/
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user