| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- """Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services."""
- import asyncio
- import logging
- from collections.abc import Callable
- from datetime import UTC, datetime
- from pathlib import Path
- from backend.app.core.config import settings as app_settings
- from backend.app.services.virtual_printer.certificate import CertificateService
- from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
- from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- logger = logging.getLogger(__name__)
- class VirtualPrinterManager:
- """Manages the virtual printer lifecycle and coordinates all services."""
- # Fixed configuration
- PRINTER_NAME = "Bambuddy"
- PRINTER_SERIAL = "00M09A391800001" # X1C serial format
- PRINTER_MODEL = "3DPrinter-X1-Carbon" # Full model name for slicer compatibility
- def __init__(self):
- """Initialize the virtual printer manager."""
- self._session_factory: Callable | None = None
- self._enabled = False
- self._access_code = ""
- self._mode = "immediate"
- # Service instances
- self._ssdp: VirtualPrinterSSDPServer | None = None
- self._ftp: VirtualPrinterFTPServer | None = None
- self._mqtt: SimpleMQTTServer | None = None
- # Background tasks
- self._tasks: list[asyncio.Task] = []
- # Directories
- self._base_dir = app_settings.base_dir / "virtual_printer"
- self._upload_dir = self._base_dir / "uploads"
- self._cert_dir = self._base_dir / "certs"
- # Certificate service - pass serial to match CN in certificate
- self._cert_service = CertificateService(self._cert_dir, serial=self.PRINTER_SERIAL)
- # Track pending uploads for MQTT correlation
- self._pending_files: dict[str, Path] = {}
- def set_session_factory(self, session_factory: Callable) -> None:
- """Set the database session factory.
- Args:
- session_factory: Async context manager for database sessions
- """
- self._session_factory = session_factory
- @property
- def is_enabled(self) -> bool:
- """Check if virtual printer is enabled."""
- return self._enabled
- @property
- def is_running(self) -> bool:
- """Check if virtual printer services are running."""
- return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
- async def configure(
- self,
- enabled: bool,
- access_code: str = "",
- mode: str = "immediate",
- ) -> None:
- """Configure and start/stop virtual printer.
- Args:
- enabled: Whether to enable the virtual printer
- access_code: Authentication password for slicer connections
- mode: Archive mode - 'immediate' or 'queue'
- """
- if enabled and not access_code:
- raise ValueError("Access code is required when enabling virtual printer")
- self._access_code = access_code
- self._mode = mode
- if enabled and not self._enabled:
- await self._start()
- elif not enabled and self._enabled:
- await self._stop()
- self._enabled = enabled
- async def _start(self) -> None:
- """Start all virtual printer services."""
- logger.info("Starting virtual printer services...")
- # Ensure certificates exist
- cert_path, key_path = self._cert_service.ensure_certificates()
- # Create directories
- self._upload_dir.mkdir(parents=True, exist_ok=True)
- (self._upload_dir / "cache").mkdir(exist_ok=True)
- # Initialize services
- self._ssdp = VirtualPrinterSSDPServer(
- name=self.PRINTER_NAME,
- serial=self.PRINTER_SERIAL,
- model=self.PRINTER_MODEL,
- )
- self._ftp = VirtualPrinterFTPServer(
- upload_dir=self._upload_dir,
- access_code=self._access_code,
- cert_path=cert_path,
- key_path=key_path,
- on_file_received=self._on_file_received,
- )
- self._mqtt = SimpleMQTTServer(
- serial=self.PRINTER_SERIAL,
- access_code=self._access_code,
- cert_path=cert_path,
- key_path=key_path,
- on_print_command=self._on_print_command,
- )
- # Start services as background tasks
- # Wrap each in error handler so one failure doesn't stop others
- async def run_with_logging(coro, name):
- try:
- await coro
- except Exception as e:
- logger.error(f"Virtual printer {name} failed: {e}")
- self._tasks = [
- asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
- asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
- asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
- ]
- logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.PRINTER_SERIAL})")
- async def _stop(self) -> None:
- """Stop all virtual printer services."""
- logger.info("Stopping virtual printer services...")
- # Cancel all tasks
- for task in self._tasks:
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- self._tasks = []
- # Stop services
- if self._ssdp:
- await self._ssdp.stop()
- self._ssdp = None
- if self._ftp:
- await self._ftp.stop()
- self._ftp = None
- if self._mqtt:
- await self._mqtt.stop()
- self._mqtt = None
- logger.info("Virtual printer stopped")
- async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
- """Handle file upload completion from FTP.
- Args:
- file_path: Path to uploaded file
- source_ip: IP address of the uploading slicer
- """
- logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}")
- # Store file reference for MQTT correlation
- self._pending_files[file_path.name] = file_path
- # In immediate mode, archive right away
- # In queue mode, create pending upload record
- if self._mode == "immediate":
- await self._archive_file(file_path, source_ip)
- else:
- await self._queue_file(file_path, source_ip)
- async def _on_print_command(self, filename: str, data: dict) -> None:
- """Handle print command from MQTT.
- In a real printer, this would start the print. For virtual printer,
- we just log it since archiving is handled by file upload.
- Args:
- filename: Name of the file to print
- data: Print command data (contains settings like timelapse, bed_leveling, etc.)
- """
- logger.info(f"Virtual printer received print command for: {filename}")
- logger.debug(f"Print command data: {data}")
- # The file should already be archived from FTP upload
- # This command just confirms the slicer's intent to "print"
- async def _archive_file(self, file_path: Path, source_ip: str) -> None:
- """Archive file immediately.
- Args:
- file_path: Path to the 3MF file
- source_ip: IP address of uploader
- """
- if not self._session_factory:
- logger.error("Cannot archive: no database session factory configured")
- return
- # Only archive 3MF files
- if file_path.suffix.lower() != ".3mf":
- logger.debug(f"Skipping non-3MF file: {file_path.name}")
- # Remove from pending and clean up
- self._pending_files.pop(file_path.name, None)
- try:
- file_path.unlink()
- except Exception:
- pass
- return
- try:
- from backend.app.services.archive import ArchiveService
- async with self._session_factory() as db:
- service = ArchiveService(db)
- # Archive the print
- archive = await service.archive_print(
- printer_id=None, # No physical printer
- source_file=file_path,
- print_data={
- "status": "archived",
- "source": "virtual_printer",
- "source_ip": source_ip,
- },
- )
- if archive:
- logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
- # Clean up uploaded file (it's now copied to archive)
- try:
- file_path.unlink()
- except Exception:
- pass
- # Remove from pending
- self._pending_files.pop(file_path.name, None)
- else:
- logger.error(f"Failed to archive file: {file_path.name}")
- except Exception as e:
- logger.error(f"Error archiving file: {e}")
- async def _queue_file(self, file_path: Path, source_ip: str) -> None:
- """Queue file for user review.
- Args:
- file_path: Path to the 3MF file
- source_ip: IP address of uploader
- """
- if not self._session_factory:
- logger.error("Cannot queue: no database session factory configured")
- return
- # Only queue 3MF files
- if file_path.suffix.lower() != ".3mf":
- logger.warning(f"Skipping non-3MF file: {file_path.name}")
- return
- try:
- from backend.app.models.pending_upload import PendingUpload
- async with self._session_factory() as db:
- pending = PendingUpload(
- filename=file_path.name,
- file_path=str(file_path),
- file_size=file_path.stat().st_size,
- source_ip=source_ip,
- status="pending",
- uploaded_at=datetime.now(UTC),
- )
- db.add(pending)
- await db.commit()
- logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
- # Remove from pending files dict
- self._pending_files.pop(file_path.name, None)
- except Exception as e:
- logger.error(f"Error queueing file: {e}")
- def get_status(self) -> dict:
- """Get virtual printer status.
- Returns:
- Status dictionary with enabled, running, mode, etc.
- """
- return {
- "enabled": self._enabled,
- "running": self.is_running,
- "mode": self._mode,
- "name": self.PRINTER_NAME,
- "serial": self.PRINTER_SERIAL,
- "model": self.PRINTER_MODEL,
- "pending_files": len(self._pending_files),
- }
- # Global instance
- virtual_printer_manager = VirtualPrinterManager()
|