manager.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  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 UTC, datetime
  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(f"Checking virtual printer directories in {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 (mode == "proxy" and (target_changed or serial_changed or remote_iface_changed))
  216. )
  217. if enabled and not self._enabled:
  218. logger.info("Starting virtual printer (was disabled)")
  219. await self._start()
  220. elif not enabled and self._enabled:
  221. logger.info("Stopping virtual printer (was enabled)")
  222. await self._stop()
  223. elif enabled and self._enabled and needs_restart:
  224. # Configuration changed while running - restart services
  225. logger.info(f"Configuration changed (mode={old_mode}→{mode}), restarting...")
  226. await self._stop()
  227. # Give time for ports to be released
  228. await asyncio.sleep(0.5)
  229. await self._start()
  230. logger.info("Virtual printer restarted with new configuration")
  231. else:
  232. logger.debug(f"No state change needed (enabled={enabled}, self._enabled={self._enabled})")
  233. self._enabled = enabled
  234. async def _start(self) -> None:
  235. """Start all virtual printer services."""
  236. logger.info(f"Starting virtual printer services (mode={self._mode})...")
  237. # Proxy mode uses different services
  238. if self._mode == "proxy":
  239. await self._start_proxy_mode()
  240. return
  241. # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
  242. await self._start_server_mode()
  243. async def _start_proxy_mode(self) -> None:
  244. """Start virtual printer in proxy mode (TLS terminating relay)."""
  245. logger.info(f"Starting proxy mode to {self._target_printer_ip}")
  246. # In proxy mode, use the REAL printer's serial number
  247. # This ensures MQTT topic subscriptions match the real printer's topics
  248. proxy_serial = self._target_printer_serial or self.printer_serial
  249. logger.info(f"Proxy mode using serial: {proxy_serial}")
  250. # Update certificate service with the real printer's serial
  251. self._cert_service.serial = proxy_serial
  252. # Regenerate printer cert if needed (CA is preserved)
  253. self._cert_service.delete_printer_certificate()
  254. cert_path, key_path = self._cert_service.generate_certificates()
  255. logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
  256. # Initialize TLS proxy with our certificates
  257. self._proxy = SlicerProxyManager(
  258. target_host=self._target_printer_ip,
  259. cert_path=cert_path,
  260. key_path=key_path,
  261. on_activity=self._on_proxy_activity,
  262. )
  263. # Start services as background tasks
  264. async def run_with_logging(coro, name):
  265. try:
  266. await coro
  267. except Exception as e:
  268. logger.error(f"Virtual printer {name} failed: {e}")
  269. self._tasks = []
  270. # SSDP setup: use SSDPProxy if remote interface is configured
  271. # Local interface is auto-detected from target printer IP
  272. if self._remote_interface_ip:
  273. # Auto-detect local interface based on target printer IP
  274. from backend.app.services.network_utils import find_interface_for_ip
  275. local_iface = find_interface_for_ip(self._target_printer_ip)
  276. if local_iface:
  277. local_interface_ip = local_iface["ip"]
  278. logger.info(
  279. f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
  280. )
  281. self._ssdp_proxy = SSDPProxy(
  282. local_interface_ip=local_interface_ip,
  283. remote_interface_ip=self._remote_interface_ip,
  284. target_printer_ip=self._target_printer_ip,
  285. )
  286. self._tasks.append(
  287. asyncio.create_task(
  288. run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
  289. name="virtual_printer_ssdp_proxy",
  290. )
  291. )
  292. else:
  293. logger.warning(
  294. f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
  295. "falling back to single-interface SSDP"
  296. )
  297. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  298. else:
  299. # Single interface: broadcast SSDP on same network (fallback)
  300. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  301. # Add TLS proxy task
  302. self._tasks.append(
  303. asyncio.create_task(
  304. run_with_logging(self._proxy.start(), "Proxy"),
  305. name="virtual_printer_proxy",
  306. )
  307. )
  308. logger.info(
  309. f"Virtual printer proxy started: "
  310. f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
  311. f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
  312. )
  313. def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
  314. """Start single-interface SSDP server as fallback."""
  315. logger.info("SSDP broadcast mode (single interface)")
  316. self._ssdp = VirtualPrinterSSDPServer(
  317. name=f"{self.PRINTER_NAME} (Proxy)",
  318. serial=proxy_serial,
  319. model=self._model,
  320. )
  321. self._tasks.append(
  322. asyncio.create_task(
  323. run_with_logging(self._ssdp.start(), "SSDP"),
  324. name="virtual_printer_ssdp",
  325. )
  326. )
  327. async def _start_server_mode(self) -> None:
  328. """Start virtual printer in server mode (FTP/MQTT servers)."""
  329. # Update certificate service with current serial (based on model)
  330. current_serial = self.printer_serial
  331. self._cert_service.serial = current_serial
  332. # Regenerate printer cert if serial changed (CA is preserved)
  333. self._cert_service.delete_printer_certificate()
  334. cert_path, key_path = self._cert_service.generate_certificates()
  335. logger.info(f"Generated certificate for serial: {current_serial}")
  336. # Create directories
  337. self._upload_dir.mkdir(parents=True, exist_ok=True)
  338. (self._upload_dir / "cache").mkdir(exist_ok=True)
  339. # Initialize services
  340. self._ssdp = VirtualPrinterSSDPServer(
  341. name=self.PRINTER_NAME,
  342. serial=self.printer_serial,
  343. model=self._model,
  344. )
  345. self._ftp = VirtualPrinterFTPServer(
  346. upload_dir=self._upload_dir,
  347. access_code=self._access_code,
  348. cert_path=cert_path,
  349. key_path=key_path,
  350. on_file_received=self._on_file_received,
  351. )
  352. self._mqtt = SimpleMQTTServer(
  353. serial=self.printer_serial,
  354. access_code=self._access_code,
  355. cert_path=cert_path,
  356. key_path=key_path,
  357. on_print_command=self._on_print_command,
  358. )
  359. # Start services as background tasks
  360. # Wrap each in error handler so one failure doesn't stop others
  361. async def run_with_logging(coro, name):
  362. try:
  363. await coro
  364. except Exception as e:
  365. logger.error(f"Virtual printer {name} failed: {e}")
  366. self._tasks = [
  367. asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
  368. asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
  369. asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
  370. ]
  371. logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.printer_serial})")
  372. def _on_proxy_activity(self, name: str, message: str) -> None:
  373. """Handle proxy activity for logging."""
  374. logger.info(f"Proxy {name}: {message}")
  375. async def _stop(self) -> None:
  376. """Stop all virtual printer services."""
  377. logger.info("Stopping virtual printer services...")
  378. # Stop services first - this closes servers and cancels active sessions
  379. if self._ftp:
  380. await self._ftp.stop()
  381. self._ftp = None
  382. if self._mqtt:
  383. await self._mqtt.stop()
  384. self._mqtt = None
  385. if self._ssdp:
  386. await self._ssdp.stop()
  387. self._ssdp = None
  388. if self._ssdp_proxy:
  389. await self._ssdp_proxy.stop()
  390. self._ssdp_proxy = None
  391. if self._proxy:
  392. await self._proxy.stop()
  393. self._proxy = None
  394. # Cancel remaining tasks with short timeout
  395. for task in self._tasks:
  396. task.cancel()
  397. if self._tasks:
  398. try:
  399. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  400. except TimeoutError:
  401. logger.debug("Some tasks didn't stop in time")
  402. self._tasks = []
  403. logger.info("Virtual printer stopped")
  404. async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
  405. """Handle file upload completion from FTP.
  406. Args:
  407. file_path: Path to uploaded file
  408. source_ip: IP address of the uploading slicer
  409. """
  410. logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}")
  411. # Store file reference for MQTT correlation
  412. self._pending_files[file_path.name] = file_path
  413. # Handle based on mode:
  414. # - immediate: archive right away
  415. # - review: create pending upload record for user review before archiving
  416. # - print_queue: archive and add to print queue (unassigned)
  417. if self._mode == "immediate":
  418. await self._archive_file(file_path, source_ip)
  419. elif self._mode == "print_queue":
  420. await self._add_to_print_queue(file_path, source_ip)
  421. else:
  422. # "review" mode (or legacy "queue" mode)
  423. await self._queue_file(file_path, source_ip)
  424. async def _on_print_command(self, filename: str, data: dict) -> None:
  425. """Handle print command from MQTT.
  426. In a real printer, this would start the print. For virtual printer,
  427. we just log it since archiving is handled by file upload.
  428. Args:
  429. filename: Name of the file to print
  430. data: Print command data (contains settings like timelapse, bed_leveling, etc.)
  431. """
  432. logger.info(f"Virtual printer received print command for: {filename}")
  433. logger.debug(f"Print command data: {data}")
  434. # The file should already be archived from FTP upload
  435. # This command just confirms the slicer's intent to "print"
  436. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  437. """Archive file immediately.
  438. Args:
  439. file_path: Path to the 3MF file
  440. source_ip: IP address of uploader
  441. """
  442. if not self._session_factory:
  443. logger.error("Cannot archive: no database session factory configured")
  444. return
  445. # Only archive 3MF files
  446. if file_path.suffix.lower() != ".3mf":
  447. logger.debug(f"Skipping non-3MF file: {file_path.name}")
  448. # Remove from pending and clean up
  449. self._pending_files.pop(file_path.name, None)
  450. try:
  451. file_path.unlink()
  452. except Exception:
  453. pass
  454. return
  455. try:
  456. from backend.app.services.archive import ArchiveService
  457. async with self._session_factory() as db:
  458. service = ArchiveService(db)
  459. # Archive the print
  460. archive = await service.archive_print(
  461. printer_id=None, # No physical printer
  462. source_file=file_path,
  463. print_data={
  464. "status": "archived",
  465. "source": "virtual_printer",
  466. "source_ip": source_ip,
  467. },
  468. )
  469. if archive:
  470. logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
  471. # Clean up uploaded file (it's now copied to archive)
  472. try:
  473. file_path.unlink()
  474. except Exception:
  475. pass
  476. # Remove from pending
  477. self._pending_files.pop(file_path.name, None)
  478. else:
  479. logger.error(f"Failed to archive file: {file_path.name}")
  480. except Exception as e:
  481. logger.error(f"Error archiving file: {e}")
  482. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  483. """Queue file for user review.
  484. Args:
  485. file_path: Path to the 3MF file
  486. source_ip: IP address of uploader
  487. """
  488. if not self._session_factory:
  489. logger.error("Cannot queue: no database session factory configured")
  490. return
  491. # Only queue 3MF files
  492. if file_path.suffix.lower() != ".3mf":
  493. logger.warning(f"Skipping non-3MF file: {file_path.name}")
  494. return
  495. try:
  496. from backend.app.models.pending_upload import PendingUpload
  497. async with self._session_factory() as db:
  498. pending = PendingUpload(
  499. filename=file_path.name,
  500. file_path=str(file_path),
  501. file_size=file_path.stat().st_size,
  502. source_ip=source_ip,
  503. status="pending",
  504. uploaded_at=datetime.now(UTC),
  505. )
  506. db.add(pending)
  507. await db.commit()
  508. logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
  509. # Remove from pending files dict
  510. self._pending_files.pop(file_path.name, None)
  511. except Exception as e:
  512. logger.error(f"Error queueing file: {e}")
  513. async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
  514. """Archive file and add to print queue (unassigned).
  515. Args:
  516. file_path: Path to the 3MF file
  517. source_ip: IP address of uploader
  518. """
  519. if not self._session_factory:
  520. logger.error("Cannot add to print queue: no database session factory configured")
  521. return
  522. # Only process 3MF files
  523. if file_path.suffix.lower() != ".3mf":
  524. logger.debug(f"Skipping non-3MF file: {file_path.name}")
  525. self._pending_files.pop(file_path.name, None)
  526. try:
  527. file_path.unlink()
  528. except Exception:
  529. pass
  530. return
  531. try:
  532. from backend.app.models.print_queue import PrintQueueItem
  533. from backend.app.services.archive import ArchiveService
  534. async with self._session_factory() as db:
  535. service = ArchiveService(db)
  536. # First, archive the print
  537. archive = await service.archive_print(
  538. printer_id=None, # No physical printer
  539. source_file=file_path,
  540. print_data={
  541. "status": "archived",
  542. "source": "virtual_printer",
  543. "source_ip": source_ip,
  544. },
  545. )
  546. if archive:
  547. logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
  548. # Now add to print queue (unassigned)
  549. queue_item = PrintQueueItem(
  550. printer_id=None, # Unassigned - user will assign later
  551. archive_id=archive.id,
  552. position=1, # Will be adjusted when assigned to a printer
  553. status="pending",
  554. )
  555. db.add(queue_item)
  556. await db.commit()
  557. logger.info(f"Added to print queue (unassigned): queue_id={queue_item.id}, archive_id={archive.id}")
  558. # Clean up uploaded file (it's now copied to archive)
  559. try:
  560. file_path.unlink()
  561. except Exception:
  562. pass
  563. # Remove from pending
  564. self._pending_files.pop(file_path.name, None)
  565. else:
  566. logger.error(f"Failed to archive file: {file_path.name}")
  567. except Exception as e:
  568. logger.error(f"Error adding to print queue: {e}")
  569. def get_status(self) -> dict:
  570. """Get virtual printer status.
  571. Returns:
  572. Status dictionary with enabled, running, mode, etc.
  573. """
  574. status = {
  575. "enabled": self._enabled,
  576. "running": self.is_running,
  577. "mode": self._mode,
  578. "name": self.PRINTER_NAME,
  579. "serial": self.printer_serial,
  580. "model": self._model,
  581. "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
  582. "pending_files": len(self._pending_files),
  583. }
  584. # Add proxy-specific status
  585. if self._mode == "proxy":
  586. status["target_printer_ip"] = self._target_printer_ip
  587. if self._proxy:
  588. proxy_status = self._proxy.get_status()
  589. status["proxy"] = proxy_status
  590. return status
  591. # Global instance
  592. virtual_printer_manager = VirtualPrinterManager()