manager.py 11 KB

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