Files
Sergey Dontsov bab1cfb906 feat(vp): per-VP "Save AMS mapping" toggle + reprint auto-apply
Lets a reprint reuse the AMS slot the slicer itself picked, instead of
re-deriving one from the file's static type/color.

When a Print Queue VP has "Save AMS mapping" on, the slicer's own
live-resolved ams_mapping (from the project_file MQTT command) is
persisted onto the archive as extra_data.slicer_ams_mapping. A later
reprint can reuse it via a new "Mapping" button in the filament-mapping
panel — one click snaps every slot to the saved pick, click again
reverts to auto-match. Archive cards and queue rows get an "AMS mapping
saved" badge so it's visible beforehand. add_to_queue also falls back
to the saved mapping automatically when the caller sends no explicit
ams_mapping (e.g. a plain reprint with no per-slot edits).

The queue item's own ams_mapping (used for that dispatch) is still
captured unconditionally whenever the slicer provides it — that part is
a correctness fix, not gated behind the toggle. Only the archive
persistence for future reprints is opt-in.

Split out from the original combined PR per review: this half is
genuinely opt-in and low-risk (#2684). The dispatch-time validation
gate that keeps a stored mapping honest (#1308) changes behaviour for
every existing user and will land as its own PR.

Review fixes applied:
- _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
  arm and rejected bool explicitly (isinstance(v, int) accepts bool).
- Translated the Russian docstring text to English.
- save_ams_mapping's model comment moved to a trailing comment on the
  column line, matching the file's convention.
- usingArchiveMapping now resets when the plate or archive changes, so
  the Mapping button can't read ON against a mapping it never applied.
- Translated "Click to change slot assignment" and "Re-read".
- add_to_queue's fallback is now called out explicitly in code comments
  and covered by three new integration tests (fallback fires, explicit
  mapping wins, unrelated extra_data doesn't false-trigger).

Closes #2684
2026-07-31 11:08:58 +03:00

597 lines
24 KiB
Python

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
from backend.app.schemas.virtual_printer import VPDiagnosticResult
# Imported at module scope so tests can patch
# backend.app.api.routes.virtual_printers.tailscale_service.
from backend.app.services.virtual_printer.tailscale import tailscale_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
class TailscaleStatusResponse(BaseModel):
available: bool
fqdn: str
hostname: str
tailnet_name: str
tailscale_ips: list[str]
error: str | None
class VirtualPrinterCreate(BaseModel):
name: str = "Bambuddy"
enabled: bool = False
mode: str = "archive"
model: str | None = None
access_code: str | None = None
target_printer_id: int | None = None
auto_dispatch: bool = True
queue_force_color_match: bool = False
save_ams_mapping: bool = False
gcode_injection: bool = False
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
auto_dispatch: bool | None = None
queue_force_color_match: bool | None = None
save_ams_mapping: bool | None = None
gcode_injection: bool | None = None
bind_ip: str | None = None
remote_interface_ip: str | None = None
tailscale_disabled: bool | None = None
def _resolve_printer_model(printer_model: str | None) -> str | None:
"""Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
"""
if not printer_model:
return None
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
# Already a valid SSDP model code
if printer_model in VIRTUAL_PRINTER_MODELS:
return printer_model
# Map display name to SSDP code
return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
"""Convert VirtualPrinter model to response dict.
In proxy mode the surfaced serial is the target printer's actual serial
(what the bridge advertises over SSDP / what slicers see), not the
self-generated suffix. Archive / queue / review keep the self-generated
serial since those modes never speak the target's identity.
"""
from backend.app.models.printer import Printer
from backend.app.models.virtual_printer import VP_MODE_PROXY
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)
if vp.mode == VP_MODE_PROXY and vp.target_printer_id:
result = await db.execute(select(Printer.serial_number).where(Printer.id == vp.target_printer_id))
target_serial = result.scalar_one_or_none()
if target_serial:
serial = target_serial
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,
"auto_dispatch": vp.auto_dispatch,
"queue_force_color_match": vp.queue_force_color_match,
"save_ams_mapping": vp.save_ams_mapping,
"gcode_injection": vp.gcode_injection,
"bind_ip": vp.bind_ip,
"remote_interface_ip": vp.remote_interface_ip,
"tailscale_disabled": vp.tailscale_disabled,
"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(await _vp_to_dict(vp, db, 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 VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
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
# Accept both canonical and legacy wire values so older clients (forks /
# mobile shortcuts / scripted setups) still work; normalize before write.
body.mode = normalize_vp_mode(body.mode) or body.mode
if body.mode not in VP_MODE_VALUES:
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. Non-proxy VPs with a target printer derive
# their access code from the target (the bridge forwards the slicer's
# auth bytes through to the real printer, so the codes MUST match),
# so a separately-supplied access_code isn't required in that case.
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 and not body.target_printer_id:
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
# Validate proxy target printer exists
target_printer = None
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))
target_printer = result.scalar_one_or_none()
if not target_printer:
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"})
# Force-inherit the access code from the target printer for non-proxy VPs.
# The non-proxy bridge (Immediate / Review / Queue with a target set) forwards
# the slicer's MQTT / RTSPS auth bytes through to the real printer, so any
# value the user supplied here would silently break the bridge if it didn't
# match the printer's code. The UI now renders the field read-only when a
# target is set; this is the belt-and-braces backstop for any non-UI client.
effective_access_code = body.access_code
if body.mode != "proxy" and target_printer is not None:
effective_access_code = target_printer.access_code
# 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 _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
or DEFAULT_VIRTUAL_PRINTER_MODEL,
access_code=effective_access_code,
target_printer_id=body.target_printer_id,
auto_dispatch=body.auto_dispatch,
queue_force_color_match=body.queue_force_color_match,
save_ams_mapping=body.save_ams_mapping,
gcode_injection=body.gcode_injection,
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 await _vp_to_dict(vp, db)
@router.get("/tailscale-status", response_model=TailscaleStatusResponse)
async def get_tailscale_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
) -> TailscaleStatusResponse:
"""Return current Tailscale availability and machine identity.
Used by the frontend to indicate whether virtual printer TLS is backed
by a trusted Let's Encrypt certificate or a self-signed CA.
"""
status = await tailscale_service.get_status()
return TailscaleStatusResponse(
available=status.available,
fqdn=status.fqdn,
hostname=status.hostname,
tailnet_name=status.tailnet_name,
tailscale_ips=status.tailscale_ips,
error=status.error,
)
@router.get("/ca-certificate")
async def get_ca_certificate(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
One CA is shared by every virtual printer — the user imports it into their
slicer's trust store once. Only the public certificate is returned; the CA
private key never leaves the backend.
"""
from backend.app.services.virtual_printer import virtual_printer_manager
try:
return virtual_printer_manager.get_ca_certificate_info()
except Exception as e:
logger.error("Failed to obtain virtual printer CA certificate: %s", e)
return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
@router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
async def diagnose_virtual_printer(
vp_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Run setup diagnostics for a virtual printer.
Probes the VP's own bind IP and services so the user can self-diagnose the
common "my virtual printer doesn't show up in the slicer" failures.
"""
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.services.virtual_printer import virtual_printer_manager
from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
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)
return await run_vp_diagnostic(vp, instance)
@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 await _vp_to_dict(vp, db, 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"})
# Redact the access code before logging — model_dump otherwise includes
# the plaintext value at DEBUG, violating the project no-secrets-in-logs
# rule. Replace with a marker that still signals "the user changed it"
# vs "the user didn't touch this field".
_safe_body = body.model_dump(exclude_unset=True)
if "access_code" in _safe_body:
_safe_body["access_code"] = "***"
logger.debug(
"Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
vp_id,
_safe_body,
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:
from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
canonical_mode = normalize_vp_mode(body.mode) or body.mode
if canonical_mode not in VP_MODE_VALUES:
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
vp.mode = canonical_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))
target_printer = result.scalar_one_or_none()
if not target_printer:
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
# Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
if body.model is None and vp.mode == "proxy" and target_printer.model:
vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
if body.auto_dispatch is not None:
vp.auto_dispatch = body.auto_dispatch
if body.queue_force_color_match is not None:
vp.queue_force_color_match = body.queue_force_color_match
if body.save_ams_mapping is not None:
vp.save_ams_mapping = body.save_ams_mapping
if body.gcode_injection is not None:
vp.gcode_injection = body.gcode_injection
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
if body.tailscale_disabled is not None:
vp.tailscale_disabled = body.tailscale_disabled
# Auto-inherit model when switching to proxy mode with existing target printer
if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
from backend.app.models.printer import Printer as PrinterModel
result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
existing_target = result.scalar_one_or_none()
if existing_target and existing_target.model:
vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
# Force-inherit the access code from the target printer for non-proxy VPs.
# See create_virtual_printer for the rationale: the bridge forwards slicer
# auth bytes through, so the VP's code MUST equal the target's. This block
# runs after every patch (whether or not access_code or target were in the
# body), so changing the target also resyncs the code, and an explicit
# access_code submitted alongside a target is silently overridden.
if vp.mode != "proxy" and vp.target_printer_id is not None:
from backend.app.models.printer import Printer as PrinterModelAC
result = await db.execute(select(PrinterModelAC).where(PrinterModelAC.id == vp.target_printer_id))
target_for_ac = result.scalar_one_or_none()
if target_for_ac is not None and vp.access_code != target_for_ac.access_code:
vp.access_code = target_for_ac.access_code
# 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 await _vp_to_dict(vp, db, 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)
# Mark any PendingUpload rows that referenced this VP's upload_dir as
# discarded — without this the rows live on as phantom entries in
# /pending-uploads/ pointing at file paths that no longer exist, and
# the user only learns they're orphaned by trying to archive one and
# getting a flip-to-discarded on file-missing.
upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
try:
from backend.app.models.pending_upload import PendingUpload
stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix)))
for pending in stale.scalars().all():
pending.status = "discarded"
await db.flush()
except Exception as e:
logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
# Delete from DB
await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
await db.commit()
# Remove the on-disk upload directory after the DB commit succeeds, so
# a crash between commit and rmtree only leaves orphan files (vs orphan
# rows pointing at a now-missing tree).
upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
if upload_dir.exists():
import shutil
shutil.rmtree(upload_dir, ignore_errors=True)
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}