mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
WIP: multi virtual printer support
This commit is contained in:
@@ -520,10 +520,10 @@ async def restore_backup(
|
||||
async def get_network_interfaces(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get available network interfaces for SSDP proxy configuration."""
|
||||
from backend.app.services.network_utils import get_network_interfaces
|
||||
"""Get available network interfaces with all IPs (primary + aliases)."""
|
||||
from backend.app.services.network_utils import get_all_interface_ips
|
||||
|
||||
interfaces = get_network_interfaces()
|
||||
interfaces = get_all_interface_ips()
|
||||
return {"interfaces": interfaces}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
|
||||
|
||||
|
||||
class VirtualPrinterCreate(BaseModel):
|
||||
name: str = "Bambuddy"
|
||||
enabled: bool = False
|
||||
mode: str = "immediate"
|
||||
model: str | None = None
|
||||
access_code: str | None = None
|
||||
target_printer_id: int | None = None
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
||||
|
||||
class VirtualPrinterUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
mode: str | None = None
|
||||
model: str | None = None
|
||||
access_code: str | None = None
|
||||
target_printer_id: int | None = None
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
||||
|
||||
def _vp_to_dict(vp, status: dict | None = None) -> dict:
|
||||
"""Convert VirtualPrinter model to response dict."""
|
||||
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
|
||||
from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
|
||||
|
||||
model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
|
||||
serial = _get_serial_for_model(model_code, vp.serial_suffix)
|
||||
|
||||
return {
|
||||
"id": vp.id,
|
||||
"name": vp.name,
|
||||
"enabled": vp.enabled,
|
||||
"mode": vp.mode,
|
||||
"model": model_code,
|
||||
"model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
|
||||
"access_code_set": bool(vp.access_code),
|
||||
"serial": serial,
|
||||
"target_printer_id": vp.target_printer_id,
|
||||
"bind_ip": vp.bind_ip,
|
||||
"remote_interface_ip": vp.remote_interface_ip,
|
||||
"position": vp.position,
|
||||
"status": status or {"running": False, "pending_files": 0},
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_virtual_printers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""List all virtual printers with status."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
|
||||
vps = result.scalars().all()
|
||||
|
||||
printers = []
|
||||
for vp in vps:
|
||||
instance = virtual_printer_manager.get_instance(vp.id)
|
||||
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
||||
printers.append(_vp_to_dict(vp, status))
|
||||
|
||||
return {
|
||||
"printers": printers,
|
||||
"models": VIRTUAL_PRINTER_MODELS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_virtual_printer(
|
||||
body: VirtualPrinterCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Create a new virtual printer."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
||||
from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
|
||||
|
||||
# Validate mode
|
||||
if body.mode not in ("immediate", "review", "print_queue", "proxy"):
|
||||
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
|
||||
|
||||
# Validate model
|
||||
if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
|
||||
)
|
||||
|
||||
# Validate access code length
|
||||
if body.access_code and len(body.access_code) != 8:
|
||||
return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
|
||||
|
||||
# Validation when enabling
|
||||
if body.enabled:
|
||||
if not body.bind_ip:
|
||||
return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
|
||||
if body.mode == "proxy":
|
||||
if not body.target_printer_id:
|
||||
return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
|
||||
else:
|
||||
if not body.access_code:
|
||||
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
|
||||
|
||||
# Validate proxy target printer exists
|
||||
if body.target_printer_id:
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
|
||||
if not result.scalar_one_or_none():
|
||||
return JSONResponse(
|
||||
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
|
||||
)
|
||||
|
||||
# Validate bind_ip uniqueness (against all enabled VPs)
|
||||
if body.bind_ip:
|
||||
result = await db.execute(
|
||||
select(VirtualPrinter).where(
|
||||
VirtualPrinter.bind_ip == body.bind_ip,
|
||||
VirtualPrinter.enabled == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
|
||||
|
||||
# Generate next serial suffix
|
||||
result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
|
||||
last_suffix = result.scalar()
|
||||
if last_suffix:
|
||||
try:
|
||||
next_num = int(last_suffix) + 1
|
||||
new_suffix = str(next_num).zfill(9)
|
||||
except ValueError:
|
||||
new_suffix = "391800002"
|
||||
else:
|
||||
new_suffix = "391800001"
|
||||
|
||||
# Get next position
|
||||
result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
|
||||
last_pos = result.scalar()
|
||||
next_pos = (last_pos or 0) + 1
|
||||
|
||||
vp = VirtualPrinter(
|
||||
name=body.name,
|
||||
enabled=body.enabled,
|
||||
mode=body.mode,
|
||||
model=body.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
|
||||
access_code=body.access_code,
|
||||
target_printer_id=body.target_printer_id,
|
||||
bind_ip=body.bind_ip,
|
||||
remote_interface_ip=body.remote_interface_ip,
|
||||
serial_suffix=new_suffix,
|
||||
position=next_pos,
|
||||
)
|
||||
db.add(vp)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
|
||||
logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
|
||||
|
||||
# Sync services if enabled
|
||||
if body.enabled:
|
||||
try:
|
||||
await virtual_printer_manager.sync_from_db()
|
||||
except Exception as e:
|
||||
logger.error("Failed to start virtual printer after create: %s", e)
|
||||
|
||||
return _vp_to_dict(vp)
|
||||
|
||||
|
||||
@router.get("/{vp_id}")
|
||||
async def get_virtual_printer(
|
||||
vp_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get a single virtual printer with status."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
|
||||
instance = virtual_printer_manager.get_instance(vp.id)
|
||||
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
||||
|
||||
return _vp_to_dict(vp, status)
|
||||
|
||||
|
||||
@router.put("/{vp_id}")
|
||||
async def update_virtual_printer(
|
||||
vp_id: int,
|
||||
body: VirtualPrinterUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update a virtual printer."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
|
||||
logger.debug(
|
||||
"Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
|
||||
vp_id,
|
||||
body.model_dump(exclude_unset=True),
|
||||
vp.mode,
|
||||
vp.enabled,
|
||||
bool(vp.access_code),
|
||||
vp.bind_ip,
|
||||
vp.target_printer_id,
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
if body.name is not None:
|
||||
vp.name = body.name
|
||||
if body.mode is not None:
|
||||
if body.mode not in ("immediate", "review", "print_queue", "proxy"):
|
||||
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
|
||||
vp.mode = body.mode
|
||||
if body.model is not None:
|
||||
if body.model not in VIRTUAL_PRINTER_MODELS:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
|
||||
)
|
||||
vp.model = body.model
|
||||
if body.access_code is not None:
|
||||
if body.access_code and len(body.access_code) != 8:
|
||||
return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
|
||||
vp.access_code = body.access_code
|
||||
if body.target_printer_id is not None:
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
|
||||
if not result.scalar_one_or_none():
|
||||
return JSONResponse(
|
||||
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
|
||||
)
|
||||
vp.target_printer_id = body.target_printer_id
|
||||
if body.bind_ip is not None:
|
||||
vp.bind_ip = body.bind_ip
|
||||
if body.remote_interface_ip is not None:
|
||||
vp.remote_interface_ip = body.remote_interface_ip
|
||||
|
||||
# Determine final enabled state
|
||||
explicitly_enabling = body.enabled is True
|
||||
new_enabled = body.enabled if body.enabled is not None else vp.enabled
|
||||
effective_mode = vp.mode
|
||||
|
||||
if explicitly_enabling:
|
||||
# User is explicitly toggling on — enforce all requirements
|
||||
if not vp.bind_ip:
|
||||
logger.warning("Update VP %d rejected: no bind_ip", vp_id)
|
||||
return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
|
||||
# Validate bind_ip uniqueness (against all enabled VPs)
|
||||
existing = await db.execute(
|
||||
select(VirtualPrinter).where(
|
||||
VirtualPrinter.bind_ip == vp.bind_ip,
|
||||
VirtualPrinter.id != vp_id,
|
||||
VirtualPrinter.enabled == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
conflict = existing.scalar_one_or_none()
|
||||
if conflict:
|
||||
logger.warning(
|
||||
"Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
|
||||
vp_id,
|
||||
vp.bind_ip,
|
||||
conflict.id,
|
||||
conflict.enabled,
|
||||
conflict.mode,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
|
||||
)
|
||||
if effective_mode == "proxy":
|
||||
if not vp.target_printer_id:
|
||||
logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
|
||||
return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
|
||||
else:
|
||||
if not vp.access_code:
|
||||
logger.warning(
|
||||
"Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
|
||||
)
|
||||
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
|
||||
elif new_enabled and body.enabled is None:
|
||||
# VP is already enabled and user is changing other fields —
|
||||
# auto-disable if new state doesn't meet requirements
|
||||
if not vp.bind_ip:
|
||||
new_enabled = False
|
||||
elif effective_mode == "proxy":
|
||||
if not vp.target_printer_id:
|
||||
new_enabled = False
|
||||
else:
|
||||
if not vp.access_code:
|
||||
new_enabled = False
|
||||
|
||||
vp.enabled = new_enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
|
||||
logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
|
||||
|
||||
# Sync services
|
||||
try:
|
||||
await virtual_printer_manager.sync_from_db()
|
||||
except Exception as e:
|
||||
logger.error("Failed to sync virtual printers after update: %s", e)
|
||||
|
||||
instance = virtual_printer_manager.get_instance(vp.id)
|
||||
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
||||
|
||||
return _vp_to_dict(vp, status)
|
||||
|
||||
|
||||
@router.delete("/{vp_id}")
|
||||
async def delete_virtual_printer(
|
||||
vp_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Delete a virtual printer."""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
|
||||
vp_name = vp.name
|
||||
|
||||
# Stop instance if running
|
||||
await virtual_printer_manager.remove_instance(vp_id)
|
||||
|
||||
# Delete from DB
|
||||
await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
|
||||
|
||||
# Resync remaining services
|
||||
try:
|
||||
await virtual_printer_manager.sync_from_db()
|
||||
except Exception as e:
|
||||
logger.error("Failed to sync virtual printers after delete: %s", e)
|
||||
|
||||
return {"detail": "Deleted", "id": vp_id}
|
||||
@@ -103,6 +103,7 @@ async def init_db():
|
||||
spool_k_profile,
|
||||
spool_usage_history,
|
||||
user,
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
@@ -1216,6 +1217,70 @@ async def run_migrations(conn):
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Migrate single virtual printer key-value settings to virtual_printers table
|
||||
try:
|
||||
# Check if virtual_printers table has any rows
|
||||
result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
|
||||
count = result.scalar() or 0
|
||||
|
||||
if count == 0:
|
||||
# Check if old key-value settings exist
|
||||
result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
# Old settings exist — migrate to first virtual printer row
|
||||
old_enabled = row[0] == "true" if row[0] else False
|
||||
|
||||
result = await conn.execute(
|
||||
text("SELECT value FROM settings WHERE key = 'virtual_printer_access_code'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
old_access_code = row[0] if row else None
|
||||
|
||||
result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
|
||||
row = result.fetchone()
|
||||
old_mode = row[0] if row else "immediate"
|
||||
if old_mode == "queue":
|
||||
old_mode = "review"
|
||||
|
||||
result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
|
||||
row = result.fetchone()
|
||||
old_model = row[0] if row else "3DPrinter-X1-Carbon"
|
||||
|
||||
result = await conn.execute(
|
||||
text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
old_target_id = int(row[0]) if row and row[0] else None
|
||||
|
||||
result = await conn.execute(
|
||||
text("SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
old_remote_iface = row[0] if row else None
|
||||
|
||||
await conn.execute(
|
||||
text("""
|
||||
INSERT INTO virtual_printers
|
||||
(name, enabled, mode, model, access_code, target_printer_id,
|
||||
bind_ip, remote_interface_ip, serial_suffix, position)
|
||||
VALUES
|
||||
(:name, :enabled, :mode, :model, :access_code, :target_id,
|
||||
NULL, :remote_iface, '391800001', 0)
|
||||
"""),
|
||||
{
|
||||
"name": "Bambuddy",
|
||||
"enabled": old_enabled,
|
||||
"mode": old_mode or "immediate",
|
||||
"model": old_model,
|
||||
"access_code": old_access_code,
|
||||
"target_id": old_target_id,
|
||||
"remote_iface": old_remote_iface,
|
||||
},
|
||||
)
|
||||
except OperationalError:
|
||||
pass # Table may not exist yet on first run
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
+10
-49
@@ -205,6 +205,7 @@ from backend.app.api.routes import (
|
||||
system,
|
||||
updates,
|
||||
users,
|
||||
virtual_printers,
|
||||
webhook,
|
||||
websocket,
|
||||
)
|
||||
@@ -3237,55 +3238,15 @@ async def lifespan(app: FastAPI):
|
||||
# Start printer runtime tracking
|
||||
start_runtime_tracking()
|
||||
|
||||
# Initialize virtual printer manager
|
||||
# Initialize virtual printer manager and sync from DB
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
virtual_printer_manager.set_session_factory(async_session)
|
||||
|
||||
# Auto-start virtual printer if enabled
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
vp_enabled = await get_setting(db, "virtual_printer_enabled")
|
||||
if vp_enabled and vp_enabled.lower() == "true":
|
||||
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")
|
||||
vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
|
||||
|
||||
# Look up printer IP and serial if in proxy mode
|
||||
vp_target_ip = ""
|
||||
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,
|
||||
remote_interface_ip=vp_remote_iface,
|
||||
)
|
||||
if vp_mode == "proxy":
|
||||
logging.info("Virtual printer proxy started (target=%s)", vp_target_ip)
|
||||
else:
|
||||
logging.info("Virtual printer started (model=%s)", vp_model or "default")
|
||||
except Exception as e:
|
||||
logging.warning("Failed to start virtual printer: %s", e)
|
||||
try:
|
||||
await virtual_printer_manager.sync_from_db()
|
||||
logging.info("Virtual printer manager synced from database")
|
||||
except Exception as e:
|
||||
logging.warning("Failed to sync virtual printers: %s", e)
|
||||
|
||||
yield
|
||||
|
||||
@@ -3299,9 +3260,8 @@ async def lifespan(app: FastAPI):
|
||||
printer_manager.disconnect_all()
|
||||
await close_spoolman_client()
|
||||
|
||||
# Stop virtual printer if running
|
||||
if virtual_printer_manager.is_enabled:
|
||||
await virtual_printer_manager.configure(enabled=False)
|
||||
# Stop all virtual printer services
|
||||
await virtual_printer_manager.stop_all()
|
||||
|
||||
await mqtt_smart_plug_service.disconnect(timeout=2)
|
||||
|
||||
@@ -3494,6 +3454,7 @@ app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(firmware.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(github_backup.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(metrics.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
# Serve static files (React build)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class VirtualPrinter(Base):
|
||||
"""Virtual printer configuration for multi-instance support."""
|
||||
|
||||
__tablename__ = "virtual_printers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
mode: Mapped[str] = mapped_column(String(20), default="immediate") # immediate|review|print_queue|proxy
|
||||
model: Mapped[str | None] = mapped_column(String(50), nullable=True) # SSDP model code (server mode)
|
||||
access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode)
|
||||
target_printer_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
|
||||
) # proxy mode
|
||||
bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) # dedicated IP (proxy mode)
|
||||
remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) # SSDP advertise IP
|
||||
serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001") # unique per printer
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -1,15 +1,26 @@
|
||||
"""Network utility functions for interface detection."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Interfaces to exclude from selection
|
||||
EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
|
||||
|
||||
# Resolve full path to `ip` command (may not be in PATH for service users)
|
||||
_IP_CMD: str | None = shutil.which("ip") or shutil.which("ip", path="/usr/sbin:/sbin:/usr/bin:/bin")
|
||||
|
||||
|
||||
def _is_excluded(name: str) -> bool:
|
||||
"""Check if an interface name should be excluded."""
|
||||
return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
|
||||
|
||||
|
||||
def get_network_interfaces() -> list[dict]:
|
||||
"""Get all network interfaces with their IPs and subnets.
|
||||
@@ -26,7 +37,7 @@ def get_network_interfaces() -> list[dict]:
|
||||
name = iface[1]
|
||||
|
||||
# Skip excluded interfaces
|
||||
if any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES):
|
||||
if _is_excluded(name):
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -76,6 +87,88 @@ def get_network_interfaces() -> list[dict]:
|
||||
return interfaces
|
||||
|
||||
|
||||
def get_all_interface_ips() -> list[dict]:
|
||||
"""Get all IPs (primary + aliases) for all non-excluded interfaces.
|
||||
|
||||
Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
|
||||
Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
|
||||
|
||||
Returns:
|
||||
List of dicts with name, ip, netmask, subnet, is_alias, label
|
||||
"""
|
||||
if not _IP_CMD:
|
||||
logger.debug("ip command not found, using ioctl fallback")
|
||||
return _fallback_get_all_ips()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_IP_CMD, "-j", "addr", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("ip addr show failed: %s", result.stderr)
|
||||
return _fallback_get_all_ips()
|
||||
|
||||
interfaces_data = json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
|
||||
logger.warning("Failed to run ip -j addr show: %s", e)
|
||||
return _fallback_get_all_ips()
|
||||
|
||||
entries = []
|
||||
for iface in interfaces_data:
|
||||
ifname = iface.get("ifname", "")
|
||||
if _is_excluded(ifname):
|
||||
continue
|
||||
|
||||
ipv4_count = 0
|
||||
for addr_info in iface.get("addr_info", []):
|
||||
if addr_info.get("family") != "inet":
|
||||
continue
|
||||
|
||||
ip = addr_info.get("local", "")
|
||||
prefix = addr_info.get("prefixlen", 24)
|
||||
label = addr_info.get("label", ifname)
|
||||
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
|
||||
netmask = str(network.netmask)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
|
||||
is_alias = ":" in label or ipv4_count > 0
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"name": ifname,
|
||||
"ip": ip,
|
||||
"netmask": netmask,
|
||||
"subnet": str(network),
|
||||
"is_alias": is_alias,
|
||||
"label": label,
|
||||
}
|
||||
)
|
||||
ipv4_count += 1
|
||||
|
||||
# Sort: primary IPs first per interface, then by interface name
|
||||
entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
|
||||
return entries
|
||||
|
||||
|
||||
def _fallback_get_all_ips() -> list[dict]:
|
||||
"""Fallback: wrap get_network_interfaces() result with alias fields."""
|
||||
return [
|
||||
{
|
||||
**iface,
|
||||
"is_alias": False,
|
||||
"label": iface["name"],
|
||||
}
|
||||
for iface in get_network_interfaces()
|
||||
]
|
||||
|
||||
|
||||
def find_interface_for_ip(target_ip: str) -> dict | None:
|
||||
"""Find which interface is on the same subnet as the target IP.
|
||||
|
||||
@@ -91,9 +184,11 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
|
||||
logger.error("Invalid target IP: %s", target_ip)
|
||||
return None
|
||||
|
||||
interfaces = get_network_interfaces()
|
||||
interfaces = get_all_interface_ips()
|
||||
|
||||
for iface in interfaces:
|
||||
if iface.get("is_alias"):
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.IPv4Network(iface["subnet"], strict=False)
|
||||
if target in network:
|
||||
|
||||
@@ -39,11 +39,13 @@ class BindServer:
|
||||
model: str,
|
||||
name: str,
|
||||
version: str = "01.00.00.00",
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
self.serial = serial
|
||||
self.model = model
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.bind_address = bind_address
|
||||
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
@@ -59,7 +61,7 @@ class BindServer:
|
||||
self._running = True
|
||||
self._server = await asyncio.start_server(
|
||||
self._handle_client,
|
||||
"0.0.0.0", # nosec B104
|
||||
self.bind_address,
|
||||
BIND_PORT,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,17 +48,20 @@ class CertificateService:
|
||||
- Printer cert with CN=serial_number, signed by the CA
|
||||
"""
|
||||
|
||||
def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL):
|
||||
def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL, shared_ca_dir: Path | None = None):
|
||||
"""Initialize the certificate service.
|
||||
|
||||
Args:
|
||||
cert_dir: Directory to store certificates
|
||||
cert_dir: Directory to store per-instance certificates
|
||||
serial: Serial number to use as CN in printer certificate
|
||||
shared_ca_dir: If set, CA cert/key are read from this directory
|
||||
instead of cert_dir (for multi-instance shared CA)
|
||||
"""
|
||||
self.cert_dir = cert_dir
|
||||
self.serial = serial
|
||||
self.ca_cert_path = cert_dir / "bbl_ca.crt"
|
||||
self.ca_key_path = cert_dir / "bbl_ca.key"
|
||||
ca_dir = shared_ca_dir or cert_dir
|
||||
self.ca_cert_path = ca_dir / "bbl_ca.crt"
|
||||
self.ca_key_path = ca_dir / "bbl_ca.key"
|
||||
self.cert_path = cert_dir / "virtual_printer.crt"
|
||||
self.key_path = cert_dir / "virtual_printer.key"
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class FTPSession:
|
||||
on_file_received: Callable[[Path, str], None] | None,
|
||||
passive_port_range: tuple[int, int] = (50000, 50100),
|
||||
pasv_address: str = "",
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
@@ -43,6 +44,7 @@ class FTPSession:
|
||||
self.on_file_received = on_file_received
|
||||
self.passive_port_range = passive_port_range
|
||||
self.pasv_address = pasv_address
|
||||
self.bind_address = bind_address
|
||||
|
||||
self.authenticated = False
|
||||
self.username: str | None = None
|
||||
@@ -218,7 +220,7 @@ class FTPSession:
|
||||
try:
|
||||
self.data_server = await asyncio.start_server(
|
||||
self._handle_data_connection,
|
||||
"0.0.0.0", # nosec B104
|
||||
self.bind_address,
|
||||
port,
|
||||
ssl=self.ssl_context,
|
||||
)
|
||||
@@ -524,6 +526,7 @@ class VirtualPrinterFTPServer:
|
||||
key_path: Path,
|
||||
port: int = FTP_PORT,
|
||||
on_file_received: Callable[[Path, str], None] | None = None,
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
"""Initialize the FTPS server.
|
||||
|
||||
@@ -534,6 +537,7 @@ class VirtualPrinterFTPServer:
|
||||
key_path: Path to TLS private key file
|
||||
port: Port to listen on (default 990)
|
||||
on_file_received: Callback when file upload completes (path, source_ip)
|
||||
bind_address: IP address to bind to (default 0.0.0.0)
|
||||
"""
|
||||
self.upload_dir = upload_dir
|
||||
self.access_code = access_code
|
||||
@@ -541,6 +545,7 @@ class VirtualPrinterFTPServer:
|
||||
self.key_path = key_path
|
||||
self.port = port
|
||||
self.on_file_received = on_file_received
|
||||
self.bind_address = bind_address
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
self._ssl_context: ssl.SSLContext | None = None
|
||||
@@ -575,7 +580,7 @@ class VirtualPrinterFTPServer:
|
||||
# Create server with SSL - TLS handshake happens before any FTP data
|
||||
self._server = await asyncio.start_server(
|
||||
self._handle_client,
|
||||
"0.0.0.0", # nosec B104
|
||||
self.bind_address,
|
||||
self.port,
|
||||
ssl=self._ssl_context, # This makes it implicit FTPS!
|
||||
)
|
||||
@@ -619,6 +624,7 @@ class VirtualPrinterFTPServer:
|
||||
on_file_received=self.on_file_received,
|
||||
passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
|
||||
pasv_address=self._pasv_address,
|
||||
bind_address=self.bind_address,
|
||||
)
|
||||
|
||||
# Track the session task so we can cancel it on stop
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,21 @@ logger = logging.getLogger(__name__)
|
||||
# Default MQTT port for Bambu printers (MQTT over TLS)
|
||||
MQTT_PORT = 8883
|
||||
|
||||
# Model code → product_name for version response (must match what slicer expects)
|
||||
MODEL_PRODUCT_NAMES = {
|
||||
"3DPrinter-X1-Carbon": "X1 Carbon",
|
||||
"3DPrinter-X1": "X1",
|
||||
"C13": "X1E",
|
||||
"C11": "P1P",
|
||||
"C12": "P1S",
|
||||
"N7": "P2S",
|
||||
"N2S": "A1",
|
||||
"N1": "A1 mini",
|
||||
"O1D": "H2D",
|
||||
"O1C": "H2C",
|
||||
"O1S": "H2S",
|
||||
}
|
||||
|
||||
|
||||
class VirtualPrinterMQTTServer:
|
||||
"""MQTT broker that accepts connections from slicers.
|
||||
@@ -168,13 +183,17 @@ class SimpleMQTTServer:
|
||||
key_path: Path,
|
||||
port: int = MQTT_PORT,
|
||||
on_print_command: Callable[[str, dict], None] | None = None,
|
||||
model: str = "",
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
self.serial = serial
|
||||
self.access_code = access_code
|
||||
self.model = model
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
self.port = port
|
||||
self.on_print_command = on_print_command
|
||||
self.bind_address = bind_address
|
||||
self._running = False
|
||||
self._server = None
|
||||
self._clients: dict[str, asyncio.StreamWriter] = {}
|
||||
@@ -255,7 +274,7 @@ class SimpleMQTTServer:
|
||||
|
||||
self._server = await asyncio.start_server(
|
||||
connection_handler,
|
||||
"0.0.0.0", # nosec B104
|
||||
self.bind_address,
|
||||
self.port,
|
||||
ssl=ssl_context,
|
||||
)
|
||||
@@ -594,7 +613,7 @@ class SimpleMQTTServer:
|
||||
}
|
||||
}
|
||||
|
||||
await self._publish_to_report(writer, status)
|
||||
await self._publish_to_report(writer, status, self.serial)
|
||||
|
||||
except OSError as e:
|
||||
logger.error("Failed to send status report: %s", e)
|
||||
@@ -602,6 +621,9 @@ class SimpleMQTTServer:
|
||||
async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
|
||||
"""Send version info response to the slicer."""
|
||||
try:
|
||||
product_name = MODEL_PRODUCT_NAMES.get(self.model, self.model or "X1 Carbon")
|
||||
serial = self.serial
|
||||
|
||||
# Build version response matching OrcaSlicer expectations
|
||||
# Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
|
||||
version_info = {
|
||||
@@ -611,55 +633,55 @@ class SimpleMQTTServer:
|
||||
"module": [
|
||||
{
|
||||
"name": "ota",
|
||||
"product_name": "X1 Carbon",
|
||||
"product_name": product_name,
|
||||
"sw_ver": "01.07.00.00",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "OTA",
|
||||
"sn": self.serial,
|
||||
"sn": serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "esp32",
|
||||
"product_name": "X1 Carbon",
|
||||
"product_name": product_name,
|
||||
"sw_ver": "01.07.22.25",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "AP05",
|
||||
"sn": self.serial,
|
||||
"sn": serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "rv1126",
|
||||
"product_name": "X1 Carbon",
|
||||
"product_name": product_name,
|
||||
"sw_ver": "00.00.27.38",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "AP05",
|
||||
"sn": self.serial,
|
||||
"sn": serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "th",
|
||||
"product_name": "X1 Carbon",
|
||||
"product_name": product_name,
|
||||
"sw_ver": "00.00.04.00",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "TH07",
|
||||
"sn": self.serial,
|
||||
"sn": serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "mc",
|
||||
"product_name": "X1 Carbon",
|
||||
"product_name": product_name,
|
||||
"sw_ver": "00.00.10.00",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "MC07",
|
||||
"sn": self.serial,
|
||||
"sn": serial,
|
||||
"flag": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
await self._publish_to_report(writer, version_info)
|
||||
logger.info("Sent version response")
|
||||
await self._publish_to_report(writer, version_info, serial)
|
||||
logger.info("Sent version response (product_name=%s)", product_name)
|
||||
|
||||
except OSError as e:
|
||||
logger.error("Failed to send version response: %s", e)
|
||||
@@ -673,9 +695,9 @@ class SimpleMQTTServer:
|
||||
self._current_file = filename
|
||||
self._prepare_percent = prepare_percent
|
||||
|
||||
async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None:
|
||||
async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = "") -> None:
|
||||
"""Publish a message on the device report topic."""
|
||||
topic = f"device/{self.serial}/report"
|
||||
topic = f"device/{serial or self.serial}/report"
|
||||
message = json.dumps(payload)
|
||||
|
||||
topic_bytes = topic.encode("utf-8")
|
||||
|
||||
@@ -34,21 +34,24 @@ class VirtualPrinterSSDPServer:
|
||||
serial: str = "00M09A391800001", # X1C serial format for compatibility
|
||||
model: str = "BL-P001", # X1C model code for best compatibility
|
||||
advertise_ip: str = "",
|
||||
bind_ip: str = "",
|
||||
):
|
||||
"""Initialize the SSDP server.
|
||||
|
||||
Args:
|
||||
name: Display name shown in slicer discovery
|
||||
serial: Unique serial number for this virtual printer (must match cert CN)
|
||||
model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
|
||||
serial: Unique serial number
|
||||
model: Model code
|
||||
advertise_ip: Override IP to advertise instead of auto-detecting
|
||||
bind_ip: IP address to bind the SSDP socket to
|
||||
"""
|
||||
self.name = name
|
||||
self.serial = serial
|
||||
self.model = model
|
||||
self._bind_ip = bind_ip
|
||||
self._running = False
|
||||
self._socket: socket.socket | None = None
|
||||
self._local_ip: str | None = advertise_ip or None
|
||||
self._local_ip: str | None = advertise_ip or bind_ip or None
|
||||
|
||||
def _get_local_ip(self) -> str:
|
||||
"""Get the local IP address to advertise."""
|
||||
@@ -67,14 +70,8 @@ class VirtualPrinterSSDPServer:
|
||||
return "127.0.0.1"
|
||||
|
||||
def _build_notify_message(self) -> bytes:
|
||||
"""Build SSDP NOTIFY message for periodic announcements.
|
||||
|
||||
Format matches real Bambu printer SSDP broadcasts observed on the network.
|
||||
Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
|
||||
"""
|
||||
"""Build SSDP NOTIFY message for periodic announcements."""
|
||||
ip = self._get_local_ip()
|
||||
# Match exact format of real Bambu printers (captured via tcpdump)
|
||||
# Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
|
||||
message = (
|
||||
"NOTIFY * HTTP/1.1\r\n"
|
||||
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
|
||||
@@ -98,13 +95,8 @@ class VirtualPrinterSSDPServer:
|
||||
return message.encode()
|
||||
|
||||
def _build_response_message(self) -> bytes:
|
||||
"""Build SSDP response message for M-SEARCH requests.
|
||||
|
||||
Format matches real Bambu printer SSDP responses.
|
||||
"""
|
||||
"""Build SSDP response message for M-SEARCH requests."""
|
||||
ip = self._get_local_ip()
|
||||
# Match format of real Bambu printers
|
||||
# Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
|
||||
message = (
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Server: UPnP/1.0\r\n"
|
||||
@@ -147,11 +139,18 @@ class VirtualPrinterSSDPServer:
|
||||
# Set non-blocking mode
|
||||
self._socket.setblocking(False)
|
||||
|
||||
# Bind to SSDP port
|
||||
self._socket.bind(("", SSDP_PORT))
|
||||
# Bind to SSDP port on specific interface (or all interfaces)
|
||||
self._socket.bind((self._bind_ip or "", SSDP_PORT))
|
||||
|
||||
# Join multicast group
|
||||
mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
|
||||
# Join multicast group (on specific interface if bind_ip is set)
|
||||
if self._bind_ip:
|
||||
mreq = struct.pack(
|
||||
"4s4s",
|
||||
socket.inet_aton(SSDP_MULTICAST_ADDR),
|
||||
socket.inet_aton(self._bind_ip),
|
||||
)
|
||||
else:
|
||||
mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
|
||||
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
|
||||
# Enable broadcast
|
||||
@@ -226,17 +225,16 @@ class VirtualPrinterSSDPServer:
|
||||
self._socket = None
|
||||
|
||||
async def _send_notify(self) -> None:
|
||||
"""Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
|
||||
"""Send SSDP NOTIFY message via broadcast."""
|
||||
if not self._socket:
|
||||
return
|
||||
|
||||
try:
|
||||
msg = self._build_notify_message()
|
||||
# Real Bambu printers broadcast to 255.255.255.255, not multicast
|
||||
self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
|
||||
logger.debug("Sent SSDP NOTIFY for %s", self.name)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to send NOTIFY: %s", e)
|
||||
logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
|
||||
|
||||
async def _send_byebye(self) -> None:
|
||||
"""Send SSDP byebye message when shutting down."""
|
||||
@@ -282,7 +280,7 @@ class VirtualPrinterSSDPServer:
|
||||
self._socket.sendto(response, addr)
|
||||
logger.info("Sent SSDP response to %s for virtual printer '%s'", addr[0], self.name)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to send SSDP response: %s", e)
|
||||
logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
|
||||
|
||||
|
||||
class SSDPProxy:
|
||||
|
||||
@@ -81,6 +81,7 @@ class TLSProxy:
|
||||
server_key_path: Path,
|
||||
on_connect: Callable[[str], None] | None = None,
|
||||
on_disconnect: Callable[[str], None] | None = None,
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
"""Initialize the TLS proxy.
|
||||
|
||||
@@ -93,6 +94,7 @@ class TLSProxy:
|
||||
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)
|
||||
bind_address: IP address to bind to (default: all interfaces)
|
||||
"""
|
||||
self.name = name
|
||||
self.listen_port = listen_port
|
||||
@@ -102,6 +104,7 @@ class TLSProxy:
|
||||
self.server_key_path = server_key_path
|
||||
self.on_connect = on_connect
|
||||
self.on_disconnect = on_disconnect
|
||||
self.bind_address = bind_address
|
||||
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
@@ -134,7 +137,7 @@ class TLSProxy:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting {self.name} TLS proxy: 0.0.0.0:{self.listen_port} → {self.target_host}:{self.target_port}"
|
||||
f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -147,7 +150,7 @@ class TLSProxy:
|
||||
# Start server with TLS
|
||||
self._server = await asyncio.start_server(
|
||||
self._handle_client,
|
||||
"0.0.0.0", # nosec B104
|
||||
self.bind_address,
|
||||
self.listen_port,
|
||||
ssl=self._server_ssl_context,
|
||||
)
|
||||
@@ -355,6 +358,7 @@ class TCPProxy:
|
||||
target_port: int,
|
||||
on_connect: Callable[[str], None] | None = None,
|
||||
on_disconnect: Callable[[str], None] | None = None,
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
self.name = name
|
||||
self.listen_port = listen_port
|
||||
@@ -362,6 +366,7 @@ class TCPProxy:
|
||||
self.target_port = target_port
|
||||
self.on_connect = on_connect
|
||||
self.on_disconnect = on_disconnect
|
||||
self.bind_address = bind_address
|
||||
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
@@ -373,8 +378,9 @@ class TCPProxy:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting %s TCP proxy: 0.0.0.0:%s → %s:%s",
|
||||
"Starting %s TCP proxy: %s:%s → %s:%s",
|
||||
self.name,
|
||||
self.bind_address,
|
||||
self.listen_port,
|
||||
self.target_host,
|
||||
self.target_port,
|
||||
@@ -385,7 +391,7 @@ class TCPProxy:
|
||||
|
||||
self._server = await asyncio.start_server(
|
||||
self._handle_client,
|
||||
"0.0.0.0", # nosec B104
|
||||
self.bind_address,
|
||||
self.listen_port,
|
||||
)
|
||||
|
||||
@@ -1053,6 +1059,7 @@ class SlicerProxyManager:
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
on_activity: Callable[[str, str], None] | None = None,
|
||||
bind_address: str = "0.0.0.0", # nosec B104
|
||||
):
|
||||
"""Initialize the slicer proxy manager.
|
||||
|
||||
@@ -1061,11 +1068,13 @@ class SlicerProxyManager:
|
||||
cert_path: Path to server certificate
|
||||
key_path: Path to server private key
|
||||
on_activity: Optional callback for activity logging (name, message)
|
||||
bind_address: IP address to bind proxy listeners to
|
||||
"""
|
||||
self.target_host = target_host
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
self.on_activity = on_activity
|
||||
self.bind_address = bind_address
|
||||
|
||||
self._ftp_proxy: TLSProxy | None = None
|
||||
self._mqtt_proxy: TLSProxy | None = None
|
||||
@@ -1100,6 +1109,7 @@ class SlicerProxyManager:
|
||||
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}"),
|
||||
bind_address=self.bind_address,
|
||||
)
|
||||
|
||||
self._mqtt_proxy = TLSProxy(
|
||||
@@ -1111,6 +1121,7 @@ class SlicerProxyManager:
|
||||
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}"),
|
||||
bind_address=self.bind_address,
|
||||
)
|
||||
|
||||
# Bind/auth proxy (port 3000) - raw TCP, no TLS
|
||||
@@ -1121,6 +1132,7 @@ class SlicerProxyManager:
|
||||
target_port=self.PRINTER_BIND_PORT,
|
||||
on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
|
||||
on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
|
||||
bind_address=self.bind_address,
|
||||
)
|
||||
|
||||
# Start as background tasks
|
||||
|
||||
@@ -4492,6 +4492,8 @@ export interface NetworkInterface {
|
||||
ip: string;
|
||||
netmask: string;
|
||||
subnet: string;
|
||||
is_alias?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface VirtualPrinterModels {
|
||||
@@ -4539,6 +4541,69 @@ export const virtualPrinterApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Multi Virtual Printer API
|
||||
export interface VirtualPrinterConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
mode: VirtualPrinterMode;
|
||||
model: string | null;
|
||||
model_name: string | null;
|
||||
access_code_set: boolean;
|
||||
serial: string;
|
||||
target_printer_id: number | null;
|
||||
bind_ip: string | null;
|
||||
remote_interface_ip: string | null;
|
||||
position: number;
|
||||
status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };
|
||||
}
|
||||
|
||||
export interface VirtualPrinterListResponse {
|
||||
printers: VirtualPrinterConfig[];
|
||||
models: Record<string, string>;
|
||||
}
|
||||
|
||||
export const multiVirtualPrinterApi = {
|
||||
list: () => request<VirtualPrinterListResponse>('/virtual-printers'),
|
||||
|
||||
get: (id: number) => request<VirtualPrinterConfig>(`/virtual-printers/${id}`),
|
||||
|
||||
create: (data: {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
mode?: string;
|
||||
model?: string;
|
||||
access_code?: string;
|
||||
target_printer_id?: number;
|
||||
bind_ip?: string;
|
||||
remote_interface_ip?: string;
|
||||
}) =>
|
||||
request<VirtualPrinterConfig>('/virtual-printers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
update: (id: number, data: {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
mode?: string;
|
||||
model?: string;
|
||||
access_code?: string;
|
||||
target_printer_id?: number;
|
||||
bind_ip?: string;
|
||||
remote_interface_ip?: string;
|
||||
}) =>
|
||||
request<VirtualPrinterConfig>(`/virtual-printers/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
remove: (id: number) =>
|
||||
request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Pending Uploads API
|
||||
export const pendingUploadsApi = {
|
||||
list: () => request<PendingUpload[]>('/pending-uploads/'),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, ChevronDown, ArrowRightLeft } from 'lucide-react';
|
||||
import { api, multiVirtualPrinterApi } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
type Mode = 'immediate' | 'review' | 'print_queue' | 'proxy';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
immediate: 'archive',
|
||||
review: 'review',
|
||||
print_queue: 'queue',
|
||||
proxy: 'proxy',
|
||||
};
|
||||
|
||||
interface VirtualPrinterAddDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [mode, setMode] = useState<Mode>('immediate');
|
||||
const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);
|
||||
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
multiVirtualPrinterApi.create({
|
||||
name: name.trim() || 'Bambuddy',
|
||||
mode,
|
||||
target_printer_id: mode === 'proxy' ? (targetPrinterId ?? undefined) : undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
|
||||
showToast(t('virtualPrinter.toast.created'));
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('virtualPrinter.toast.failedToCreate'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card
|
||||
className="w-full max-w-md"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">{t('virtualPrinter.addDialog.title')}</h3>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.addDialog.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Bambuddy"
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm placeholder-bambu-gray"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div>
|
||||
<label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.mode.title')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['immediate', 'review', 'print_queue', 'proxy'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`p-2 rounded-lg border text-left transition-colors ${
|
||||
mode === m
|
||||
? m === 'proxy'
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-white text-xs font-medium">
|
||||
{m === 'proxy' && <ArrowRightLeft className="w-3 h-3" />}
|
||||
{t(`virtualPrinter.mode.${MODE_LABELS[m]}`)}
|
||||
</div>
|
||||
<div className="text-[10px] text-bambu-gray">
|
||||
{t(`virtualPrinter.mode.${MODE_LABELS[m]}Desc`)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Printer - only for proxy mode */}
|
||||
{mode === 'proxy' && (
|
||||
<div>
|
||||
<label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.targetPrinter.title')}</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={targetPrinterId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
setTargetPrinterId(isNaN(id) ? null : id);
|
||||
}}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm appearance-none cursor-pointer pr-10"
|
||||
>
|
||||
<option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{t('virtualPrinter.addDialog.hint')}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1" disabled={createMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => createMutation.mutate()}
|
||||
className="flex-1"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
t('virtualPrinter.addDialog.create')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
|
||||
ChevronDown, ChevronRight, ArrowRightLeft, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { api, multiVirtualPrinterApi } from '../api/client';
|
||||
import type { VirtualPrinterConfig } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
immediate: 'archive',
|
||||
review: 'review',
|
||||
print_queue: 'queue',
|
||||
proxy: 'proxy',
|
||||
};
|
||||
|
||||
interface VirtualPrinterCardProps {
|
||||
printer: VirtualPrinterConfig;
|
||||
models: Record<string, string>;
|
||||
}
|
||||
|
||||
export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [localEnabled, setLocalEnabled] = useState(printer.enabled);
|
||||
const [localName, setLocalName] = useState(printer.name);
|
||||
const [localAccessCode, setLocalAccessCode] = useState('');
|
||||
const [localMode, setLocalMode] = useState<LocalMode>(
|
||||
(printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode
|
||||
);
|
||||
const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(printer.target_printer_id);
|
||||
const [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');
|
||||
const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
|
||||
const [localModel, setLocalModel] = useState(printer.model || '');
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Sync local state when props change (e.g., after backend auto-disable)
|
||||
useEffect(() => {
|
||||
if (!pendingAction) {
|
||||
setLocalEnabled(printer.enabled);
|
||||
setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
|
||||
setLocalName(printer.name);
|
||||
setLocalTargetPrinterId(printer.target_printer_id);
|
||||
setLocalBindIp(printer.bind_ip || '');
|
||||
setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
|
||||
setLocalModel(printer.model || '');
|
||||
}
|
||||
}, [printer, pendingAction]);
|
||||
|
||||
// Fetch printers for dropdown
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Fetch network interfaces
|
||||
const { data: networkInterfaces } = useQuery({
|
||||
queryKey: ['network-interfaces'],
|
||||
queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof multiVirtualPrinterApi.update>[1]) =>
|
||||
multiVirtualPrinterApi.update(printer.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
|
||||
showToast(t('virtualPrinter.toast.updated'));
|
||||
setPendingAction(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
|
||||
setLocalEnabled(printer.enabled);
|
||||
setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
|
||||
setLocalTargetPrinterId(printer.target_printer_id);
|
||||
setLocalBindIp(printer.bind_ip || '');
|
||||
setPendingAction(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => multiVirtualPrinterApi.remove(printer.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
|
||||
showToast(t('virtualPrinter.toast.deleted'));
|
||||
setShowDeleteConfirm(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('virtualPrinter.toast.failedToDelete'), 'error');
|
||||
setShowDeleteConfirm(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleEnabled = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newEnabled = !localEnabled;
|
||||
if (newEnabled) {
|
||||
if (!localBindIp) {
|
||||
showToast(t('virtualPrinter.toast.bindIpRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
if (localMode === 'proxy') {
|
||||
if (!localTargetPrinterId) {
|
||||
showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!localAccessCode && !printer.access_code_set) {
|
||||
showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setLocalEnabled(newEnabled);
|
||||
setPendingAction('toggle');
|
||||
updateMutation.mutate({ enabled: newEnabled });
|
||||
};
|
||||
|
||||
const handleNameChange = () => {
|
||||
if (!localName.trim()) return;
|
||||
setPendingAction('name');
|
||||
updateMutation.mutate({ name: localName.trim() });
|
||||
};
|
||||
|
||||
const handleAccessCodeChange = () => {
|
||||
if (!localAccessCode) {
|
||||
showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');
|
||||
return;
|
||||
}
|
||||
if (localAccessCode.length !== 8) {
|
||||
showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');
|
||||
return;
|
||||
}
|
||||
setPendingAction('accessCode');
|
||||
updateMutation.mutate({ access_code: localAccessCode });
|
||||
setLocalAccessCode('');
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: LocalMode) => {
|
||||
setLocalMode(mode);
|
||||
setPendingAction('mode');
|
||||
updateMutation.mutate({ mode });
|
||||
};
|
||||
|
||||
const handleModelChange = (model: string) => {
|
||||
setLocalModel(model);
|
||||
setPendingAction('model');
|
||||
updateMutation.mutate({ model });
|
||||
};
|
||||
|
||||
const handleTargetPrinterChange = (printerId: number) => {
|
||||
setLocalTargetPrinterId(printerId);
|
||||
setPendingAction('targetPrinter');
|
||||
updateMutation.mutate({ target_printer_id: printerId });
|
||||
};
|
||||
|
||||
const handleRemoteInterfaceChange = (ip: string) => {
|
||||
setLocalRemoteInterfaceIp(ip);
|
||||
setPendingAction('remoteInterface');
|
||||
updateMutation.mutate({ remote_interface_ip: ip });
|
||||
};
|
||||
|
||||
const isRunning = printer.status?.running || false;
|
||||
const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'archive'}`);
|
||||
const targetPrinterName = printers?.find(p => p.id === localTargetPrinterId)?.name;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
{/* Collapsed header - always visible, clickable to expand */}
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-3 cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<button className="text-bambu-gray flex-shrink-0">
|
||||
{expanded
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
|
||||
<span className="text-white font-medium truncate">{printer.name}</span>
|
||||
<span className="text-xs text-bambu-gray flex-shrink-0">{modeLabel}</span>
|
||||
{printer.model_name && (
|
||||
<span className="text-xs text-bambu-gray flex-shrink-0">{printer.model_name}</span>
|
||||
)}
|
||||
{targetPrinterName && (
|
||||
<span className="text-xs text-bambu-gray flex-shrink-0 truncate">
|
||||
{localMode === 'proxy' && <ArrowRightLeft className="w-3 h-3 inline mr-1" />}
|
||||
{targetPrinterName}
|
||||
</span>
|
||||
)}
|
||||
{localBindIp && (
|
||||
<span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localBindIp}</span>
|
||||
)}
|
||||
{localRemoteInterfaceIp && (
|
||||
<span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
disabled={pendingAction === 'toggle'}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
} ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
localEnabled ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<CardContent className="pt-0 space-y-4">
|
||||
<div className="border-t border-bambu-dark-tertiary" />
|
||||
|
||||
{/* Name + delete */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => setLocalName(e.target.value)}
|
||||
onBlur={handleNameChange}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}
|
||||
className="flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-bambu-gray font-mono">{printer.serial}</span>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['immediate', 'review', 'print_queue', 'proxy'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeChange(mode)}
|
||||
disabled={pendingAction === 'mode'}
|
||||
className={`p-2 rounded-lg border text-left transition-colors ${
|
||||
localMode === mode
|
||||
? mode === 'proxy'
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-white text-xs font-medium">
|
||||
{mode === 'proxy' && <ArrowRightLeft className="w-3 h-3" />}
|
||||
{t(`virtualPrinter.mode.${MODE_LABELS[mode]}`)}
|
||||
</div>
|
||||
<div className="text-[10px] text-bambu-gray">
|
||||
{t(`virtualPrinter.mode.${MODE_LABELS[mode]}Desc`)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printer Model - for non-proxy modes */}
|
||||
{localMode !== 'proxy' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.model.title')}</div>
|
||||
<p className="text-xs text-bambu-gray mb-2">{t('virtualPrinter.model.description')}</p>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={localModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={pendingAction === 'model'}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
|
||||
>
|
||||
{Object.entries(models).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name} ({code})</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxy mode: hint about using target printer's access code */}
|
||||
{localMode === 'proxy' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-blue-500/10 border border-blue-500/30">
|
||||
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{t('virtualPrinter.proxy.accessCodeHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Access Code - only for non-proxy modes */}
|
||||
{localMode !== 'proxy' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="text-white text-sm font-medium">{t('virtualPrinter.accessCode.title')}</div>
|
||||
{printer.access_code_set ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<Check className="w-3 h-3" />
|
||||
{t('virtualPrinter.accessCode.isSet')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-yellow-400">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('virtualPrinter.accessCode.notSet')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showAccessCode ? 'text' : 'password'}
|
||||
value={localAccessCode}
|
||||
onChange={(e) => setLocalAccessCode(e.target.value)}
|
||||
placeholder={printer.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-1.5 text-white text-sm 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>
|
||||
{localAccessCode && (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
<span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Printer */}
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.targetPrinter.title')}</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-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
|
||||
>
|
||||
<option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.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>
|
||||
</div>
|
||||
|
||||
{/* Bind Interface */}
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.bindIp.title')}</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={localBindIp}
|
||||
onChange={(e) => {
|
||||
setLocalBindIp(e.target.value);
|
||||
setPendingAction('bindIp');
|
||||
updateMutation.mutate({ bind_ip: e.target.value });
|
||||
}}
|
||||
disabled={pendingAction === 'bindIp'}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
|
||||
>
|
||||
<option value="">{t('virtualPrinter.bindIp.placeholder')}</option>
|
||||
{networkInterfaces?.map((iface) => (
|
||||
<option key={iface.ip} value={iface.ip}>
|
||||
{iface.name} ({iface.ip}){iface.is_alias ? ' [alias]' : ''} - {iface.subnet}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('virtualPrinter.bindIp.hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Remote Interface - always visible for configuration */}
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-white text-sm font-medium">{t('virtualPrinter.remoteInterface.title')}</div>
|
||||
{localRemoteInterfaceIp ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400"><Check className="w-3 h-3" /></span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-bambu-gray"><Info className="w-3 h-3" /></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={localRemoteInterfaceIp}
|
||||
onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
|
||||
disabled={pendingAction === 'remoteInterface'}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
|
||||
>
|
||||
<option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
|
||||
{networkInterfaces?.map((iface) => (
|
||||
<option key={iface.ip} value={iface.ip}>
|
||||
{iface.name} ({iface.ip}) - {iface.subnet}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('virtualPrinter.deleteConfirm.title')}
|
||||
message={t('virtualPrinter.deleteConfirm.message', { name: printer.name })}
|
||||
variant="danger"
|
||||
confirmText={t('common.delete')}
|
||||
isLoading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info } from 'lucide-react';
|
||||
import { multiVirtualPrinterApi } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { VirtualPrinterCard } from './VirtualPrinterCard';
|
||||
import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
|
||||
|
||||
export function VirtualPrinterList() {
|
||||
const { t } = useTranslation();
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['virtual-printers'],
|
||||
queryFn: multiVirtualPrinterApi.list,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 flex justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const printers = data?.printers || [];
|
||||
const models = data?.models || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Top row - Setup Required (25%) + How it works (75%) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-start">
|
||||
<Card className="border-l-4 border-l-yellow-500">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs">
|
||||
<p className="text-white font-medium">{t('virtualPrinter.setupRequired.title')}</p>
|
||||
<p className="text-bambu-gray mt-1">{t('virtualPrinter.setupRequired.description')}</p>
|
||||
<a
|
||||
href="https://wiki.bambuddy.cool/features/virtual-printer/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/50 rounded text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t('virtualPrinter.setupRequired.readGuide')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-3">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-bambu-gray">
|
||||
<p className="text-white font-medium mb-1">{t('virtualPrinter.howItWorks.title')}</p>
|
||||
<p className="mb-2">{t('virtualPrinter.howItWorks.intro')}</p>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-2">
|
||||
<div className="p-2 rounded bg-bambu-dark-secondary">
|
||||
<p className="text-white font-medium mb-0.5">{t('virtualPrinter.mode.archive')}</p>
|
||||
<p>{t('virtualPrinter.howItWorks.archiveDesc')}</p>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-bambu-dark-secondary">
|
||||
<p className="text-white font-medium mb-0.5">{t('virtualPrinter.mode.review')}</p>
|
||||
<p>{t('virtualPrinter.howItWorks.reviewDesc')}</p>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-bambu-dark-secondary">
|
||||
<p className="text-white font-medium mb-0.5">{t('virtualPrinter.mode.queue')}</p>
|
||||
<p>{t('virtualPrinter.howItWorks.queueDesc')}</p>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-bambu-dark-secondary border border-blue-500/30">
|
||||
<p className="text-white font-medium mb-0.5">{t('virtualPrinter.mode.proxy')}</p>
|
||||
<p>{t('virtualPrinter.howItWorks.proxyDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Header with add button */}
|
||||
<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">{t('virtualPrinter.list.title')}</h2>
|
||||
<span className="text-sm text-bambu-gray">({printers.length})</span>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('virtualPrinter.list.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Printer cards - 3 column grid */}
|
||||
{printers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
|
||||
<p className="text-bambu-gray mb-4">{t('virtualPrinter.list.empty')}</p>
|
||||
<Button variant="primary" onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('virtualPrinter.list.addFirst')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-start">
|
||||
{printers.map((printer) => (
|
||||
<VirtualPrinterCard key={printer.id} printer={printer} models={models} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddDialog && (
|
||||
<VirtualPrinterAddDialog onClose={() => setShowAddDialog(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2979,19 +2979,11 @@ export default {
|
||||
},
|
||||
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: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
|
||||
proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
|
||||
proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
|
||||
proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
|
||||
proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
|
||||
proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
|
||||
intro: 'Jeder virtuelle Drucker erscheint in Bambu Studio / OrcaSlicer über SSDP-Erkennung. Erstelle mehrere Drucker mit verschiedenen Modi:',
|
||||
archiveDesc: '3MF-Dateien an diesen Drucker werden direkt im Archiv gespeichert.',
|
||||
reviewDesc: 'Dateien werden zur Überprüfung zurückgehalten. Du genehmigst oder verwirfst jeden Upload vor der Archivierung.',
|
||||
queueDesc: 'Dateien werden archiviert und automatisch zur Druckwarteschlange hinzugefügt (nicht zugewiesen).',
|
||||
proxyDesc: 'Datenverkehr wird an einen echten Drucker weitergeleitet. Erfordert eine dedizierte IP pro Proxy (Interface-Alias).',
|
||||
},
|
||||
status: {
|
||||
title: 'Status-Details',
|
||||
@@ -3011,8 +3003,37 @@ export default {
|
||||
failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',
|
||||
accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
|
||||
targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',
|
||||
bindIpRequired: 'Bitte zuerst eine Bind-IP setzen',
|
||||
accessCodeEmpty: 'Zugangscode darf nicht leer sein',
|
||||
accessCodeLength: 'Zugangscode muss genau 8 Zeichen lang sein',
|
||||
created: 'Virtueller Drucker erstellt',
|
||||
failedToCreate: 'Virtueller Drucker konnte nicht erstellt werden',
|
||||
deleted: 'Virtueller Drucker gelöscht',
|
||||
failedToDelete: 'Virtueller Drucker konnte nicht gelöscht werden',
|
||||
},
|
||||
list: {
|
||||
title: 'Virtuelle Drucker',
|
||||
add: 'Hinzufügen',
|
||||
addFirst: 'Virtuellen Drucker hinzufügen',
|
||||
empty: 'Keine virtuellen Drucker konfiguriert. Fügen Sie einen hinzu, um zu beginnen.',
|
||||
},
|
||||
bindIp: {
|
||||
title: 'Bind-Interface',
|
||||
placeholder: 'Interface auswählen...',
|
||||
hint: 'Netzwerkinterface, an das dieser virtuelle Drucker gebunden wird. Muss pro Drucker eindeutig sein.',
|
||||
},
|
||||
proxy: {
|
||||
accessCodeHint: 'Im Proxy-Modus den Zugangscode des Zieldruckers im Slicer verwenden. Die Verbindung wird transparent zum echten Drucker weitergeleitet.',
|
||||
},
|
||||
addDialog: {
|
||||
title: 'Virtuellen Drucker hinzufügen',
|
||||
name: 'Name',
|
||||
hint: 'Sie können Zugangscode, Zieldrucker und andere Einstellungen nach dem Erstellen konfigurieren.',
|
||||
create: 'Erstellen',
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: 'Virtuellen Drucker löschen',
|
||||
message: 'Möchten Sie "{{name}}" wirklich löschen? Dies stoppt alle Dienste für diesen Drucker.',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -2984,19 +2984,11 @@ export default {
|
||||
},
|
||||
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: 'Select the target printer (must be in LAN mode)',
|
||||
proxyStep2: 'For cross-network: select the slicer network interface',
|
||||
proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
|
||||
proxyStep4: 'Connect using the printer\'s access code',
|
||||
proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
|
||||
proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
|
||||
intro: 'Each virtual printer appears in Bambu Studio / OrcaSlicer via SSDP discovery. Create multiple printers with different modes:',
|
||||
archiveDesc: '3MF files sent to this printer are saved directly to the archive.',
|
||||
reviewDesc: 'Files are held for review. You approve or discard each upload before archiving.',
|
||||
queueDesc: 'Files are archived and automatically added to the print queue (unassigned).',
|
||||
proxyDesc: 'Traffic is relayed to a real printer. Requires a dedicated IP per proxy (interface alias).',
|
||||
},
|
||||
status: {
|
||||
title: 'Status Details',
|
||||
@@ -3016,8 +3008,37 @@ export default {
|
||||
failedToUpdate: 'Failed to update settings',
|
||||
accessCodeRequired: 'Please set an access code first',
|
||||
targetPrinterRequired: 'Please select a target printer first',
|
||||
bindIpRequired: 'Please set a bind IP first',
|
||||
accessCodeEmpty: 'Access code cannot be empty',
|
||||
accessCodeLength: 'Access code must be exactly 8 characters',
|
||||
created: 'Virtual printer created',
|
||||
failedToCreate: 'Failed to create virtual printer',
|
||||
deleted: 'Virtual printer deleted',
|
||||
failedToDelete: 'Failed to delete virtual printer',
|
||||
},
|
||||
list: {
|
||||
title: 'Virtual Printers',
|
||||
add: 'Add',
|
||||
addFirst: 'Add Virtual Printer',
|
||||
empty: 'No virtual printers configured. Add one to get started.',
|
||||
},
|
||||
bindIp: {
|
||||
title: 'Bind Interface',
|
||||
placeholder: 'Select interface...',
|
||||
hint: 'Network interface for this virtual printer to bind to. Must be unique per printer.',
|
||||
},
|
||||
proxy: {
|
||||
accessCodeHint: 'In proxy mode, use your target printer\'s access code in the slicer. The connection is forwarded transparently to the real printer.',
|
||||
},
|
||||
addDialog: {
|
||||
title: 'Add Virtual Printer',
|
||||
name: 'Name',
|
||||
hint: 'You can configure access code, target printer, and other settings after creating.',
|
||||
create: 'Create',
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: 'Delete Virtual Printer',
|
||||
message: 'Are you sure you want to delete "{{name}}"? This will stop all services for this printer.',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -2954,6 +2954,14 @@ export default {
|
||||
placeholder: 'Auto (défaut)...',
|
||||
hint: 'Force l\'IP annoncée via SSDP.',
|
||||
},
|
||||
bindIp: {
|
||||
title: 'Interface réseau',
|
||||
placeholder: 'Sélectionner interface...',
|
||||
hint: 'Interface réseau sur laquelle cette imprimante virtuelle écoute. Doit être unique par imprimante.',
|
||||
},
|
||||
proxy: {
|
||||
accessCodeHint: 'En mode proxy, utilisez le code d\'accès de l\'imprimante cible dans le slicer. La connexion est transmise de manière transparente à l\'imprimante réelle.',
|
||||
},
|
||||
mode: {
|
||||
title: 'Mode',
|
||||
archive: 'Archiver',
|
||||
|
||||
@@ -2675,6 +2675,14 @@ export default {
|
||||
placeholder: 'Rilevamento automatico (predefinito)...',
|
||||
hint: 'Sovrascrive l\'indirizzo IP pubblicizzato via SSDP e usato nel certificato TLS. Utile quando Bambuddy ha piu interfacce di rete.',
|
||||
},
|
||||
bindIp: {
|
||||
title: 'Interfaccia di rete',
|
||||
placeholder: 'Seleziona interfaccia...',
|
||||
hint: 'Interfaccia di rete a cui questa stampante virtuale si collega. Deve essere unica per stampante.',
|
||||
},
|
||||
proxy: {
|
||||
accessCodeHint: 'In modalita proxy, usa il codice di accesso della stampante di destinazione nello slicer. La connessione viene inoltrata in modo trasparente alla stampante reale.',
|
||||
},
|
||||
mode: {
|
||||
title: 'Modalita',
|
||||
archive: 'Archivio',
|
||||
|
||||
@@ -2818,20 +2818,12 @@ export default {
|
||||
hint: 'SSDPで広告され、TLS証明書に使用されるIPアドレスを上書きします。Bambuddyに複数のネットワークインターフェースがある場合に便利です。',
|
||||
},
|
||||
howItWorks: {
|
||||
step5: '設定したアクセスコードで接続する',
|
||||
step6: 'Bambuddyに「印刷」すると、3MFファイルがアーカイブされる',
|
||||
proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
|
||||
proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
|
||||
proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
|
||||
proxyStep4: 'プリンターのアクセスコードで接続',
|
||||
proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
|
||||
proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
|
||||
title: '仕組み',
|
||||
titleProxy: '仕組み(プロキシモード)',
|
||||
step1: 'プラットフォーム用のセットアップガイドを完了',
|
||||
step2: '仮想プリンターを有効にしてアクセスコードを設定',
|
||||
step3: 'Bambu StudioまたはOrcaSlicerで「プリンター追加」へ',
|
||||
step4: '「Bambuddy」プリンターが検出リストに表示されます',
|
||||
intro: '各仮想プリンターはSSDPディスカバリーを通じてBambu Studio / OrcaSlicerに表示されます。異なるモードで複数のプリンターを作成できます:',
|
||||
archiveDesc: 'このプリンターに送信された3MFファイルはアーカイブに直接保存されます。',
|
||||
reviewDesc: 'ファイルはレビュー用に保留されます。アーカイブ前に各アップロードを承認または破棄します。',
|
||||
queueDesc: 'ファイルはアーカイブされ、自動的に印刷キューに追加されます(未割り当て)。',
|
||||
proxyDesc: 'トラフィックは実際のプリンターに転送されます。プロキシごとに専用IPが必要です(インターフェースエイリアス)。',
|
||||
},
|
||||
status: {
|
||||
mode: 'モード',
|
||||
@@ -2849,10 +2841,39 @@ export default {
|
||||
toast: {
|
||||
accessCodeRequired: '先にアクセスコードを設定してください',
|
||||
targetPrinterRequired: '先にターゲットプリンターを選択してください',
|
||||
bindIpRequired: '先にバインドIPを設定してください',
|
||||
accessCodeEmpty: 'アクセスコードは空にできません',
|
||||
accessCodeLength: 'アクセスコードは8文字である必要があります',
|
||||
updated: '仮想プリンター設定を更新しました',
|
||||
failedToUpdate: '設定の更新に失敗しました',
|
||||
created: '仮想プリンターを作成しました',
|
||||
failedToCreate: '仮想プリンターの作成に失敗しました',
|
||||
deleted: '仮想プリンターを削除しました',
|
||||
failedToDelete: '仮想プリンターの削除に失敗しました',
|
||||
},
|
||||
list: {
|
||||
title: '仮想プリンター',
|
||||
add: '追加',
|
||||
addFirst: '仮想プリンターを追加',
|
||||
empty: '仮想プリンターが設定されていません。追加して始めましょう。',
|
||||
},
|
||||
bindIp: {
|
||||
title: 'バインドインターフェース',
|
||||
placeholder: 'インターフェースを選択...',
|
||||
hint: 'この仮想プリンターがバインドするネットワークインターフェース。プリンターごとに一意である必要があります。',
|
||||
},
|
||||
proxy: {
|
||||
accessCodeHint: 'プロキシモードでは、スライサーにターゲットプリンターのアクセスコードを使用してください。接続は実際のプリンターに透過的に転送されます。',
|
||||
},
|
||||
addDialog: {
|
||||
title: '仮想プリンターを追加',
|
||||
name: '名前',
|
||||
hint: 'アクセスコード、ターゲットプリンター、その他の設定は作成後に設定できます。',
|
||||
create: '作成',
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '仮想プリンターを削除',
|
||||
message: '「{{name}}」を削除してもよろしいですか?このプリンターのすべてのサービスが停止されます。',
|
||||
},
|
||||
title: '仮想プリンター',
|
||||
stopped: '停止',
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SpoolmanSettings } from '../components/SpoolmanSettings';
|
||||
import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
|
||||
import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
|
||||
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
|
||||
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
|
||||
import { VirtualPrinterList } from '../components/VirtualPrinterList';
|
||||
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
|
||||
import { EmailSettings } from '../components/EmailSettings';
|
||||
import { APIBrowser } from '../components/APIBrowser';
|
||||
@@ -3330,7 +3330,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Virtual Printer Tab */}
|
||||
{activeTab === 'virtual-printer' && (
|
||||
<VirtualPrinterSettings />
|
||||
<VirtualPrinterList />
|
||||
)}
|
||||
|
||||
{/* Filament Tab */}
|
||||
|
||||
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-D8lA4NE5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DF7TfzH1.css">
|
||||
<script type="module" crossorigin src="/assets/index-_Fo-S_XR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BGInJFa3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user