manager.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. """Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
  2. Each virtual printer runs its own independent services (FTP, MQTT, SSDP, Bind)
  3. bound to its dedicated IP address, regardless of mode.
  4. """
  5. import asyncio
  6. import logging
  7. from collections.abc import Callable
  8. from datetime import datetime, timezone
  9. from pathlib import Path
  10. from backend.app.core.config import settings as app_settings
  11. from backend.app.services.virtual_printer.bind_server import BindServer
  12. from backend.app.services.virtual_printer.certificate import CertificateService
  13. from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
  14. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  15. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
  16. from backend.app.services.virtual_printer.tailscale import tailscale_service
  17. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  18. logger = logging.getLogger(__name__)
  19. # Mapping of SSDP model codes to display names
  20. # These are the codes that slicers expect during discovery
  21. # Sources:
  22. # - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  23. # - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
  24. VIRTUAL_PRINTER_MODELS = {
  25. # X1 Series
  26. "BL-P001": "X1C", # X1 Carbon
  27. "BL-P002": "X1", # X1
  28. "C13": "X1E", # X1E
  29. # X2 Series
  30. "N6": "X2D", # X2D
  31. # P Series
  32. "C11": "P1P", # P1P
  33. "C12": "P1S", # P1S
  34. "N7": "P2S", # P2S
  35. # A1 Series
  36. "N2S": "A1", # A1
  37. "N1": "A1 Mini", # A1 Mini
  38. # H2 Series
  39. "O1D": "H2D", # H2D
  40. "O1C": "H2C", # H2C
  41. "O1C2": "H2C", # H2C (dual nozzle variant)
  42. "O1S": "H2S", # H2S
  43. }
  44. # Serial number prefixes for each model (based on Bambu Lab serial number format)
  45. # Format: MMM??RYMDDUUUUU (15 chars total)
  46. # MMM = Model prefix (3 chars)
  47. # ?? = Unknown/revision code (2 chars)
  48. # R = Revision letter (1 char)
  49. # Y = Year digit (1 char)
  50. # M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
  51. # DD = Day (2 chars)
  52. # UUUUU = Unit number (5 chars)
  53. MODEL_SERIAL_PREFIXES = {
  54. # X1 Series
  55. "BL-P001": "00M00A", # X1C
  56. "BL-P002": "00M00A", # X1
  57. "C13": "03W00A", # X1E
  58. # X2 Series
  59. "N6": "20P90A", # X2D (first 4 chars "20P9" match real serials)
  60. # P Series
  61. "C11": "01S00A", # P1P
  62. "C12": "01P00A", # P1S
  63. "N7": "22E00A", # P2S
  64. # A1 Series
  65. "N2S": "03900A", # A1
  66. "N1": "03000A", # A1 Mini
  67. # H2 Series
  68. "O1D": "09400A", # H2D
  69. "O1C": "09400A", # H2C
  70. "O1C2": "09400A", # H2C (dual nozzle variant)
  71. "O1S": "09400A", # H2S
  72. }
  73. # Reverse mapping: display name → SSDP model code (for auto-inheriting from printer model)
  74. DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}
  75. # Default model
  76. DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
  77. def _get_serial_for_model(model: str, serial_suffix: str) -> str:
  78. """Get serial number for the given model and suffix."""
  79. prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
  80. return f"{prefix}{serial_suffix}"
  81. class VirtualPrinterInstance:
  82. """Per-printer state and file handling logic.
  83. Each instance represents one virtual printer with its own config,
  84. upload directory, certificates, and file handling mode.
  85. """
  86. def __init__(
  87. self,
  88. *,
  89. vp_id: int,
  90. name: str,
  91. mode: str,
  92. model: str,
  93. access_code: str,
  94. serial_suffix: str,
  95. target_printer_ip: str = "",
  96. target_printer_serial: str = "",
  97. target_printer_id: int | None = None,
  98. auto_dispatch: bool = True,
  99. bind_ip: str = "",
  100. remote_interface_ip: str = "",
  101. tailscale_disabled: bool = True,
  102. base_dir: Path,
  103. session_factory: Callable | None = None,
  104. ):
  105. self.id = vp_id
  106. self.name = name
  107. self.mode = mode
  108. self.model = model
  109. self.access_code = access_code
  110. self.serial_suffix = serial_suffix
  111. self.target_printer_ip = target_printer_ip
  112. self.target_printer_serial = target_printer_serial
  113. self.target_printer_id = target_printer_id
  114. self.auto_dispatch = auto_dispatch
  115. self.bind_ip = bind_ip
  116. self.remote_interface_ip = remote_interface_ip
  117. self.tailscale_disabled = tailscale_disabled
  118. self._session_factory = session_factory
  119. # Directories
  120. self.upload_dir = base_dir / "uploads" / str(vp_id)
  121. self.cert_dir = base_dir / "certs" / str(vp_id)
  122. shared_ca_dir = base_dir / "certs"
  123. # Ensure directories exist
  124. self.upload_dir.mkdir(parents=True, exist_ok=True)
  125. (self.upload_dir / "cache").mkdir(exist_ok=True)
  126. self.cert_dir.mkdir(parents=True, exist_ok=True)
  127. # Certificate service (shared CA, per-instance printer cert)
  128. self._cert_service = CertificateService(
  129. cert_dir=self.cert_dir,
  130. serial=self.serial,
  131. shared_ca_dir=shared_ca_dir,
  132. )
  133. # Tailscale FQDN used for this instance (set at start_server/start_proxy time)
  134. self.tailscale_fqdn: str | None = None
  135. # Pending files for MQTT correlation
  136. self._pending_files: dict[str, Path] = {}
  137. # Per-instance services
  138. self._proxy: SlicerProxyManager | None = None
  139. self._ftp: VirtualPrinterFTPServer | None = None
  140. self._mqtt: SimpleMQTTServer | None = None
  141. self._bind: BindServer | None = None
  142. self._ssdp: VirtualPrinterSSDPServer | None = None
  143. self._ssdp_proxy: SSDPProxy | None = None
  144. self._tasks: list[asyncio.Task] = []
  145. self._cert_renewal_task: asyncio.Task | None = None
  146. self._cert_restart_task: asyncio.Task | None = None
  147. @property
  148. def serial(self) -> str:
  149. """Full serial number for this virtual printer."""
  150. return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)
  151. @property
  152. def cert_path(self) -> Path:
  153. return self._cert_service.cert_path
  154. @property
  155. def key_path(self) -> Path:
  156. return self._cert_service.key_path
  157. @property
  158. def is_proxy(self) -> bool:
  159. return self.mode == "proxy"
  160. @property
  161. def is_running(self) -> bool:
  162. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  163. def generate_certificates(self) -> tuple[Path, Path]:
  164. """Generate certificates for this instance."""
  165. self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)
  166. additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None
  167. if self.bind_ip:
  168. additional_ips = additional_ips or []
  169. additional_ips.append(self.bind_ip)
  170. self._cert_service.delete_printer_certificate()
  171. return self._cert_service.generate_certificates(additional_ips=additional_ips)
  172. # -- File handling callbacks --
  173. async def on_file_received(self, file_path: Path, source_ip: str) -> None:
  174. """Handle file upload completion from FTP."""
  175. logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
  176. self._pending_files[file_path.name] = file_path
  177. if self.mode == "immediate":
  178. await self._archive_file(file_path, source_ip)
  179. elif self.mode == "print_queue":
  180. await self._add_to_print_queue(file_path, source_ip)
  181. else:
  182. await self._queue_file(file_path, source_ip)
  183. # Reset MQTT status back to IDLE
  184. if self._mqtt and file_path.suffix.lower() == ".3mf":
  185. self._mqtt.set_gcode_state("IDLE")
  186. async def on_print_command(self, filename: str, data: dict) -> None:
  187. """Handle print command from MQTT."""
  188. logger.info("[VP %s] Print command for: %s", self.name, filename)
  189. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  190. """Archive file immediately."""
  191. if not self._session_factory:
  192. logger.error("Cannot archive: no database session factory configured")
  193. return
  194. if file_path.suffix.lower() != ".3mf":
  195. logger.debug("Skipping non-3MF file: %s", file_path.name)
  196. self._pending_files.pop(file_path.name, None)
  197. try:
  198. file_path.unlink()
  199. except OSError:
  200. pass
  201. return
  202. try:
  203. from backend.app.api.routes.settings import get_setting
  204. from backend.app.services.archive import ArchiveService
  205. async with self._session_factory() as db:
  206. name_source = await get_setting(db, "virtual_printer_archive_name_source")
  207. prefer_filename = name_source == "filename"
  208. service = ArchiveService(db)
  209. archive = await service.archive_print(
  210. printer_id=None,
  211. source_file=file_path,
  212. print_data={
  213. "status": "archived",
  214. "source": "virtual_printer",
  215. "source_ip": source_ip,
  216. },
  217. prefer_filename_for_name=prefer_filename,
  218. )
  219. if archive:
  220. logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
  221. try:
  222. file_path.unlink()
  223. except OSError:
  224. pass
  225. self._pending_files.pop(file_path.name, None)
  226. else:
  227. logger.error("Failed to archive file: %s", file_path.name)
  228. except Exception as e:
  229. logger.error("Error archiving file: %s", e)
  230. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  231. """Queue file for user review."""
  232. if not self._session_factory:
  233. logger.error("Cannot queue: no database session factory configured")
  234. return
  235. if file_path.suffix.lower() != ".3mf":
  236. self._pending_files.pop(file_path.name, None)
  237. try:
  238. file_path.unlink()
  239. except OSError:
  240. pass
  241. return
  242. # Peek at the 3MF for the embedded title BEFORE we hand it off to the
  243. # DB. Storing it now means the /pending-uploads/ list doesn't have to
  244. # reopen every 3MF on every render to keep the review card and the
  245. # eventual archive name in sync (#1152 follow-up). Failure to parse is
  246. # not fatal — the response model falls back to the filename stem.
  247. metadata_print_name: str | None = None
  248. try:
  249. from backend.app.services.archive import ThreeMFParser
  250. parsed = ThreeMFParser(file_path).parse()
  251. raw_name = parsed.get("print_name")
  252. if isinstance(raw_name, str) and raw_name.strip():
  253. metadata_print_name = raw_name.strip()[:255]
  254. except Exception as e:
  255. logger.debug("[VP %s] Metadata title peek failed for %s: %s", self.name, file_path.name, e)
  256. try:
  257. from backend.app.models.pending_upload import PendingUpload
  258. async with self._session_factory() as db:
  259. pending = PendingUpload(
  260. filename=file_path.name,
  261. file_path=str(file_path),
  262. file_size=file_path.stat().st_size,
  263. source_ip=source_ip,
  264. status="pending",
  265. uploaded_at=datetime.now(timezone.utc),
  266. metadata_print_name=metadata_print_name,
  267. )
  268. db.add(pending)
  269. await db.commit()
  270. logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
  271. self._pending_files.pop(file_path.name, None)
  272. except Exception as e:
  273. logger.error("Error queueing file: %s", e)
  274. async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
  275. """Archive file and add to print queue, assigned to target printer or model."""
  276. if not self._session_factory:
  277. logger.error("Cannot add to print queue: no database session factory configured")
  278. return
  279. if file_path.suffix.lower() != ".3mf":
  280. self._pending_files.pop(file_path.name, None)
  281. try:
  282. file_path.unlink()
  283. except OSError:
  284. pass
  285. return
  286. try:
  287. from backend.app.api.routes.settings import get_setting
  288. from backend.app.models.print_queue import PrintQueueItem
  289. from backend.app.services.archive import ArchiveService
  290. async with self._session_factory() as db:
  291. name_source = await get_setting(db, "virtual_printer_archive_name_source")
  292. prefer_filename = name_source == "filename"
  293. service = ArchiveService(db)
  294. archive = await service.archive_print(
  295. printer_id=None,
  296. source_file=file_path,
  297. print_data={
  298. "status": "archived",
  299. "source": "virtual_printer",
  300. "source_ip": source_ip,
  301. },
  302. prefer_filename_for_name=prefer_filename,
  303. )
  304. if archive:
  305. logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
  306. # Assign to specific printer if configured, otherwise use model for "Any X" scheduling
  307. target_model = None
  308. if not self.target_printer_id and self.model:
  309. target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
  310. plate_id = self._extract_plate_id(file_path)
  311. queue_item = PrintQueueItem(
  312. printer_id=self.target_printer_id,
  313. target_model=target_model,
  314. archive_id=archive.id,
  315. plate_id=plate_id,
  316. position=1,
  317. status="pending",
  318. manual_start=not self.auto_dispatch,
  319. )
  320. db.add(queue_item)
  321. await db.commit()
  322. logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
  323. try:
  324. file_path.unlink()
  325. except OSError:
  326. pass
  327. self._pending_files.pop(file_path.name, None)
  328. else:
  329. logger.error("Failed to archive file: %s", file_path.name)
  330. except Exception as e:
  331. logger.error("Error adding to print queue: %s", e)
  332. @staticmethod
  333. def _extract_plate_id(file_path: Path) -> int | None:
  334. """Extract plate index from 3MF slice_info.config."""
  335. try:
  336. import xml.etree.ElementTree as ET
  337. import zipfile
  338. with zipfile.ZipFile(file_path, "r") as zf:
  339. if "Metadata/slice_info.config" in zf.namelist():
  340. content = zf.read("Metadata/slice_info.config").decode()
  341. root = ET.fromstring(content) # noqa: S314 # nosec B314
  342. plate = root.find(".//plate")
  343. if plate is not None:
  344. for meta in plate.findall("metadata"):
  345. if meta.get("key") == "index" and meta.get("value"):
  346. return int(meta.get("value"))
  347. except Exception:
  348. return None
  349. return None
  350. # -- Service lifecycle --
  351. async def _cancel_renewal_task(self) -> None:
  352. """Cancel the cert renewal task and await its completion."""
  353. if self._cert_renewal_task:
  354. self._cert_renewal_task.cancel()
  355. try:
  356. await self._cert_renewal_task
  357. except asyncio.CancelledError:
  358. pass
  359. except Exception as e:
  360. logger.warning("[VP %s] Unexpected error in cert renewal task: %s", self.name, e)
  361. self._cert_renewal_task = None
  362. async def _cancel_restart_task(self) -> None:
  363. """Cancel the cert restart task and await its completion.
  364. Skip when the caller IS the restart task itself — stop_server() /
  365. stop_proxy() are called from inside _restart_for_cert_renewal,
  366. which runs AS _cert_restart_task. Cancelling + awaiting self
  367. flags a CancelledError on the next `await` in stop_server,
  368. which tears down the old listeners but never lets start_server
  369. run — the VP would sit on an expired cert until process restart.
  370. """
  371. task = self._cert_restart_task
  372. if task is asyncio.current_task():
  373. # Renewal path cleaning up its own restart task: clear the
  374. # reference so future callers don't see a stale task handle,
  375. # but do NOT cancel-and-await ourselves.
  376. self._cert_restart_task = None
  377. return
  378. if task and not task.done():
  379. task.cancel()
  380. try:
  381. await task
  382. except asyncio.CancelledError:
  383. pass
  384. except Exception as e:
  385. logger.warning("[VP %s] Unexpected error in cert restart task: %s", self.name, e)
  386. self._cert_restart_task = None
  387. async def _restart_for_cert_renewal(self) -> None:
  388. """Restart VP services to load the newly renewed Tailscale cert into TLS listeners."""
  389. logger.info("[VP %s] Restarting services to apply renewed Tailscale cert", self.name)
  390. try:
  391. if self.is_proxy:
  392. await self.stop_proxy()
  393. await self.start_proxy()
  394. else:
  395. await self.stop_server()
  396. await self.start_server()
  397. except asyncio.CancelledError:
  398. raise
  399. except Exception as e:
  400. logger.error("[VP %s] Failed to restart after cert renewal: %s", self.name, e)
  401. async def _cert_renewal_loop(self) -> None:
  402. """Daily background check for Tailscale cert renewal while VP is running.
  403. Checks first, then sleeps, so a cert that was just barely renewed at startup
  404. is not re-checked for another 24 h. When a renewal actually happens the loop
  405. schedules a VP restart so the new cert is loaded into the running TLS listeners.
  406. _cert_renewal_task is tracked separately from _tasks because it has a different
  407. lifecycle: it runs for the entire lifetime of the VP, not just during service start.
  408. """
  409. while True:
  410. try:
  411. if self.tailscale_fqdn:
  412. needs_renewal = tailscale_service.cert_needs_renewal(
  413. self._cert_service.ts_cert_path, fqdn=self.tailscale_fqdn
  414. )
  415. if needs_renewal:
  416. renewed = await self._cert_service.use_tailscale_cert(self.tailscale_fqdn, tailscale_service)
  417. if renewed:
  418. logger.info(
  419. "[VP %s] Tailscale cert renewed for %s, scheduling restart",
  420. self.name,
  421. self.tailscale_fqdn,
  422. )
  423. # Schedule restart in a separate task; this loop ends here
  424. # so the restart can cleanly cancel _cert_renewal_task and
  425. # create a fresh one via start_server/start_proxy.
  426. self._cert_restart_task = asyncio.create_task(
  427. self._restart_for_cert_renewal(),
  428. name=f"vp_{self.id}_cert_restart",
  429. )
  430. break
  431. await asyncio.sleep(86400) # check once per day
  432. except asyncio.CancelledError:
  433. break
  434. except Exception as e:
  435. logger.error("[VP %s] Cert renewal loop error: %s", self.name, e)
  436. await asyncio.sleep(3600) # back off 1 h on unexpected error
  437. async def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]:
  438. """Return (cert_path, key_path, advertise_address) for TLS services.
  439. When Tailscale is available, provisions a LE cert and returns the
  440. Tailscale FQDN as the advertise address so SSDP broadcasts the hostname
  441. that matches the trusted cert.
  442. Falls back to the self-signed cert and IP-based advertising when
  443. Tailscale is absent or provisioning fails.
  444. """
  445. if self.tailscale_disabled:
  446. logger.info("[VP %s] Tailscale integration disabled by user, using self-signed cert", self.name)
  447. else:
  448. try:
  449. ts_status = await tailscale_service.get_status()
  450. if ts_status.available:
  451. ts_result = await self._cert_service.use_tailscale_cert(ts_status.fqdn, tailscale_service)
  452. if ts_result:
  453. self.tailscale_fqdn = ts_status.fqdn
  454. logger.info("[VP %s] Using Tailscale cert for %s", self.name, ts_status.fqdn)
  455. return ts_result[0], ts_result[1], ts_status.fqdn
  456. logger.warning(
  457. "[VP %s] Tailscale available (%s) but cert provisioning failed, falling back to self-signed cert",
  458. self.name,
  459. ts_status.fqdn,
  460. )
  461. else:
  462. logger.info(
  463. "[VP %s] Tailscale not available (%s), using self-signed cert",
  464. self.name,
  465. ts_status.error or "not connected",
  466. )
  467. except Exception as e:
  468. logger.warning("[VP %s] Tailscale cert check failed, falling back to self-signed: %s", self.name, e)
  469. self.tailscale_fqdn = None
  470. cert_path, key_path = self.generate_certificates()
  471. advertise = self.remote_interface_ip or self.bind_ip or ""
  472. return cert_path, key_path, advertise
  473. async def start_server(self) -> None:
  474. """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
  475. logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
  476. cert_path, key_path, advertise_addr = await self._resolve_cert_and_advertise()
  477. bind_addr = self.bind_ip or "0.0.0.0" # nosec B104
  478. async def run_with_logging(coro, svc_name):
  479. try:
  480. await coro
  481. except Exception as e:
  482. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  483. self._tasks = []
  484. # FTP server
  485. self._ftp = VirtualPrinterFTPServer(
  486. upload_dir=self.upload_dir,
  487. access_code=self.access_code,
  488. cert_path=cert_path,
  489. key_path=key_path,
  490. on_file_received=self.on_file_received,
  491. bind_address=bind_addr,
  492. vp_name=self.name,
  493. )
  494. self._tasks.append(
  495. asyncio.create_task(
  496. run_with_logging(self._ftp.start(), "FTP"),
  497. name=f"vp_{self.id}_ftp",
  498. )
  499. )
  500. # MQTT server
  501. self._mqtt = SimpleMQTTServer(
  502. serial=self.serial,
  503. access_code=self.access_code,
  504. cert_path=cert_path,
  505. key_path=key_path,
  506. on_print_command=self.on_print_command,
  507. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  508. bind_address=bind_addr,
  509. vp_name=self.name,
  510. )
  511. self._tasks.append(
  512. asyncio.create_task(
  513. run_with_logging(self._mqtt.start(), "MQTT"),
  514. name=f"vp_{self.id}_mqtt",
  515. )
  516. )
  517. # Bind server
  518. self._bind = BindServer(
  519. serial=self.serial,
  520. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  521. name=self.name,
  522. bind_address=bind_addr,
  523. cert_path=cert_path,
  524. key_path=key_path,
  525. )
  526. self._tasks.append(
  527. asyncio.create_task(
  528. run_with_logging(self._bind.start(), "Bind"),
  529. name=f"vp_{self.id}_bind",
  530. )
  531. )
  532. # SSDP server — advertise_addr is the Tailscale FQDN when available,
  533. # otherwise the bind/remote IP (existing behaviour)
  534. self._ssdp = VirtualPrinterSSDPServer(
  535. name=self.name,
  536. serial=self.serial,
  537. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  538. advertise_ip=advertise_addr,
  539. bind_ip=bind_addr,
  540. )
  541. self._tasks.append(
  542. asyncio.create_task(
  543. run_with_logging(self._ssdp.start(), "SSDP"),
  544. name=f"vp_{self.id}_ssdp",
  545. )
  546. )
  547. # Guard against double-start: cancel any orphaned task before creating a new one
  548. await self._cancel_renewal_task()
  549. self._cert_renewal_task = asyncio.create_task(self._cert_renewal_loop(), name=f"vp_{self.id}_cert_renewal")
  550. logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
  551. async def stop_server(self) -> None:
  552. """Stop server-mode services."""
  553. await self._cancel_renewal_task()
  554. await self._cancel_restart_task()
  555. if self._ftp:
  556. await self._ftp.stop()
  557. self._ftp = None
  558. if self._mqtt:
  559. await self._mqtt.stop()
  560. self._mqtt = None
  561. if self._bind:
  562. await self._bind.stop()
  563. self._bind = None
  564. if self._ssdp:
  565. await self._ssdp.stop()
  566. self._ssdp = None
  567. await self._cancel_tasks()
  568. async def start_proxy(self) -> None:
  569. """Start proxy mode services for this instance."""
  570. logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
  571. cert_path, key_path, _ = await self._resolve_cert_and_advertise()
  572. self._proxy = SlicerProxyManager(
  573. target_host=self.target_printer_ip,
  574. cert_path=cert_path,
  575. key_path=key_path,
  576. on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
  577. bind_address=self.bind_ip or "0.0.0.0", # nosec B104
  578. bind_identity={
  579. "serial": self.target_printer_serial or self.serial,
  580. "model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  581. "name": self.name,
  582. "version": "01.00.00.00",
  583. },
  584. )
  585. async def run_with_logging(coro, svc_name):
  586. try:
  587. await coro
  588. except Exception as e:
  589. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  590. self._tasks = []
  591. # SSDP for proxy
  592. proxy_serial = self.target_printer_serial or self.serial
  593. if self.remote_interface_ip:
  594. from backend.app.services.network_utils import find_interface_for_ip
  595. local_iface = find_interface_for_ip(self.target_printer_ip)
  596. if local_iface:
  597. self._ssdp_proxy = SSDPProxy(
  598. local_interface_ip=local_iface["ip"],
  599. remote_interface_ip=self.remote_interface_ip,
  600. target_printer_ip=self.target_printer_ip,
  601. name=self.name,
  602. )
  603. self._tasks.append(
  604. asyncio.create_task(
  605. run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
  606. name=f"vp_{self.id}_ssdp_proxy",
  607. )
  608. )
  609. else:
  610. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  611. else:
  612. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  613. self._tasks.append(
  614. asyncio.create_task(
  615. run_with_logging(self._proxy.start(), "Proxy"),
  616. name=f"vp_{self.id}_proxy",
  617. )
  618. )
  619. # Guard against double-start: cancel any orphaned task before creating a new one
  620. await self._cancel_renewal_task()
  621. self._cert_renewal_task = asyncio.create_task(self._cert_renewal_loop(), name=f"vp_{self.id}_cert_renewal")
  622. def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
  623. """Start single-interface SSDP server as fallback for proxy mode."""
  624. self._ssdp = VirtualPrinterSSDPServer(
  625. name=f"{self.name} (Proxy)",
  626. serial=proxy_serial,
  627. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  628. advertise_ip=self.bind_ip or "",
  629. bind_ip=self.bind_ip or "",
  630. )
  631. self._tasks.append(
  632. asyncio.create_task(
  633. run_with_logging(self._ssdp.start(), "SSDP"),
  634. name=f"vp_{self.id}_ssdp",
  635. )
  636. )
  637. async def stop_proxy(self) -> None:
  638. """Stop proxy mode services for this instance."""
  639. await self._cancel_renewal_task()
  640. await self._cancel_restart_task()
  641. if self._proxy:
  642. await self._proxy.stop()
  643. self._proxy = None
  644. if self._ssdp:
  645. await self._ssdp.stop()
  646. self._ssdp = None
  647. if self._ssdp_proxy:
  648. await self._ssdp_proxy.stop()
  649. self._ssdp_proxy = None
  650. await self._cancel_tasks()
  651. async def _cancel_tasks(self) -> None:
  652. """Cancel all running tasks and wait for cleanup."""
  653. for task in self._tasks:
  654. task.cancel()
  655. if self._tasks:
  656. try:
  657. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  658. except TimeoutError:
  659. pass
  660. self._tasks = []
  661. def get_status(self) -> dict:
  662. """Get status for this instance."""
  663. status: dict = {
  664. "running": self.is_running,
  665. "pending_files": len(self._pending_files),
  666. }
  667. if self.tailscale_fqdn:
  668. status["tailscale_fqdn"] = self.tailscale_fqdn
  669. if self.is_proxy and self._proxy:
  670. status["proxy"] = self._proxy.get_status()
  671. return status
  672. class VirtualPrinterManager:
  673. """Multi-instance virtual printer registry and orchestrator.
  674. Every VP runs its own independent services on a dedicated bind IP.
  675. """
  676. def __init__(self):
  677. self._session_factory: Callable | None = None
  678. self._instances: dict[int, VirtualPrinterInstance] = {}
  679. # Directories
  680. self._base_dir = app_settings.base_dir / "virtual_printer"
  681. # Ensure base directories exist
  682. self._ensure_base_directories()
  683. def _ensure_base_directories(self) -> None:
  684. """Create base directories at startup."""
  685. for dir_path in [self._base_dir, self._base_dir / "uploads", self._base_dir / "certs"]:
  686. try:
  687. dir_path.mkdir(parents=True, exist_ok=True)
  688. except PermissionError:
  689. logger.error(
  690. f"Cannot create directory {dir_path}: Permission denied. "
  691. f"For Docker: ensure the data volume is writable by the container user. "
  692. f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
  693. )
  694. def set_session_factory(self, session_factory: Callable) -> None:
  695. """Set the database session factory."""
  696. self._session_factory = session_factory
  697. @property
  698. def is_enabled(self) -> bool:
  699. """Check if any virtual printer is running."""
  700. return len(self._instances) > 0
  701. async def sync_from_db(self) -> None:
  702. """Load all VPs from DB, reconcile running state."""
  703. if not self._session_factory:
  704. logger.warning("Cannot sync virtual printers: no session factory")
  705. return
  706. from sqlalchemy import select
  707. from backend.app.models.printer import Printer
  708. from backend.app.models.virtual_printer import VirtualPrinter
  709. async with self._session_factory() as db:
  710. result = await db.execute(
  711. select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position) # noqa: E712
  712. )
  713. enabled_vps = result.scalars().all()
  714. # Stop instances that are no longer enabled or changed mode
  715. enabled_ids = {vp.id for vp in enabled_vps}
  716. for vp_id in list(self._instances.keys()):
  717. if vp_id not in enabled_ids:
  718. await self.remove_instance(vp_id)
  719. # Look up printer IPs for proxy VPs
  720. proxy_vps = [vp for vp in enabled_vps if vp.mode == "proxy"]
  721. proxy_ips: dict[int, tuple[str, str]] = {}
  722. if proxy_vps:
  723. async with self._session_factory() as db:
  724. for pvp in proxy_vps:
  725. if pvp.target_printer_id:
  726. result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))
  727. printer = result.scalar_one_or_none()
  728. if printer:
  729. proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
  730. # Detect config changes on running instances and restart if needed
  731. for vp in enabled_vps:
  732. instance = self._instances.get(vp.id)
  733. if not instance:
  734. continue
  735. changed = (
  736. instance.mode != vp.mode
  737. or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
  738. or instance.access_code != (vp.access_code or "")
  739. or instance.bind_ip != (vp.bind_ip or "")
  740. or instance.remote_interface_ip != (vp.remote_interface_ip or "")
  741. or instance.target_printer_id != vp.target_printer_id
  742. or instance.auto_dispatch != vp.auto_dispatch
  743. or instance.tailscale_disabled != vp.tailscale_disabled
  744. )
  745. if changed:
  746. logger.info(
  747. "VP %s config changed (mode: %s→%s), restarting",
  748. instance.name,
  749. instance.mode,
  750. vp.mode,
  751. )
  752. await self.remove_instance(vp.id)
  753. # Start instances for all enabled VPs (skip already running)
  754. for vp in enabled_vps:
  755. if vp.id in self._instances:
  756. continue
  757. if vp.mode == "proxy":
  758. ip_info = proxy_ips.get(vp.id)
  759. if not ip_info:
  760. logger.warning("Proxy VP %s: target printer not found, skipping", vp.name)
  761. continue
  762. target_ip, target_serial = ip_info
  763. instance = VirtualPrinterInstance(
  764. vp_id=vp.id,
  765. name=vp.name,
  766. mode=vp.mode,
  767. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  768. access_code=vp.access_code or "",
  769. serial_suffix=vp.serial_suffix,
  770. target_printer_ip=target_ip,
  771. target_printer_serial=target_serial,
  772. auto_dispatch=vp.auto_dispatch,
  773. bind_ip=vp.bind_ip or "",
  774. remote_interface_ip=vp.remote_interface_ip or "",
  775. tailscale_disabled=vp.tailscale_disabled,
  776. base_dir=self._base_dir,
  777. session_factory=self._session_factory,
  778. )
  779. self._instances[vp.id] = instance
  780. await instance.start_proxy()
  781. logger.info("Started proxy VP: %s → %s (bind=%s)", instance.name, target_ip, instance.bind_ip)
  782. else:
  783. instance = VirtualPrinterInstance(
  784. vp_id=vp.id,
  785. name=vp.name,
  786. mode=vp.mode,
  787. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  788. access_code=vp.access_code or "",
  789. serial_suffix=vp.serial_suffix,
  790. target_printer_id=vp.target_printer_id,
  791. auto_dispatch=vp.auto_dispatch,
  792. bind_ip=vp.bind_ip or "",
  793. remote_interface_ip=vp.remote_interface_ip or "",
  794. tailscale_disabled=vp.tailscale_disabled,
  795. base_dir=self._base_dir,
  796. session_factory=self._session_factory,
  797. )
  798. self._instances[vp.id] = instance
  799. await instance.start_server()
  800. logger.info("Started server-mode VP: %s on %s", instance.name, vp.bind_ip)
  801. async def remove_instance(self, vp_id: int) -> None:
  802. """Stop and remove a single VP instance."""
  803. instance = self._instances.pop(vp_id, None)
  804. if instance:
  805. if instance.is_proxy:
  806. await instance.stop_proxy()
  807. else:
  808. await instance.stop_server()
  809. logger.info("Removed VP instance: %s", instance.name)
  810. async def stop_all(self) -> None:
  811. """Shutdown all virtual printer services."""
  812. logger.info("Stopping all virtual printer services...")
  813. for vp_id in list(self._instances.keys()):
  814. await self.remove_instance(vp_id)
  815. logger.info("All virtual printer services stopped")
  816. def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:
  817. """Get a running instance by ID."""
  818. return self._instances.get(vp_id)
  819. def get_all_status(self) -> list[dict]:
  820. """Get status for all running instances."""
  821. return [
  822. {
  823. "id": inst.id,
  824. "name": inst.name,
  825. "mode": inst.mode,
  826. **inst.get_status(),
  827. }
  828. for inst in self._instances.values()
  829. ]
  830. # -- Legacy single-printer compat --
  831. def get_status(self) -> dict:
  832. """Get status for first virtual printer (backward compat)."""
  833. if self._instances:
  834. first = next(iter(self._instances.values()))
  835. return {
  836. "enabled": True,
  837. "running": first.is_running,
  838. "mode": first.mode,
  839. "name": first.name,
  840. "serial": first.serial,
  841. "model": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  842. "model_name": VIRTUAL_PRINTER_MODELS.get(
  843. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  844. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  845. ),
  846. "pending_files": first.get_status().get("pending_files", 0),
  847. **({"target_printer_ip": first.target_printer_ip} if first.is_proxy else {}),
  848. **({"proxy": first.get_status().get("proxy", {})} if first.is_proxy else {}),
  849. }
  850. return {
  851. "enabled": False,
  852. "running": False,
  853. "mode": "immediate",
  854. "name": "Bambuddy",
  855. "serial": "",
  856. "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
  857. "model_name": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],
  858. "pending_files": 0,
  859. }
  860. async def configure(
  861. self,
  862. enabled: bool,
  863. access_code: str = "",
  864. mode: str = "immediate",
  865. model: str = "",
  866. target_printer_ip: str = "",
  867. target_printer_serial: str = "",
  868. remote_interface_ip: str = "",
  869. ) -> None:
  870. """Legacy single-printer configure. Delegates to sync_from_db()."""
  871. # This method is kept for backward compat with the settings endpoint.
  872. # The actual work is done by sync_from_db() which reads from the DB.
  873. await self.sync_from_db()
  874. # Global instance
  875. virtual_printer_manager = VirtualPrinterManager()