mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:
- Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
dialog (IP-only).
- Their printer-MQTT trust path validates only against the bundled BBL CA
store (`printer.cer`), NOT the system trust store. Confirmed against
ClusterM/open-bambu-networking's clean-room reimplementation:
`mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
chain validation against BBL CA only, hostname check intentionally
skipped (because Bambu's printer cert CN is the device serial).
- LE certs don't chain to BBL CA, so the slicer rejects with the
well-known "-1" before any hostname/IP logic runs.
The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:
- Toggle stays as an informational marker — when ON, the VP card surfaces
the host's Tailscale IP + MagicDNS hostname so users know what to paste
into the slicer.
- Cert is always self-signed (signed by `bbl_ca`).
- Tailscale exposure is via the existing bind_ip dropdown, which already
includes `tailscale0` IPs.
- Tailscale's role is strictly network reach — same trust burden as LAN.
Backend cuts:
- `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
`_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
`cryptography` import. Keep `get_status` and `TailscaleStatus`.
- `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
- `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
`_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
`_cancel_renewal_task`, `_cancel_restart_task`. Simplify
`_resolve_cert_and_advertise` to a sync method that just generates the
self-signed cert. Drop `tailscale_disabled` from the change-detection
diff (toggle is informational — no service restart needed).
- `routes/virtual_printers.py` + `routes/settings.py`: drop the
`tailscale_not_available` 409 guard on toggle-enable.
Frontend cuts:
- `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
`multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
no longer populated). Drop the `tailscale_not_available` toast handler.
- `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
- i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.
Docs:
- Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
— remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
steps, document the toggle as informational, keep the Docker socket
mount + LXC TUN troubleshooting (those still apply for daemon
reachability).
- README: drop "the Tailscale benefit here is the tunnel, not cert-import
elimination" framing in favour of "surfaces the IP for paste into
slicer; CA import unchanged because BBL CA store, not system trust
store, is what gets validated".
Tests:
- `test_tailscale.py`: reduced to surviving `get_status` cases (binary
missing, command fails, success, empty DNSName, malformed JSON).
- `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
→ `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
informational; `remove_instance` must NOT be called).
- `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
`TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
directions succeed and daemon is never consulted).
- `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
FQDN-copy block drives data through that query.
DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.
Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
464 lines
18 KiB
Python
464 lines
18 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
|
|
|
|
# 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 = "immediate"
|
|
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
|
|
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
|
|
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)
|
|
|
|
|
|
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,
|
|
"auto_dispatch": vp.auto_dispatch,
|
|
"queue_force_color_match": vp.queue_force_color_match,
|
|
"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(_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
|
|
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"})
|
|
|
|
# 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=body.access_code,
|
|
target_printer_id=body.target_printer_id,
|
|
auto_dispatch=body.auto_dispatch,
|
|
queue_force_color_match=body.queue_force_color_match,
|
|
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("/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("/{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))
|
|
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.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
|
|
|
|
# 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}
|