manager.py 28 KB

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