manager.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  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.tcp_proxy import SlicerProxyManager
  17. logger = logging.getLogger(__name__)
  18. # Mapping of SSDP model codes to display names
  19. # These are the codes that slicers expect during discovery
  20. # Sources:
  21. # - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  22. # - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
  23. VIRTUAL_PRINTER_MODELS = {
  24. # X1 Series
  25. "BL-P001": "X1C", # X1 Carbon
  26. "BL-P002": "X1", # X1
  27. "C13": "X1E", # X1E
  28. # X2 Series
  29. "N6": "X2D", # X2D
  30. # P Series
  31. "C11": "P1P", # P1P
  32. "C12": "P1S", # P1S
  33. "N7": "P2S", # P2S
  34. # A1 Series
  35. "N2S": "A1", # A1
  36. "N1": "A1 Mini", # A1 Mini
  37. # H2 Series
  38. "O1D": "H2D", # H2D
  39. "O1C": "H2C", # H2C
  40. "O1C2": "H2C", # H2C (dual nozzle variant)
  41. "O1S": "H2S", # H2S
  42. }
  43. # Serial number prefixes for each model (based on Bambu Lab serial number format)
  44. # Format: MMM??RYMDDUUUUU (15 chars total)
  45. # MMM = Model prefix (3 chars)
  46. # ?? = Unknown/revision code (2 chars)
  47. # R = Revision letter (1 char)
  48. # Y = Year digit (1 char)
  49. # M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
  50. # DD = Day (2 chars)
  51. # UUUUU = Unit number (5 chars)
  52. MODEL_SERIAL_PREFIXES = {
  53. # X1 Series
  54. "BL-P001": "00M00A", # X1C
  55. "BL-P002": "00M00A", # X1
  56. "C13": "03W00A", # X1E
  57. # X2 Series
  58. "N6": "20P90A", # X2D (first 4 chars "20P9" match real serials)
  59. # P Series
  60. "C11": "01S00A", # P1P
  61. "C12": "01P00A", # P1S
  62. "N7": "22E00A", # P2S
  63. # A1 Series
  64. "N2S": "03900A", # A1
  65. "N1": "03000A", # A1 Mini
  66. # H2 Series
  67. "O1D": "09400A", # H2D
  68. "O1C": "09400A", # H2C
  69. "O1C2": "09400A", # H2C (dual nozzle variant)
  70. "O1S": "09400A", # H2S
  71. }
  72. # Reverse mapping: display name → SSDP model code (for auto-inheriting from printer model)
  73. DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}
  74. # Default model
  75. DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
  76. def _get_serial_for_model(model: str, serial_suffix: str) -> str:
  77. """Get serial number for the given model and suffix."""
  78. prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
  79. return f"{prefix}{serial_suffix}"
  80. class VirtualPrinterInstance:
  81. """Per-printer state and file handling logic.
  82. Each instance represents one virtual printer with its own config,
  83. upload directory, certificates, and file handling mode.
  84. """
  85. def __init__(
  86. self,
  87. *,
  88. vp_id: int,
  89. name: str,
  90. mode: str,
  91. model: str,
  92. access_code: str,
  93. serial_suffix: str,
  94. target_printer_ip: str = "",
  95. target_printer_serial: str = "",
  96. target_printer_id: int | None = None,
  97. auto_dispatch: bool = True,
  98. bind_ip: str = "",
  99. remote_interface_ip: str = "",
  100. base_dir: Path,
  101. session_factory: Callable | None = None,
  102. ):
  103. self.id = vp_id
  104. self.name = name
  105. self.mode = mode
  106. self.model = model
  107. self.access_code = access_code
  108. self.serial_suffix = serial_suffix
  109. self.target_printer_ip = target_printer_ip
  110. self.target_printer_serial = target_printer_serial
  111. self.target_printer_id = target_printer_id
  112. self.auto_dispatch = auto_dispatch
  113. self.bind_ip = bind_ip
  114. self.remote_interface_ip = remote_interface_ip
  115. self._session_factory = session_factory
  116. # Directories
  117. self.upload_dir = base_dir / "uploads" / str(vp_id)
  118. self.cert_dir = base_dir / "certs" / str(vp_id)
  119. shared_ca_dir = base_dir / "certs"
  120. # Ensure directories exist
  121. self.upload_dir.mkdir(parents=True, exist_ok=True)
  122. (self.upload_dir / "cache").mkdir(exist_ok=True)
  123. self.cert_dir.mkdir(parents=True, exist_ok=True)
  124. # Certificate service (shared CA, per-instance printer cert)
  125. self._cert_service = CertificateService(
  126. cert_dir=self.cert_dir,
  127. serial=self.serial,
  128. shared_ca_dir=shared_ca_dir,
  129. )
  130. # Pending files for MQTT correlation
  131. self._pending_files: dict[str, Path] = {}
  132. # Per-instance services
  133. self._proxy: SlicerProxyManager | None = None
  134. self._ftp: VirtualPrinterFTPServer | None = None
  135. self._mqtt: SimpleMQTTServer | None = None
  136. self._bind: BindServer | None = None
  137. self._ssdp: VirtualPrinterSSDPServer | None = None
  138. self._ssdp_proxy: SSDPProxy | None = None
  139. self._tasks: list[asyncio.Task] = []
  140. @property
  141. def serial(self) -> str:
  142. """Full serial number for this virtual printer."""
  143. return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)
  144. @property
  145. def cert_path(self) -> Path:
  146. return self._cert_service.cert_path
  147. @property
  148. def key_path(self) -> Path:
  149. return self._cert_service.key_path
  150. @property
  151. def is_proxy(self) -> bool:
  152. return self.mode == "proxy"
  153. @property
  154. def is_running(self) -> bool:
  155. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  156. def generate_certificates(self) -> tuple[Path, Path]:
  157. """Generate certificates for this instance."""
  158. self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)
  159. additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None
  160. if self.bind_ip:
  161. additional_ips = additional_ips or []
  162. additional_ips.append(self.bind_ip)
  163. self._cert_service.delete_printer_certificate()
  164. return self._cert_service.generate_certificates(additional_ips=additional_ips)
  165. # -- File handling callbacks --
  166. async def on_file_received(self, file_path: Path, source_ip: str) -> None:
  167. """Handle file upload completion from FTP."""
  168. logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
  169. self._pending_files[file_path.name] = file_path
  170. if self.mode == "immediate":
  171. await self._archive_file(file_path, source_ip)
  172. elif self.mode == "print_queue":
  173. await self._add_to_print_queue(file_path, source_ip)
  174. else:
  175. await self._queue_file(file_path, source_ip)
  176. # Reset MQTT status back to IDLE
  177. if self._mqtt and file_path.suffix.lower() == ".3mf":
  178. self._mqtt.set_gcode_state("IDLE")
  179. async def on_print_command(self, filename: str, data: dict) -> None:
  180. """Handle print command from MQTT."""
  181. logger.info("[VP %s] Print command for: %s", self.name, filename)
  182. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  183. """Archive file immediately."""
  184. if not self._session_factory:
  185. logger.error("Cannot archive: no database session factory configured")
  186. return
  187. if file_path.suffix.lower() != ".3mf":
  188. logger.debug("Skipping non-3MF file: %s", file_path.name)
  189. self._pending_files.pop(file_path.name, None)
  190. try:
  191. file_path.unlink()
  192. except OSError:
  193. pass
  194. return
  195. try:
  196. from backend.app.services.archive import ArchiveService
  197. async with self._session_factory() as db:
  198. service = ArchiveService(db)
  199. archive = await service.archive_print(
  200. printer_id=None,
  201. source_file=file_path,
  202. print_data={
  203. "status": "archived",
  204. "source": "virtual_printer",
  205. "source_ip": source_ip,
  206. },
  207. )
  208. if archive:
  209. logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
  210. try:
  211. file_path.unlink()
  212. except OSError:
  213. pass
  214. self._pending_files.pop(file_path.name, None)
  215. else:
  216. logger.error("Failed to archive file: %s", file_path.name)
  217. except Exception as e:
  218. logger.error("Error archiving file: %s", e)
  219. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  220. """Queue file for user review."""
  221. if not self._session_factory:
  222. logger.error("Cannot queue: no database session factory configured")
  223. return
  224. if file_path.suffix.lower() != ".3mf":
  225. self._pending_files.pop(file_path.name, None)
  226. try:
  227. file_path.unlink()
  228. except OSError:
  229. pass
  230. return
  231. try:
  232. from backend.app.models.pending_upload import PendingUpload
  233. async with self._session_factory() as db:
  234. pending = PendingUpload(
  235. filename=file_path.name,
  236. file_path=str(file_path),
  237. file_size=file_path.stat().st_size,
  238. source_ip=source_ip,
  239. status="pending",
  240. uploaded_at=datetime.now(timezone.utc),
  241. )
  242. db.add(pending)
  243. await db.commit()
  244. logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
  245. self._pending_files.pop(file_path.name, None)
  246. except Exception as e:
  247. logger.error("Error queueing file: %s", e)
  248. async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
  249. """Archive file and add to print queue, assigned to target printer or model."""
  250. if not self._session_factory:
  251. logger.error("Cannot add to print queue: no database session factory configured")
  252. return
  253. if file_path.suffix.lower() != ".3mf":
  254. self._pending_files.pop(file_path.name, None)
  255. try:
  256. file_path.unlink()
  257. except OSError:
  258. pass
  259. return
  260. try:
  261. from backend.app.models.print_queue import PrintQueueItem
  262. from backend.app.services.archive import ArchiveService
  263. async with self._session_factory() as db:
  264. service = ArchiveService(db)
  265. archive = await service.archive_print(
  266. printer_id=None,
  267. source_file=file_path,
  268. print_data={
  269. "status": "archived",
  270. "source": "virtual_printer",
  271. "source_ip": source_ip,
  272. },
  273. )
  274. if archive:
  275. logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
  276. # Assign to specific printer if configured, otherwise use model for "Any X" scheduling
  277. target_model = None
  278. if not self.target_printer_id and self.model:
  279. target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
  280. plate_id = self._extract_plate_id(file_path)
  281. queue_item = PrintQueueItem(
  282. printer_id=self.target_printer_id,
  283. target_model=target_model,
  284. archive_id=archive.id,
  285. plate_id=plate_id,
  286. position=1,
  287. status="pending",
  288. manual_start=not self.auto_dispatch,
  289. )
  290. db.add(queue_item)
  291. await db.commit()
  292. logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
  293. try:
  294. file_path.unlink()
  295. except OSError:
  296. pass
  297. self._pending_files.pop(file_path.name, None)
  298. else:
  299. logger.error("Failed to archive file: %s", file_path.name)
  300. except Exception as e:
  301. logger.error("Error adding to print queue: %s", e)
  302. @staticmethod
  303. def _extract_plate_id(file_path: Path) -> int | None:
  304. """Extract plate index from 3MF slice_info.config."""
  305. try:
  306. import xml.etree.ElementTree as ET
  307. import zipfile
  308. with zipfile.ZipFile(file_path, "r") as zf:
  309. if "Metadata/slice_info.config" in zf.namelist():
  310. content = zf.read("Metadata/slice_info.config").decode()
  311. root = ET.fromstring(content) # noqa: S314 # nosec B314
  312. plate = root.find(".//plate")
  313. if plate is not None:
  314. for meta in plate.findall("metadata"):
  315. if meta.get("key") == "index" and meta.get("value"):
  316. return int(meta.get("value"))
  317. except Exception:
  318. return None
  319. return None
  320. # -- Service lifecycle --
  321. async def start_server(self) -> None:
  322. """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
  323. logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
  324. cert_path, key_path = self.generate_certificates()
  325. bind_addr = self.bind_ip or "0.0.0.0" # nosec B104
  326. async def run_with_logging(coro, svc_name):
  327. try:
  328. await coro
  329. except Exception as e:
  330. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  331. self._tasks = []
  332. # FTP server
  333. self._ftp = VirtualPrinterFTPServer(
  334. upload_dir=self.upload_dir,
  335. access_code=self.access_code,
  336. cert_path=cert_path,
  337. key_path=key_path,
  338. on_file_received=self.on_file_received,
  339. bind_address=bind_addr,
  340. vp_name=self.name,
  341. )
  342. self._tasks.append(
  343. asyncio.create_task(
  344. run_with_logging(self._ftp.start(), "FTP"),
  345. name=f"vp_{self.id}_ftp",
  346. )
  347. )
  348. # MQTT server
  349. self._mqtt = SimpleMQTTServer(
  350. serial=self.serial,
  351. access_code=self.access_code,
  352. cert_path=cert_path,
  353. key_path=key_path,
  354. on_print_command=self.on_print_command,
  355. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  356. bind_address=bind_addr,
  357. vp_name=self.name,
  358. )
  359. self._tasks.append(
  360. asyncio.create_task(
  361. run_with_logging(self._mqtt.start(), "MQTT"),
  362. name=f"vp_{self.id}_mqtt",
  363. )
  364. )
  365. # Bind server
  366. self._bind = BindServer(
  367. serial=self.serial,
  368. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  369. name=self.name,
  370. bind_address=bind_addr,
  371. cert_path=cert_path,
  372. key_path=key_path,
  373. )
  374. self._tasks.append(
  375. asyncio.create_task(
  376. run_with_logging(self._bind.start(), "Bind"),
  377. name=f"vp_{self.id}_bind",
  378. )
  379. )
  380. # SSDP server
  381. self._ssdp = VirtualPrinterSSDPServer(
  382. name=self.name,
  383. serial=self.serial,
  384. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  385. advertise_ip=self.remote_interface_ip or self.bind_ip or "",
  386. bind_ip=bind_addr,
  387. )
  388. self._tasks.append(
  389. asyncio.create_task(
  390. run_with_logging(self._ssdp.start(), "SSDP"),
  391. name=f"vp_{self.id}_ssdp",
  392. )
  393. )
  394. logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
  395. async def stop_server(self) -> None:
  396. """Stop server-mode services."""
  397. if self._ftp:
  398. await self._ftp.stop()
  399. self._ftp = None
  400. if self._mqtt:
  401. await self._mqtt.stop()
  402. self._mqtt = None
  403. if self._bind:
  404. await self._bind.stop()
  405. self._bind = None
  406. if self._ssdp:
  407. await self._ssdp.stop()
  408. self._ssdp = None
  409. await self._cancel_tasks()
  410. async def start_proxy(self) -> None:
  411. """Start proxy mode services for this instance."""
  412. logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
  413. cert_path, key_path = self.generate_certificates()
  414. self._proxy = SlicerProxyManager(
  415. target_host=self.target_printer_ip,
  416. cert_path=cert_path,
  417. key_path=key_path,
  418. on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
  419. bind_address=self.bind_ip or "0.0.0.0", # nosec B104
  420. bind_identity={
  421. "serial": self.target_printer_serial or self.serial,
  422. "model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  423. "name": self.name,
  424. "version": "01.00.00.00",
  425. },
  426. )
  427. async def run_with_logging(coro, svc_name):
  428. try:
  429. await coro
  430. except Exception as e:
  431. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  432. self._tasks = []
  433. # SSDP for proxy
  434. proxy_serial = self.target_printer_serial or self.serial
  435. if self.remote_interface_ip:
  436. from backend.app.services.network_utils import find_interface_for_ip
  437. local_iface = find_interface_for_ip(self.target_printer_ip)
  438. if local_iface:
  439. self._ssdp_proxy = SSDPProxy(
  440. local_interface_ip=local_iface["ip"],
  441. remote_interface_ip=self.remote_interface_ip,
  442. target_printer_ip=self.target_printer_ip,
  443. name=self.name,
  444. )
  445. self._tasks.append(
  446. asyncio.create_task(
  447. run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
  448. name=f"vp_{self.id}_ssdp_proxy",
  449. )
  450. )
  451. else:
  452. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  453. else:
  454. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  455. self._tasks.append(
  456. asyncio.create_task(
  457. run_with_logging(self._proxy.start(), "Proxy"),
  458. name=f"vp_{self.id}_proxy",
  459. )
  460. )
  461. def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
  462. """Start single-interface SSDP server as fallback for proxy mode."""
  463. self._ssdp = VirtualPrinterSSDPServer(
  464. name=f"{self.name} (Proxy)",
  465. serial=proxy_serial,
  466. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  467. advertise_ip=self.bind_ip or "",
  468. bind_ip=self.bind_ip or "",
  469. )
  470. self._tasks.append(
  471. asyncio.create_task(
  472. run_with_logging(self._ssdp.start(), "SSDP"),
  473. name=f"vp_{self.id}_ssdp",
  474. )
  475. )
  476. async def stop_proxy(self) -> None:
  477. """Stop proxy mode services for this instance."""
  478. if self._proxy:
  479. await self._proxy.stop()
  480. self._proxy = None
  481. if self._ssdp:
  482. await self._ssdp.stop()
  483. self._ssdp = None
  484. if self._ssdp_proxy:
  485. await self._ssdp_proxy.stop()
  486. self._ssdp_proxy = None
  487. await self._cancel_tasks()
  488. async def _cancel_tasks(self) -> None:
  489. """Cancel all running tasks and wait for cleanup."""
  490. for task in self._tasks:
  491. task.cancel()
  492. if self._tasks:
  493. try:
  494. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  495. except TimeoutError:
  496. pass
  497. self._tasks = []
  498. def get_status(self) -> dict:
  499. """Get status for this instance."""
  500. status: dict = {
  501. "running": self.is_running,
  502. "pending_files": len(self._pending_files),
  503. }
  504. if self.is_proxy and self._proxy:
  505. status["proxy"] = self._proxy.get_status()
  506. return status
  507. class VirtualPrinterManager:
  508. """Multi-instance virtual printer registry and orchestrator.
  509. Every VP runs its own independent services on a dedicated bind IP.
  510. """
  511. def __init__(self):
  512. self._session_factory: Callable | None = None
  513. self._instances: dict[int, VirtualPrinterInstance] = {}
  514. # Directories
  515. self._base_dir = app_settings.base_dir / "virtual_printer"
  516. # Ensure base directories exist
  517. self._ensure_base_directories()
  518. def _ensure_base_directories(self) -> None:
  519. """Create base directories at startup."""
  520. for dir_path in [self._base_dir, self._base_dir / "uploads", self._base_dir / "certs"]:
  521. try:
  522. dir_path.mkdir(parents=True, exist_ok=True)
  523. except PermissionError:
  524. logger.error(
  525. f"Cannot create directory {dir_path}: Permission denied. "
  526. f"For Docker: ensure the data volume is writable by the container user. "
  527. f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
  528. )
  529. def set_session_factory(self, session_factory: Callable) -> None:
  530. """Set the database session factory."""
  531. self._session_factory = session_factory
  532. @property
  533. def is_enabled(self) -> bool:
  534. """Check if any virtual printer is running."""
  535. return len(self._instances) > 0
  536. async def sync_from_db(self) -> None:
  537. """Load all VPs from DB, reconcile running state."""
  538. if not self._session_factory:
  539. logger.warning("Cannot sync virtual printers: no session factory")
  540. return
  541. from sqlalchemy import select
  542. from backend.app.models.printer import Printer
  543. from backend.app.models.virtual_printer import VirtualPrinter
  544. async with self._session_factory() as db:
  545. result = await db.execute(
  546. select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position) # noqa: E712
  547. )
  548. enabled_vps = result.scalars().all()
  549. # Stop instances that are no longer enabled or changed mode
  550. enabled_ids = {vp.id for vp in enabled_vps}
  551. for vp_id in list(self._instances.keys()):
  552. if vp_id not in enabled_ids:
  553. await self.remove_instance(vp_id)
  554. # Look up printer IPs for proxy VPs
  555. proxy_vps = [vp for vp in enabled_vps if vp.mode == "proxy"]
  556. proxy_ips: dict[int, tuple[str, str]] = {}
  557. if proxy_vps:
  558. async with self._session_factory() as db:
  559. for pvp in proxy_vps:
  560. if pvp.target_printer_id:
  561. result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))
  562. printer = result.scalar_one_or_none()
  563. if printer:
  564. proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
  565. # Detect config changes on running instances and restart if needed
  566. for vp in enabled_vps:
  567. instance = self._instances.get(vp.id)
  568. if not instance:
  569. continue
  570. changed = (
  571. instance.mode != vp.mode
  572. or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
  573. or instance.access_code != (vp.access_code or "")
  574. or instance.bind_ip != (vp.bind_ip or "")
  575. or instance.remote_interface_ip != (vp.remote_interface_ip or "")
  576. or instance.target_printer_id != vp.target_printer_id
  577. or instance.auto_dispatch != vp.auto_dispatch
  578. )
  579. if changed:
  580. logger.info(
  581. "VP %s config changed (mode: %s→%s), restarting",
  582. instance.name,
  583. instance.mode,
  584. vp.mode,
  585. )
  586. await self.remove_instance(vp.id)
  587. # Start instances for all enabled VPs (skip already running)
  588. for vp in enabled_vps:
  589. if vp.id in self._instances:
  590. continue
  591. if vp.mode == "proxy":
  592. ip_info = proxy_ips.get(vp.id)
  593. if not ip_info:
  594. logger.warning("Proxy VP %s: target printer not found, skipping", vp.name)
  595. continue
  596. target_ip, target_serial = ip_info
  597. instance = VirtualPrinterInstance(
  598. vp_id=vp.id,
  599. name=vp.name,
  600. mode=vp.mode,
  601. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  602. access_code=vp.access_code or "",
  603. serial_suffix=vp.serial_suffix,
  604. target_printer_ip=target_ip,
  605. target_printer_serial=target_serial,
  606. auto_dispatch=vp.auto_dispatch,
  607. bind_ip=vp.bind_ip or "",
  608. remote_interface_ip=vp.remote_interface_ip or "",
  609. base_dir=self._base_dir,
  610. session_factory=self._session_factory,
  611. )
  612. self._instances[vp.id] = instance
  613. await instance.start_proxy()
  614. logger.info("Started proxy VP: %s → %s (bind=%s)", instance.name, target_ip, instance.bind_ip)
  615. else:
  616. instance = VirtualPrinterInstance(
  617. vp_id=vp.id,
  618. name=vp.name,
  619. mode=vp.mode,
  620. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  621. access_code=vp.access_code or "",
  622. serial_suffix=vp.serial_suffix,
  623. target_printer_id=vp.target_printer_id,
  624. auto_dispatch=vp.auto_dispatch,
  625. bind_ip=vp.bind_ip or "",
  626. remote_interface_ip=vp.remote_interface_ip or "",
  627. base_dir=self._base_dir,
  628. session_factory=self._session_factory,
  629. )
  630. self._instances[vp.id] = instance
  631. await instance.start_server()
  632. logger.info("Started server-mode VP: %s on %s", instance.name, vp.bind_ip)
  633. async def remove_instance(self, vp_id: int) -> None:
  634. """Stop and remove a single VP instance."""
  635. instance = self._instances.pop(vp_id, None)
  636. if instance:
  637. if instance.is_proxy:
  638. await instance.stop_proxy()
  639. else:
  640. await instance.stop_server()
  641. logger.info("Removed VP instance: %s", instance.name)
  642. async def stop_all(self) -> None:
  643. """Shutdown all virtual printer services."""
  644. logger.info("Stopping all virtual printer services...")
  645. for vp_id in list(self._instances.keys()):
  646. await self.remove_instance(vp_id)
  647. logger.info("All virtual printer services stopped")
  648. def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:
  649. """Get a running instance by ID."""
  650. return self._instances.get(vp_id)
  651. def get_all_status(self) -> list[dict]:
  652. """Get status for all running instances."""
  653. return [
  654. {
  655. "id": inst.id,
  656. "name": inst.name,
  657. "mode": inst.mode,
  658. **inst.get_status(),
  659. }
  660. for inst in self._instances.values()
  661. ]
  662. # -- Legacy single-printer compat --
  663. def get_status(self) -> dict:
  664. """Get status for first virtual printer (backward compat)."""
  665. if self._instances:
  666. first = next(iter(self._instances.values()))
  667. return {
  668. "enabled": True,
  669. "running": first.is_running,
  670. "mode": first.mode,
  671. "name": first.name,
  672. "serial": first.serial,
  673. "model": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  674. "model_name": VIRTUAL_PRINTER_MODELS.get(
  675. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  676. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  677. ),
  678. "pending_files": first.get_status().get("pending_files", 0),
  679. **({"target_printer_ip": first.target_printer_ip} if first.is_proxy else {}),
  680. **({"proxy": first.get_status().get("proxy", {})} if first.is_proxy else {}),
  681. }
  682. return {
  683. "enabled": False,
  684. "running": False,
  685. "mode": "immediate",
  686. "name": "Bambuddy",
  687. "serial": "",
  688. "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
  689. "model_name": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],
  690. "pending_files": 0,
  691. }
  692. async def configure(
  693. self,
  694. enabled: bool,
  695. access_code: str = "",
  696. mode: str = "immediate",
  697. model: str = "",
  698. target_printer_ip: str = "",
  699. target_printer_serial: str = "",
  700. remote_interface_ip: str = "",
  701. ) -> None:
  702. """Legacy single-printer configure. Delegates to sync_from_db()."""
  703. # This method is kept for backward compat with the settings endpoint.
  704. # The actual work is done by sync_from_db() which reads from the DB.
  705. await self.sync_from_db()
  706. # Global instance
  707. virtual_printer_manager = VirtualPrinterManager()