manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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. VIRTUAL_PRINTER_MODELS = {
  16. # X1 Series
  17. "BL-P001": "X1C", # X1 Carbon
  18. "BL-P002": "X1", # X1
  19. "BL-P003": "X1E", # X1E
  20. # P Series
  21. "C11": "P1S", # P1S
  22. "C12": "P1P", # P1P
  23. "C13": "P2S", # P2S
  24. # A1 Series
  25. "N2S": "A1", # A1
  26. "N1": "A1 Mini", # A1 Mini
  27. # H2 Series
  28. "O1D": "H2D", # H2D
  29. "O1C": "H2C", # H2C
  30. "O1S": "H2S", # H2S
  31. }
  32. # Default model
  33. DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
  34. class VirtualPrinterManager:
  35. """Manages the virtual printer lifecycle and coordinates all services."""
  36. # Fixed configuration
  37. PRINTER_NAME = "Bambuddy"
  38. PRINTER_SERIAL = "00M09A391800001" # X1C serial format
  39. def __init__(self):
  40. """Initialize the virtual printer manager."""
  41. self._session_factory: Callable | None = None
  42. self._enabled = False
  43. self._access_code = ""
  44. self._mode = "immediate"
  45. self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
  46. # Service instances
  47. self._ssdp: VirtualPrinterSSDPServer | None = None
  48. self._ftp: VirtualPrinterFTPServer | None = None
  49. self._mqtt: SimpleMQTTServer | None = None
  50. # Background tasks
  51. self._tasks: list[asyncio.Task] = []
  52. # Directories
  53. self._base_dir = app_settings.base_dir / "virtual_printer"
  54. self._upload_dir = self._base_dir / "uploads"
  55. self._cert_dir = self._base_dir / "certs"
  56. # Certificate service - pass serial to match CN in certificate
  57. self._cert_service = CertificateService(self._cert_dir, serial=self.PRINTER_SERIAL)
  58. # Track pending uploads for MQTT correlation
  59. self._pending_files: dict[str, Path] = {}
  60. def set_session_factory(self, session_factory: Callable) -> None:
  61. """Set the database session factory.
  62. Args:
  63. session_factory: Async context manager for database sessions
  64. """
  65. self._session_factory = session_factory
  66. @property
  67. def is_enabled(self) -> bool:
  68. """Check if virtual printer is enabled."""
  69. return self._enabled
  70. @property
  71. def is_running(self) -> bool:
  72. """Check if virtual printer services are running."""
  73. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  74. async def configure(
  75. self,
  76. enabled: bool,
  77. access_code: str = "",
  78. mode: str = "immediate",
  79. model: str = "",
  80. ) -> None:
  81. """Configure and start/stop virtual printer.
  82. Args:
  83. enabled: Whether to enable the virtual printer
  84. access_code: Authentication password for slicer connections
  85. mode: Archive mode - 'immediate' or 'queue'
  86. model: SSDP model code (e.g., 'BL-P001' for X1C)
  87. """
  88. if enabled and not access_code:
  89. raise ValueError("Access code is required when enabling virtual printer")
  90. # Validate model if provided
  91. new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
  92. model_changed = new_model != self._model
  93. self._access_code = access_code
  94. self._mode = mode
  95. self._model = new_model
  96. if enabled and not self._enabled:
  97. await self._start()
  98. elif not enabled and self._enabled:
  99. await self._stop()
  100. elif enabled and self._enabled and model_changed:
  101. # Model changed while running - restart services
  102. await self._stop()
  103. await self._start()
  104. self._enabled = enabled
  105. async def _start(self) -> None:
  106. """Start all virtual printer services."""
  107. logger.info("Starting virtual printer services...")
  108. # Ensure certificates exist
  109. cert_path, key_path = self._cert_service.ensure_certificates()
  110. # Create directories
  111. self._upload_dir.mkdir(parents=True, exist_ok=True)
  112. (self._upload_dir / "cache").mkdir(exist_ok=True)
  113. # Initialize services
  114. self._ssdp = VirtualPrinterSSDPServer(
  115. name=self.PRINTER_NAME,
  116. serial=self.PRINTER_SERIAL,
  117. model=self._model,
  118. )
  119. self._ftp = VirtualPrinterFTPServer(
  120. upload_dir=self._upload_dir,
  121. access_code=self._access_code,
  122. cert_path=cert_path,
  123. key_path=key_path,
  124. on_file_received=self._on_file_received,
  125. )
  126. self._mqtt = SimpleMQTTServer(
  127. serial=self.PRINTER_SERIAL,
  128. access_code=self._access_code,
  129. cert_path=cert_path,
  130. key_path=key_path,
  131. on_print_command=self._on_print_command,
  132. )
  133. # Start services as background tasks
  134. # Wrap each in error handler so one failure doesn't stop others
  135. async def run_with_logging(coro, name):
  136. try:
  137. await coro
  138. except Exception as e:
  139. logger.error(f"Virtual printer {name} failed: {e}")
  140. self._tasks = [
  141. asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
  142. asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
  143. asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
  144. ]
  145. logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.PRINTER_SERIAL})")
  146. async def _stop(self) -> None:
  147. """Stop all virtual printer services."""
  148. logger.info("Stopping virtual printer services...")
  149. # Cancel all tasks
  150. for task in self._tasks:
  151. task.cancel()
  152. try:
  153. await task
  154. except asyncio.CancelledError:
  155. pass
  156. self._tasks = []
  157. # Stop services
  158. if self._ssdp:
  159. await self._ssdp.stop()
  160. self._ssdp = None
  161. if self._ftp:
  162. await self._ftp.stop()
  163. self._ftp = None
  164. if self._mqtt:
  165. await self._mqtt.stop()
  166. self._mqtt = None
  167. logger.info("Virtual printer stopped")
  168. async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
  169. """Handle file upload completion from FTP.
  170. Args:
  171. file_path: Path to uploaded file
  172. source_ip: IP address of the uploading slicer
  173. """
  174. logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}")
  175. # Store file reference for MQTT correlation
  176. self._pending_files[file_path.name] = file_path
  177. # In immediate mode, archive right away
  178. # In queue mode, create pending upload record
  179. if self._mode == "immediate":
  180. await self._archive_file(file_path, source_ip)
  181. else:
  182. await self._queue_file(file_path, source_ip)
  183. async def _on_print_command(self, filename: str, data: dict) -> None:
  184. """Handle print command from MQTT.
  185. In a real printer, this would start the print. For virtual printer,
  186. we just log it since archiving is handled by file upload.
  187. Args:
  188. filename: Name of the file to print
  189. data: Print command data (contains settings like timelapse, bed_leveling, etc.)
  190. """
  191. logger.info(f"Virtual printer received print command for: {filename}")
  192. logger.debug(f"Print command data: {data}")
  193. # The file should already be archived from FTP upload
  194. # This command just confirms the slicer's intent to "print"
  195. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  196. """Archive file immediately.
  197. Args:
  198. file_path: Path to the 3MF file
  199. source_ip: IP address of uploader
  200. """
  201. if not self._session_factory:
  202. logger.error("Cannot archive: no database session factory configured")
  203. return
  204. # Only archive 3MF files
  205. if file_path.suffix.lower() != ".3mf":
  206. logger.debug(f"Skipping non-3MF file: {file_path.name}")
  207. # Remove from pending and clean up
  208. self._pending_files.pop(file_path.name, None)
  209. try:
  210. file_path.unlink()
  211. except Exception:
  212. pass
  213. return
  214. try:
  215. from backend.app.services.archive import ArchiveService
  216. async with self._session_factory() as db:
  217. service = ArchiveService(db)
  218. # Archive the print
  219. archive = await service.archive_print(
  220. printer_id=None, # No physical printer
  221. source_file=file_path,
  222. print_data={
  223. "status": "archived",
  224. "source": "virtual_printer",
  225. "source_ip": source_ip,
  226. },
  227. )
  228. if archive:
  229. logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
  230. # Clean up uploaded file (it's now copied to archive)
  231. try:
  232. file_path.unlink()
  233. except Exception:
  234. pass
  235. # Remove from pending
  236. self._pending_files.pop(file_path.name, None)
  237. else:
  238. logger.error(f"Failed to archive file: {file_path.name}")
  239. except Exception as e:
  240. logger.error(f"Error archiving file: {e}")
  241. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  242. """Queue file for user review.
  243. Args:
  244. file_path: Path to the 3MF file
  245. source_ip: IP address of uploader
  246. """
  247. if not self._session_factory:
  248. logger.error("Cannot queue: no database session factory configured")
  249. return
  250. # Only queue 3MF files
  251. if file_path.suffix.lower() != ".3mf":
  252. logger.warning(f"Skipping non-3MF file: {file_path.name}")
  253. return
  254. try:
  255. from backend.app.models.pending_upload import PendingUpload
  256. async with self._session_factory() as db:
  257. pending = PendingUpload(
  258. filename=file_path.name,
  259. file_path=str(file_path),
  260. file_size=file_path.stat().st_size,
  261. source_ip=source_ip,
  262. status="pending",
  263. uploaded_at=datetime.now(UTC),
  264. )
  265. db.add(pending)
  266. await db.commit()
  267. logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
  268. # Remove from pending files dict
  269. self._pending_files.pop(file_path.name, None)
  270. except Exception as e:
  271. logger.error(f"Error queueing file: {e}")
  272. def get_status(self) -> dict:
  273. """Get virtual printer status.
  274. Returns:
  275. Status dictionary with enabled, running, mode, etc.
  276. """
  277. return {
  278. "enabled": self._enabled,
  279. "running": self.is_running,
  280. "mode": self._mode,
  281. "name": self.PRINTER_NAME,
  282. "serial": self.PRINTER_SERIAL,
  283. "model": self._model,
  284. "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
  285. "pending_files": len(self._pending_files),
  286. }
  287. # Global instance
  288. virtual_printer_manager = VirtualPrinterManager()