manager.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  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 datetime, timezone
  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 SSDPProxy, 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. self._remote_interface_ip = "" # For proxy mode SSDP (LAN B - slicer network)
  85. # Service instances
  86. self._ssdp: VirtualPrinterSSDPServer | None = None
  87. self._ssdp_proxy: SSDPProxy | None = None
  88. self._ftp: VirtualPrinterFTPServer | None = None
  89. self._mqtt: SimpleMQTTServer | None = None
  90. self._proxy: SlicerProxyManager | None = None # For proxy mode
  91. # Background tasks
  92. self._tasks: list[asyncio.Task] = []
  93. # Directories
  94. self._base_dir = app_settings.base_dir / "virtual_printer"
  95. self._upload_dir = self._base_dir / "uploads"
  96. self._cert_dir = self._base_dir / "certs"
  97. # Create directories early to avoid permission issues later
  98. # If running in Docker, these need to be on a writable volume
  99. self._ensure_directories()
  100. # Certificate service
  101. self._cert_service = CertificateService(self._cert_dir)
  102. # Track pending uploads for MQTT correlation
  103. self._pending_files: dict[str, Path] = {}
  104. def _ensure_directories(self) -> None:
  105. """Create and verify virtual printer directories are writable.
  106. Creates all required directories at startup to catch permission
  107. issues early rather than when the user tries to enable features.
  108. """
  109. dirs_to_create = [
  110. self._base_dir,
  111. self._upload_dir,
  112. self._upload_dir / "cache",
  113. self._cert_dir,
  114. ]
  115. logger.info("Checking virtual printer directories in %s", self._base_dir)
  116. for dir_path in dirs_to_create:
  117. try:
  118. dir_path.mkdir(parents=True, exist_ok=True)
  119. except PermissionError:
  120. logger.error(
  121. f"Cannot create directory {dir_path}: Permission denied. "
  122. f"For Docker: ensure the data volume is writable by the container user. "
  123. f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
  124. )
  125. continue
  126. # Verify directory is writable by attempting to create a test file
  127. test_file = dir_path / ".write_test"
  128. try:
  129. test_file.touch()
  130. test_file.unlink(missing_ok=True)
  131. except PermissionError:
  132. logger.error(
  133. f"Directory {dir_path} exists but is not writable. "
  134. f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
  135. f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
  136. )
  137. def _get_serial_for_model(self, model: str) -> str:
  138. """Get appropriate serial number for the given model.
  139. Args:
  140. model: SSDP model code (e.g., 'BL-P001', 'C11')
  141. Returns:
  142. Serial number with correct prefix for the model
  143. """
  144. prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
  145. return f"{prefix}{self.SERIAL_SUFFIX}"
  146. @property
  147. def printer_serial(self) -> str:
  148. """Get the current printer serial number based on model."""
  149. return self._get_serial_for_model(self._model)
  150. def set_session_factory(self, session_factory: Callable) -> None:
  151. """Set the database session factory.
  152. Args:
  153. session_factory: Async context manager for database sessions
  154. """
  155. self._session_factory = session_factory
  156. @property
  157. def is_enabled(self) -> bool:
  158. """Check if virtual printer is enabled."""
  159. return self._enabled
  160. @property
  161. def is_running(self) -> bool:
  162. """Check if virtual printer services are running."""
  163. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  164. async def configure(
  165. self,
  166. enabled: bool,
  167. access_code: str = "",
  168. mode: str = "immediate",
  169. model: str = "",
  170. target_printer_ip: str = "",
  171. target_printer_serial: str = "",
  172. remote_interface_ip: str = "",
  173. ) -> None:
  174. """Configure and start/stop virtual printer.
  175. Args:
  176. enabled: Whether to enable the virtual printer
  177. access_code: Authentication password for slicer connections
  178. mode: Archive mode - 'immediate', 'review', 'print_queue', or 'proxy'
  179. model: SSDP model code (e.g., 'BL-P001' for X1C)
  180. target_printer_ip: Target printer IP for proxy mode
  181. target_printer_serial: Target printer serial for proxy mode
  182. remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
  183. """
  184. # Proxy mode has different requirements
  185. if mode == "proxy":
  186. if enabled and not target_printer_ip:
  187. raise ValueError("Target printer IP is required for proxy mode")
  188. # Access code not required for proxy mode (uses printer's credentials)
  189. else:
  190. if enabled and not access_code:
  191. raise ValueError("Access code is required when enabling virtual printer")
  192. # Validate model if provided
  193. new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
  194. model_changed = new_model != self._model
  195. mode_changed = mode != self._mode
  196. target_changed = target_printer_ip != self._target_printer_ip
  197. serial_changed = target_printer_serial != self._target_printer_serial
  198. remote_iface_changed = remote_interface_ip != self._remote_interface_ip
  199. old_mode = self._mode
  200. logger.debug(
  201. f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
  202. f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
  203. f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
  204. f"remote_interface_ip={remote_interface_ip}"
  205. )
  206. self._access_code = access_code
  207. self._mode = mode
  208. self._model = new_model
  209. self._target_printer_ip = target_printer_ip
  210. self._target_printer_serial = target_printer_serial
  211. self._remote_interface_ip = remote_interface_ip
  212. needs_restart = (
  213. model_changed
  214. or mode_changed
  215. or remote_iface_changed
  216. or (mode == "proxy" and (target_changed or serial_changed))
  217. )
  218. if enabled and not self._enabled:
  219. logger.info("Starting virtual printer (was disabled)")
  220. await self._start()
  221. elif not enabled and self._enabled:
  222. logger.info("Stopping virtual printer (was enabled)")
  223. await self._stop()
  224. elif enabled and self._enabled and needs_restart:
  225. # Configuration changed while running - restart services
  226. logger.info("Configuration changed (mode=%s→%s), restarting...", old_mode, mode)
  227. await self._stop()
  228. # Give time for ports to be released
  229. await asyncio.sleep(0.5)
  230. await self._start()
  231. logger.info("Virtual printer restarted with new configuration")
  232. else:
  233. logger.debug("No state change needed (enabled=%s, self._enabled=%s)", enabled, self._enabled)
  234. self._enabled = enabled
  235. async def _start(self) -> None:
  236. """Start all virtual printer services."""
  237. logger.info("Starting virtual printer services (mode=%s)...", self._mode)
  238. # Proxy mode uses different services
  239. if self._mode == "proxy":
  240. await self._start_proxy_mode()
  241. return
  242. # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
  243. await self._start_server_mode()
  244. async def _start_proxy_mode(self) -> None:
  245. """Start virtual printer in proxy mode (TLS terminating relay)."""
  246. logger.info("Starting proxy mode to %s", self._target_printer_ip)
  247. # In proxy mode, use the REAL printer's serial number
  248. # This ensures MQTT topic subscriptions match the real printer's topics
  249. proxy_serial = self._target_printer_serial or self.printer_serial
  250. logger.info("Proxy mode using serial: %s", proxy_serial)
  251. # Update certificate service with the real printer's serial
  252. self._cert_service.serial = proxy_serial
  253. # Regenerate printer cert if needed (CA is preserved)
  254. # Include remote interface IP in SAN so slicer TLS succeeds
  255. additional_ips = []
  256. if self._remote_interface_ip:
  257. additional_ips.append(self._remote_interface_ip)
  258. self._cert_service.delete_printer_certificate()
  259. cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
  260. logger.info("Generated certificate for proxy serial: %s", proxy_serial)
  261. # Initialize TLS proxy with our certificates
  262. self._proxy = SlicerProxyManager(
  263. target_host=self._target_printer_ip,
  264. cert_path=cert_path,
  265. key_path=key_path,
  266. on_activity=self._on_proxy_activity,
  267. )
  268. # Start services as background tasks
  269. async def run_with_logging(coro, name):
  270. try:
  271. await coro
  272. except Exception as e:
  273. logger.error("Virtual printer %s failed: %s", name, e)
  274. self._tasks = []
  275. # SSDP setup: use SSDPProxy if remote interface is configured
  276. # Local interface is auto-detected from target printer IP
  277. if self._remote_interface_ip:
  278. # Auto-detect local interface based on target printer IP
  279. from backend.app.services.network_utils import find_interface_for_ip
  280. local_iface = find_interface_for_ip(self._target_printer_ip)
  281. if local_iface:
  282. local_interface_ip = local_iface["ip"]
  283. logger.info(
  284. f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
  285. )
  286. self._ssdp_proxy = SSDPProxy(
  287. local_interface_ip=local_interface_ip,
  288. remote_interface_ip=self._remote_interface_ip,
  289. target_printer_ip=self._target_printer_ip,
  290. )
  291. self._tasks.append(
  292. asyncio.create_task(
  293. run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
  294. name="virtual_printer_ssdp_proxy",
  295. )
  296. )
  297. else:
  298. logger.warning(
  299. f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
  300. "falling back to single-interface SSDP"
  301. )
  302. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  303. else:
  304. # Single interface: broadcast SSDP on same network (fallback)
  305. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  306. # Add TLS proxy task
  307. self._tasks.append(
  308. asyncio.create_task(
  309. run_with_logging(self._proxy.start(), "Proxy"),
  310. name="virtual_printer_proxy",
  311. )
  312. )
  313. logger.info(
  314. "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d",
  315. self._target_printer_ip,
  316. SlicerProxyManager.PRINTER_FTP_PORT,
  317. self._target_printer_ip,
  318. SlicerProxyManager.PRINTER_MQTT_PORT,
  319. )
  320. def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
  321. """Start single-interface SSDP server as fallback."""
  322. logger.info("SSDP broadcast mode (single interface)")
  323. self._ssdp = VirtualPrinterSSDPServer(
  324. name=f"{self.PRINTER_NAME} (Proxy)",
  325. serial=proxy_serial,
  326. model=self._model,
  327. )
  328. self._tasks.append(
  329. asyncio.create_task(
  330. run_with_logging(self._ssdp.start(), "SSDP"),
  331. name="virtual_printer_ssdp",
  332. )
  333. )
  334. async def _start_server_mode(self) -> None:
  335. """Start virtual printer in server mode (FTP/MQTT servers)."""
  336. # Update certificate service with current serial (based on model)
  337. current_serial = self.printer_serial
  338. self._cert_service.serial = current_serial
  339. # Regenerate printer cert if serial changed (CA is preserved)
  340. # Include remote interface IP in SAN so slicer TLS succeeds on that interface
  341. additional_ips = []
  342. if self._remote_interface_ip:
  343. additional_ips.append(self._remote_interface_ip)
  344. self._cert_service.delete_printer_certificate()
  345. cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
  346. logger.info("Generated certificate for serial: %s", current_serial)
  347. # Create directories
  348. self._upload_dir.mkdir(parents=True, exist_ok=True)
  349. (self._upload_dir / "cache").mkdir(exist_ok=True)
  350. # Initialize services
  351. self._ssdp = VirtualPrinterSSDPServer(
  352. name=self.PRINTER_NAME,
  353. serial=self.printer_serial,
  354. model=self._model,
  355. advertise_ip=self._remote_interface_ip,
  356. )
  357. self._ftp = VirtualPrinterFTPServer(
  358. upload_dir=self._upload_dir,
  359. access_code=self._access_code,
  360. cert_path=cert_path,
  361. key_path=key_path,
  362. on_file_received=self._on_file_received,
  363. )
  364. self._mqtt = SimpleMQTTServer(
  365. serial=self.printer_serial,
  366. access_code=self._access_code,
  367. cert_path=cert_path,
  368. key_path=key_path,
  369. on_print_command=self._on_print_command,
  370. )
  371. # Start services as background tasks
  372. # Wrap each in error handler so one failure doesn't stop others
  373. async def run_with_logging(coro, name):
  374. try:
  375. await coro
  376. except Exception as e:
  377. logger.error("Virtual printer %s failed: %s", name, e)
  378. self._tasks = [
  379. asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
  380. asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
  381. asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
  382. ]
  383. logger.info("Virtual printer '%s' started (serial: %s)", self.PRINTER_NAME, self.printer_serial)
  384. def _on_proxy_activity(self, name: str, message: str) -> None:
  385. """Handle proxy activity for logging."""
  386. logger.info("Proxy %s: %s", name, message)
  387. async def _stop(self) -> None:
  388. """Stop all virtual printer services."""
  389. logger.info("Stopping virtual printer services...")
  390. # Stop services first - this closes servers and cancels active sessions
  391. if self._ftp:
  392. await self._ftp.stop()
  393. self._ftp = None
  394. if self._mqtt:
  395. await self._mqtt.stop()
  396. self._mqtt = None
  397. if self._ssdp:
  398. await self._ssdp.stop()
  399. self._ssdp = None
  400. if self._ssdp_proxy:
  401. await self._ssdp_proxy.stop()
  402. self._ssdp_proxy = None
  403. if self._proxy:
  404. await self._proxy.stop()
  405. self._proxy = None
  406. # Cancel remaining tasks with short timeout
  407. for task in self._tasks:
  408. task.cancel()
  409. if self._tasks:
  410. try:
  411. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  412. except TimeoutError:
  413. logger.debug("Some tasks didn't stop in time")
  414. self._tasks = []
  415. logger.info("Virtual printer stopped")
  416. async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
  417. """Handle file upload completion from FTP.
  418. Args:
  419. file_path: Path to uploaded file
  420. source_ip: IP address of the uploading slicer
  421. """
  422. logger.info("Virtual printer received file: %s from %s", file_path.name, source_ip)
  423. # Store file reference for MQTT correlation
  424. self._pending_files[file_path.name] = file_path
  425. # Handle based on mode:
  426. # - immediate: archive right away
  427. # - review: create pending upload record for user review before archiving
  428. # - print_queue: archive and add to print queue (unassigned)
  429. if self._mode == "immediate":
  430. await self._archive_file(file_path, source_ip)
  431. elif self._mode == "print_queue":
  432. await self._add_to_print_queue(file_path, source_ip)
  433. else:
  434. # "review" mode (or legacy "queue" mode)
  435. await self._queue_file(file_path, source_ip)
  436. # Reset MQTT status back to IDLE after file processing
  437. # This tells the slicer the printer is done with the file
  438. if self._mqtt and file_path.suffix.lower() == ".3mf":
  439. self._mqtt.set_gcode_state("IDLE")
  440. async def _on_print_command(self, filename: str, data: dict) -> None:
  441. """Handle print command from MQTT.
  442. In a real printer, this would start the print. For virtual printer,
  443. we just log it since archiving is handled by file upload.
  444. Args:
  445. filename: Name of the file to print
  446. data: Print command data (contains settings like timelapse, bed_leveling, etc.)
  447. """
  448. logger.info("Virtual printer received print command for: %s", filename)
  449. logger.debug("Print command data: %s", data)
  450. # The file should already be archived from FTP upload
  451. # This command just confirms the slicer's intent to "print"
  452. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  453. """Archive file immediately.
  454. Args:
  455. file_path: Path to the 3MF file
  456. source_ip: IP address of uploader
  457. """
  458. if not self._session_factory:
  459. logger.error("Cannot archive: no database session factory configured")
  460. return
  461. # Only archive 3MF files
  462. if file_path.suffix.lower() != ".3mf":
  463. logger.debug("Skipping non-3MF file: %s", file_path.name)
  464. # Remove from pending and clean up
  465. self._pending_files.pop(file_path.name, None)
  466. try:
  467. file_path.unlink()
  468. except OSError:
  469. pass # Best-effort removal of non-3MF file; may already be gone
  470. return
  471. try:
  472. from backend.app.services.archive import ArchiveService
  473. async with self._session_factory() as db:
  474. service = ArchiveService(db)
  475. # Archive the print
  476. archive = await service.archive_print(
  477. printer_id=None, # No physical printer
  478. source_file=file_path,
  479. print_data={
  480. "status": "archived",
  481. "source": "virtual_printer",
  482. "source_ip": source_ip,
  483. },
  484. )
  485. if archive:
  486. logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
  487. # Clean up uploaded file (it's now copied to archive)
  488. try:
  489. file_path.unlink()
  490. except OSError:
  491. pass # Best-effort cleanup of uploaded file after archiving
  492. # Remove from pending
  493. self._pending_files.pop(file_path.name, None)
  494. else:
  495. logger.error("Failed to archive file: %s", file_path.name)
  496. except Exception as e: # Mixed async DB + archive operations
  497. logger.error("Error archiving file: %s", e)
  498. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  499. """Queue file for user review.
  500. Args:
  501. file_path: Path to the 3MF file
  502. source_ip: IP address of uploader
  503. """
  504. if not self._session_factory:
  505. logger.error("Cannot queue: no database session factory configured")
  506. return
  507. # Only queue 3MF files
  508. if file_path.suffix.lower() != ".3mf":
  509. logger.debug("Skipping non-3MF file: %s", file_path.name)
  510. self._pending_files.pop(file_path.name, None)
  511. try:
  512. file_path.unlink()
  513. except OSError:
  514. pass # Best-effort removal of non-3MF file; may already be gone
  515. return
  516. try:
  517. from backend.app.models.pending_upload import PendingUpload
  518. async with self._session_factory() as db:
  519. pending = PendingUpload(
  520. filename=file_path.name,
  521. file_path=str(file_path),
  522. file_size=file_path.stat().st_size,
  523. source_ip=source_ip,
  524. status="pending",
  525. uploaded_at=datetime.now(timezone.utc),
  526. )
  527. db.add(pending)
  528. await db.commit()
  529. logger.info("Queued virtual printer upload: %s - %s", pending.id, file_path.name)
  530. # Remove from pending files dict
  531. self._pending_files.pop(file_path.name, None)
  532. except Exception as e:
  533. logger.error("Error queueing file: %s", e)
  534. async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
  535. """Archive file and add to print queue (unassigned).
  536. Args:
  537. file_path: Path to the 3MF file
  538. source_ip: IP address of uploader
  539. """
  540. if not self._session_factory:
  541. logger.error("Cannot add to print queue: no database session factory configured")
  542. return
  543. # Only process 3MF files
  544. if file_path.suffix.lower() != ".3mf":
  545. logger.debug("Skipping non-3MF file: %s", file_path.name)
  546. self._pending_files.pop(file_path.name, None)
  547. try:
  548. file_path.unlink()
  549. except OSError:
  550. pass # Best-effort removal of non-3MF file; may already be gone
  551. return
  552. try:
  553. from backend.app.models.print_queue import PrintQueueItem
  554. from backend.app.services.archive import ArchiveService
  555. async with self._session_factory() as db:
  556. service = ArchiveService(db)
  557. # First, archive the print
  558. archive = await service.archive_print(
  559. printer_id=None, # No physical printer
  560. source_file=file_path,
  561. print_data={
  562. "status": "archived",
  563. "source": "virtual_printer",
  564. "source_ip": source_ip,
  565. },
  566. )
  567. if archive:
  568. logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
  569. # Now add to print queue (unassigned)
  570. queue_item = PrintQueueItem(
  571. printer_id=None, # Unassigned - user will assign later
  572. archive_id=archive.id,
  573. position=1, # Will be adjusted when assigned to a printer
  574. status="pending",
  575. )
  576. db.add(queue_item)
  577. await db.commit()
  578. logger.info(
  579. "Added to print queue (unassigned): queue_id=%s, archive_id=%s", queue_item.id, archive.id
  580. )
  581. # Clean up uploaded file (it's now copied to archive)
  582. try:
  583. file_path.unlink()
  584. except OSError:
  585. pass # Best-effort cleanup of uploaded file after archiving and queuing
  586. # Remove from pending
  587. self._pending_files.pop(file_path.name, None)
  588. else:
  589. logger.error("Failed to archive file: %s", file_path.name)
  590. except Exception as e: # Mixed async DB + archive + queue operations
  591. logger.error("Error adding to print queue: %s", e)
  592. def get_status(self) -> dict:
  593. """Get virtual printer status.
  594. Returns:
  595. Status dictionary with enabled, running, mode, etc.
  596. """
  597. status = {
  598. "enabled": self._enabled,
  599. "running": self.is_running,
  600. "mode": self._mode,
  601. "name": self.PRINTER_NAME,
  602. "serial": self.printer_serial,
  603. "model": self._model,
  604. "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
  605. "pending_files": len(self._pending_files),
  606. }
  607. # Add proxy-specific status
  608. if self._mode == "proxy":
  609. status["target_printer_ip"] = self._target_printer_ip
  610. if self._proxy:
  611. proxy_status = self._proxy.get_status()
  612. status["proxy"] = proxy_status
  613. return status
  614. # Global instance
  615. virtual_printer_manager = VirtualPrinterManager()