manager.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. """Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
  2. Supports multiple modes:
  3. - immediate: Archive uploads immediately
  4. - review: Queue uploads for user review before archiving
  5. - print_queue: Archive and add to print queue (unassigned)
  6. - proxy: Transparent TCP proxy to a real printer (for remote slicer access)
  7. """
  8. import asyncio
  9. import logging
  10. from collections.abc import Callable
  11. from datetime import UTC, datetime
  12. from pathlib import Path
  13. from backend.app.core.config import settings as app_settings
  14. from backend.app.services.virtual_printer.certificate import CertificateService
  15. from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
  16. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  17. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  18. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  19. logger = logging.getLogger(__name__)
  20. # Mapping of SSDP model codes to display names
  21. # These are the codes that slicers expect during discovery
  22. # Sources:
  23. # - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  24. # - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
  25. VIRTUAL_PRINTER_MODELS = {
  26. # X1 Series
  27. "3DPrinter-X1-Carbon": "X1C", # X1 Carbon
  28. "3DPrinter-X1": "X1", # X1
  29. "C13": "X1E", # X1E
  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. "O1S": "H2S", # H2S
  41. }
  42. # Serial number prefixes for each model (based on Bambu Lab serial number format)
  43. # Format: MMM??RYMDDUUUUU (15 chars total)
  44. # MMM = Model prefix (3 chars)
  45. # ?? = Unknown/revision code (2 chars)
  46. # R = Revision letter (1 char)
  47. # Y = Year digit (1 char)
  48. # M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
  49. # DD = Day (2 chars)
  50. # UUUUU = Unit number (5 chars)
  51. MODEL_SERIAL_PREFIXES = {
  52. # X1 Series
  53. "3DPrinter-X1-Carbon": "00M00A", # X1C
  54. "3DPrinter-X1": "00M00A", # X1
  55. "C13": "03W00A", # X1E
  56. # P Series
  57. "C11": "01S00A", # P1P
  58. "C12": "01P00A", # P1S
  59. "N7": "22E00A", # P2S
  60. # A1 Series
  61. "N2S": "03900A", # A1
  62. "N1": "03000A", # A1 Mini
  63. # H2 Series
  64. "O1D": "09400A", # H2D
  65. "O1C": "09400A", # H2C
  66. "O1S": "09400A", # H2S
  67. }
  68. # Default model
  69. DEFAULT_VIRTUAL_PRINTER_MODEL = "3DPrinter-X1-Carbon" # X1C
  70. class VirtualPrinterManager:
  71. """Manages the virtual printer lifecycle and coordinates all services."""
  72. # Fixed configuration
  73. PRINTER_NAME = "Bambuddy"
  74. SERIAL_SUFFIX = "391800001" # Fixed suffix for virtual printer
  75. def __init__(self):
  76. """Initialize the virtual printer manager."""
  77. self._session_factory: Callable | None = None
  78. self._enabled = False
  79. self._access_code = ""
  80. self._mode = "immediate"
  81. self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
  82. self._target_printer_ip = "" # For proxy mode
  83. self._target_printer_serial = "" # For proxy mode (real printer's serial)
  84. # Service instances
  85. self._ssdp: VirtualPrinterSSDPServer | None = None
  86. self._ftp: VirtualPrinterFTPServer | None = None
  87. self._mqtt: SimpleMQTTServer | None = None
  88. self._proxy: SlicerProxyManager | None = None # For proxy mode
  89. # Background tasks
  90. self._tasks: list[asyncio.Task] = []
  91. # Directories
  92. self._base_dir = app_settings.base_dir / "virtual_printer"
  93. self._upload_dir = self._base_dir / "uploads"
  94. self._cert_dir = self._base_dir / "certs"
  95. # Certificate service
  96. self._cert_service = CertificateService(self._cert_dir)
  97. # Track pending uploads for MQTT correlation
  98. self._pending_files: dict[str, Path] = {}
  99. def _get_serial_for_model(self, model: str) -> str:
  100. """Get appropriate serial number for the given model.
  101. Args:
  102. model: SSDP model code (e.g., 'BL-P001', 'C11')
  103. Returns:
  104. Serial number with correct prefix for the model
  105. """
  106. prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
  107. return f"{prefix}{self.SERIAL_SUFFIX}"
  108. @property
  109. def printer_serial(self) -> str:
  110. """Get the current printer serial number based on model."""
  111. return self._get_serial_for_model(self._model)
  112. def set_session_factory(self, session_factory: Callable) -> None:
  113. """Set the database session factory.
  114. Args:
  115. session_factory: Async context manager for database sessions
  116. """
  117. self._session_factory = session_factory
  118. @property
  119. def is_enabled(self) -> bool:
  120. """Check if virtual printer is enabled."""
  121. return self._enabled
  122. @property
  123. def is_running(self) -> bool:
  124. """Check if virtual printer services are running."""
  125. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  126. async def configure(
  127. self,
  128. enabled: bool,
  129. access_code: str = "",
  130. mode: str = "immediate",
  131. model: str = "",
  132. target_printer_ip: str = "",
  133. target_printer_serial: str = "",
  134. ) -> None:
  135. """Configure and start/stop virtual printer.
  136. Args:
  137. enabled: Whether to enable the virtual printer
  138. access_code: Authentication password for slicer connections
  139. mode: Archive mode - 'immediate', 'review', 'print_queue', or 'proxy'
  140. model: SSDP model code (e.g., 'BL-P001' for X1C)
  141. target_printer_ip: Target printer IP for proxy mode
  142. target_printer_serial: Target printer serial for proxy mode
  143. """
  144. # Proxy mode has different requirements
  145. if mode == "proxy":
  146. if enabled and not target_printer_ip:
  147. raise ValueError("Target printer IP is required for proxy mode")
  148. # Access code not required for proxy mode (uses printer's credentials)
  149. else:
  150. if enabled and not access_code:
  151. raise ValueError("Access code is required when enabling virtual printer")
  152. # Validate model if provided
  153. new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
  154. model_changed = new_model != self._model
  155. mode_changed = mode != self._mode
  156. target_changed = target_printer_ip != self._target_printer_ip
  157. serial_changed = target_printer_serial != self._target_printer_serial
  158. old_mode = self._mode
  159. logger.debug(
  160. f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
  161. f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
  162. f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
  163. )
  164. self._access_code = access_code
  165. self._mode = mode
  166. self._model = new_model
  167. self._target_printer_ip = target_printer_ip
  168. self._target_printer_serial = target_printer_serial
  169. needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
  170. if enabled and not self._enabled:
  171. logger.info("Starting virtual printer (was disabled)")
  172. await self._start()
  173. elif not enabled and self._enabled:
  174. logger.info("Stopping virtual printer (was enabled)")
  175. await self._stop()
  176. elif enabled and self._enabled and needs_restart:
  177. # Configuration changed while running - restart services
  178. logger.info(f"Configuration changed (mode={old_mode}→{mode}), restarting...")
  179. await self._stop()
  180. # Give time for ports to be released
  181. await asyncio.sleep(0.5)
  182. await self._start()
  183. logger.info("Virtual printer restarted with new configuration")
  184. else:
  185. logger.debug(f"No state change needed (enabled={enabled}, self._enabled={self._enabled})")
  186. self._enabled = enabled
  187. async def _start(self) -> None:
  188. """Start all virtual printer services."""
  189. logger.info(f"Starting virtual printer services (mode={self._mode})...")
  190. # Proxy mode uses different services
  191. if self._mode == "proxy":
  192. await self._start_proxy_mode()
  193. return
  194. # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
  195. await self._start_server_mode()
  196. async def _start_proxy_mode(self) -> None:
  197. """Start virtual printer in proxy mode (TLS terminating relay)."""
  198. logger.info(f"Starting proxy mode to {self._target_printer_ip}")
  199. # In proxy mode, use the REAL printer's serial number
  200. # This ensures MQTT topic subscriptions match the real printer's topics
  201. proxy_serial = self._target_printer_serial or self.printer_serial
  202. logger.info(f"Proxy mode using serial: {proxy_serial}")
  203. # Update certificate service with the real printer's serial
  204. self._cert_service.serial = proxy_serial
  205. # Regenerate printer cert if needed (CA is preserved)
  206. self._cert_service.delete_printer_certificate()
  207. cert_path, key_path = self._cert_service.generate_certificates()
  208. logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
  209. # Initialize SSDP for local discovery using the real printer's serial
  210. self._ssdp = VirtualPrinterSSDPServer(
  211. name=f"{self.PRINTER_NAME} (Proxy)",
  212. serial=proxy_serial,
  213. model=self._model,
  214. )
  215. # Initialize TLS proxy with our certificates
  216. self._proxy = SlicerProxyManager(
  217. target_host=self._target_printer_ip,
  218. cert_path=cert_path,
  219. key_path=key_path,
  220. on_activity=self._on_proxy_activity,
  221. )
  222. # Start services as background tasks
  223. async def run_with_logging(coro, name):
  224. try:
  225. await coro
  226. except Exception as e:
  227. logger.error(f"Virtual printer {name} failed: {e}")
  228. self._tasks = [
  229. asyncio.create_task(
  230. run_with_logging(self._ssdp.start(), "SSDP"),
  231. name="virtual_printer_ssdp",
  232. ),
  233. asyncio.create_task(
  234. run_with_logging(self._proxy.start(), "Proxy"),
  235. name="virtual_printer_proxy",
  236. ),
  237. ]
  238. logger.info(
  239. f"Virtual printer proxy started: "
  240. f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
  241. f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
  242. )
  243. async def _start_server_mode(self) -> None:
  244. """Start virtual printer in server mode (FTP/MQTT servers)."""
  245. # Update certificate service with current serial (based on model)
  246. current_serial = self.printer_serial
  247. self._cert_service.serial = current_serial
  248. # Regenerate printer cert if serial changed (CA is preserved)
  249. self._cert_service.delete_printer_certificate()
  250. cert_path, key_path = self._cert_service.generate_certificates()
  251. logger.info(f"Generated certificate for serial: {current_serial}")
  252. # Create directories
  253. self._upload_dir.mkdir(parents=True, exist_ok=True)
  254. (self._upload_dir / "cache").mkdir(exist_ok=True)
  255. # Initialize services
  256. self._ssdp = VirtualPrinterSSDPServer(
  257. name=self.PRINTER_NAME,
  258. serial=self.printer_serial,
  259. model=self._model,
  260. )
  261. self._ftp = VirtualPrinterFTPServer(
  262. upload_dir=self._upload_dir,
  263. access_code=self._access_code,
  264. cert_path=cert_path,
  265. key_path=key_path,
  266. on_file_received=self._on_file_received,
  267. )
  268. self._mqtt = SimpleMQTTServer(
  269. serial=self.printer_serial,
  270. access_code=self._access_code,
  271. cert_path=cert_path,
  272. key_path=key_path,
  273. on_print_command=self._on_print_command,
  274. )
  275. # Start services as background tasks
  276. # Wrap each in error handler so one failure doesn't stop others
  277. async def run_with_logging(coro, name):
  278. try:
  279. await coro
  280. except Exception as e:
  281. logger.error(f"Virtual printer {name} failed: {e}")
  282. self._tasks = [
  283. asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
  284. asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
  285. asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
  286. ]
  287. logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.printer_serial})")
  288. def _on_proxy_activity(self, name: str, message: str) -> None:
  289. """Handle proxy activity for logging."""
  290. logger.info(f"Proxy {name}: {message}")
  291. async def _stop(self) -> None:
  292. """Stop all virtual printer services."""
  293. logger.info("Stopping virtual printer services...")
  294. # Stop services first - this closes servers and cancels active sessions
  295. if self._ftp:
  296. await self._ftp.stop()
  297. self._ftp = None
  298. if self._mqtt:
  299. await self._mqtt.stop()
  300. self._mqtt = None
  301. if self._ssdp:
  302. await self._ssdp.stop()
  303. self._ssdp = None
  304. if self._proxy:
  305. await self._proxy.stop()
  306. self._proxy = None
  307. # Cancel remaining tasks with short timeout
  308. for task in self._tasks:
  309. task.cancel()
  310. if self._tasks:
  311. try:
  312. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  313. except TimeoutError:
  314. logger.debug("Some tasks didn't stop in time")
  315. self._tasks = []
  316. logger.info("Virtual printer stopped")
  317. async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
  318. """Handle file upload completion from FTP.
  319. Args:
  320. file_path: Path to uploaded file
  321. source_ip: IP address of the uploading slicer
  322. """
  323. logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}")
  324. # Store file reference for MQTT correlation
  325. self._pending_files[file_path.name] = file_path
  326. # Handle based on mode:
  327. # - immediate: archive right away
  328. # - review: create pending upload record for user review before archiving
  329. # - print_queue: archive and add to print queue (unassigned)
  330. if self._mode == "immediate":
  331. await self._archive_file(file_path, source_ip)
  332. elif self._mode == "print_queue":
  333. await self._add_to_print_queue(file_path, source_ip)
  334. else:
  335. # "review" mode (or legacy "queue" mode)
  336. await self._queue_file(file_path, source_ip)
  337. async def _on_print_command(self, filename: str, data: dict) -> None:
  338. """Handle print command from MQTT.
  339. In a real printer, this would start the print. For virtual printer,
  340. we just log it since archiving is handled by file upload.
  341. Args:
  342. filename: Name of the file to print
  343. data: Print command data (contains settings like timelapse, bed_leveling, etc.)
  344. """
  345. logger.info(f"Virtual printer received print command for: {filename}")
  346. logger.debug(f"Print command data: {data}")
  347. # The file should already be archived from FTP upload
  348. # This command just confirms the slicer's intent to "print"
  349. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  350. """Archive file immediately.
  351. Args:
  352. file_path: Path to the 3MF file
  353. source_ip: IP address of uploader
  354. """
  355. if not self._session_factory:
  356. logger.error("Cannot archive: no database session factory configured")
  357. return
  358. # Only archive 3MF files
  359. if file_path.suffix.lower() != ".3mf":
  360. logger.debug(f"Skipping non-3MF file: {file_path.name}")
  361. # Remove from pending and clean up
  362. self._pending_files.pop(file_path.name, None)
  363. try:
  364. file_path.unlink()
  365. except Exception:
  366. pass
  367. return
  368. try:
  369. from backend.app.services.archive import ArchiveService
  370. async with self._session_factory() as db:
  371. service = ArchiveService(db)
  372. # Archive the print
  373. archive = await service.archive_print(
  374. printer_id=None, # No physical printer
  375. source_file=file_path,
  376. print_data={
  377. "status": "archived",
  378. "source": "virtual_printer",
  379. "source_ip": source_ip,
  380. },
  381. )
  382. if archive:
  383. logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
  384. # Clean up uploaded file (it's now copied to archive)
  385. try:
  386. file_path.unlink()
  387. except Exception:
  388. pass
  389. # Remove from pending
  390. self._pending_files.pop(file_path.name, None)
  391. else:
  392. logger.error(f"Failed to archive file: {file_path.name}")
  393. except Exception as e:
  394. logger.error(f"Error archiving file: {e}")
  395. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  396. """Queue file for user review.
  397. Args:
  398. file_path: Path to the 3MF file
  399. source_ip: IP address of uploader
  400. """
  401. if not self._session_factory:
  402. logger.error("Cannot queue: no database session factory configured")
  403. return
  404. # Only queue 3MF files
  405. if file_path.suffix.lower() != ".3mf":
  406. logger.warning(f"Skipping non-3MF file: {file_path.name}")
  407. return
  408. try:
  409. from backend.app.models.pending_upload import PendingUpload
  410. async with self._session_factory() as db:
  411. pending = PendingUpload(
  412. filename=file_path.name,
  413. file_path=str(file_path),
  414. file_size=file_path.stat().st_size,
  415. source_ip=source_ip,
  416. status="pending",
  417. uploaded_at=datetime.now(UTC),
  418. )
  419. db.add(pending)
  420. await db.commit()
  421. logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
  422. # Remove from pending files dict
  423. self._pending_files.pop(file_path.name, None)
  424. except Exception as e:
  425. logger.error(f"Error queueing file: {e}")
  426. async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
  427. """Archive file and add to print queue (unassigned).
  428. Args:
  429. file_path: Path to the 3MF file
  430. source_ip: IP address of uploader
  431. """
  432. if not self._session_factory:
  433. logger.error("Cannot add to print queue: no database session factory configured")
  434. return
  435. # Only process 3MF files
  436. if file_path.suffix.lower() != ".3mf":
  437. logger.debug(f"Skipping non-3MF file: {file_path.name}")
  438. self._pending_files.pop(file_path.name, None)
  439. try:
  440. file_path.unlink()
  441. except Exception:
  442. pass
  443. return
  444. try:
  445. from backend.app.models.print_queue import PrintQueueItem
  446. from backend.app.services.archive import ArchiveService
  447. async with self._session_factory() as db:
  448. service = ArchiveService(db)
  449. # First, archive the print
  450. archive = await service.archive_print(
  451. printer_id=None, # No physical printer
  452. source_file=file_path,
  453. print_data={
  454. "status": "archived",
  455. "source": "virtual_printer",
  456. "source_ip": source_ip,
  457. },
  458. )
  459. if archive:
  460. logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
  461. # Now add to print queue (unassigned)
  462. queue_item = PrintQueueItem(
  463. printer_id=None, # Unassigned - user will assign later
  464. archive_id=archive.id,
  465. position=1, # Will be adjusted when assigned to a printer
  466. status="pending",
  467. )
  468. db.add(queue_item)
  469. await db.commit()
  470. logger.info(f"Added to print queue (unassigned): queue_id={queue_item.id}, archive_id={archive.id}")
  471. # Clean up uploaded file (it's now copied to archive)
  472. try:
  473. file_path.unlink()
  474. except Exception:
  475. pass
  476. # Remove from pending
  477. self._pending_files.pop(file_path.name, None)
  478. else:
  479. logger.error(f"Failed to archive file: {file_path.name}")
  480. except Exception as e:
  481. logger.error(f"Error adding to print queue: {e}")
  482. def get_status(self) -> dict:
  483. """Get virtual printer status.
  484. Returns:
  485. Status dictionary with enabled, running, mode, etc.
  486. """
  487. status = {
  488. "enabled": self._enabled,
  489. "running": self.is_running,
  490. "mode": self._mode,
  491. "name": self.PRINTER_NAME,
  492. "serial": self.printer_serial,
  493. "model": self._model,
  494. "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
  495. "pending_files": len(self._pending_files),
  496. }
  497. # Add proxy-specific status
  498. if self._mode == "proxy":
  499. status["target_printer_ip"] = self._target_printer_ip
  500. if self._proxy:
  501. proxy_status = self._proxy.get_status()
  502. status["proxy"] = proxy_status
  503. return status
  504. # Global instance
  505. virtual_printer_manager = VirtualPrinterManager()