manager.py 18 KB

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