manager.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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. # In immediate mode, archive right away
  241. # In queue mode, create pending upload record
  242. if self._mode == "immediate":
  243. await self._archive_file(file_path, source_ip)
  244. else:
  245. await self._queue_file(file_path, source_ip)
  246. async def _on_print_command(self, filename: str, data: dict) -> None:
  247. """Handle print command from MQTT.
  248. In a real printer, this would start the print. For virtual printer,
  249. we just log it since archiving is handled by file upload.
  250. Args:
  251. filename: Name of the file to print
  252. data: Print command data (contains settings like timelapse, bed_leveling, etc.)
  253. """
  254. logger.info(f"Virtual printer received print command for: {filename}")
  255. logger.debug(f"Print command data: {data}")
  256. # The file should already be archived from FTP upload
  257. # This command just confirms the slicer's intent to "print"
  258. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  259. """Archive file immediately.
  260. Args:
  261. file_path: Path to the 3MF file
  262. source_ip: IP address of uploader
  263. """
  264. if not self._session_factory:
  265. logger.error("Cannot archive: no database session factory configured")
  266. return
  267. # Only archive 3MF files
  268. if file_path.suffix.lower() != ".3mf":
  269. logger.debug(f"Skipping non-3MF file: {file_path.name}")
  270. # Remove from pending and clean up
  271. self._pending_files.pop(file_path.name, None)
  272. try:
  273. file_path.unlink()
  274. except Exception:
  275. pass
  276. return
  277. try:
  278. from backend.app.services.archive import ArchiveService
  279. async with self._session_factory() as db:
  280. service = ArchiveService(db)
  281. # Archive the print
  282. archive = await service.archive_print(
  283. printer_id=None, # No physical printer
  284. source_file=file_path,
  285. print_data={
  286. "status": "archived",
  287. "source": "virtual_printer",
  288. "source_ip": source_ip,
  289. },
  290. )
  291. if archive:
  292. logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
  293. # Clean up uploaded file (it's now copied to archive)
  294. try:
  295. file_path.unlink()
  296. except Exception:
  297. pass
  298. # Remove from pending
  299. self._pending_files.pop(file_path.name, None)
  300. else:
  301. logger.error(f"Failed to archive file: {file_path.name}")
  302. except Exception as e:
  303. logger.error(f"Error archiving file: {e}")
  304. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  305. """Queue file for user review.
  306. Args:
  307. file_path: Path to the 3MF file
  308. source_ip: IP address of uploader
  309. """
  310. if not self._session_factory:
  311. logger.error("Cannot queue: no database session factory configured")
  312. return
  313. # Only queue 3MF files
  314. if file_path.suffix.lower() != ".3mf":
  315. logger.warning(f"Skipping non-3MF file: {file_path.name}")
  316. return
  317. try:
  318. from backend.app.models.pending_upload import PendingUpload
  319. async with self._session_factory() as db:
  320. pending = PendingUpload(
  321. filename=file_path.name,
  322. file_path=str(file_path),
  323. file_size=file_path.stat().st_size,
  324. source_ip=source_ip,
  325. status="pending",
  326. uploaded_at=datetime.now(UTC),
  327. )
  328. db.add(pending)
  329. await db.commit()
  330. logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
  331. # Remove from pending files dict
  332. self._pending_files.pop(file_path.name, None)
  333. except Exception as e:
  334. logger.error(f"Error queueing file: {e}")
  335. def get_status(self) -> dict:
  336. """Get virtual printer status.
  337. Returns:
  338. Status dictionary with enabled, running, mode, etc.
  339. """
  340. return {
  341. "enabled": self._enabled,
  342. "running": self.is_running,
  343. "mode": self._mode,
  344. "name": self.PRINTER_NAME,
  345. "serial": self.printer_serial,
  346. "model": self._model,
  347. "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
  348. "pending_files": len(self._pending_files),
  349. }
  350. # Global instance
  351. virtual_printer_manager = VirtualPrinterManager()