manager.py 27 KB

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