manager.py 41 KB

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