mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
- Virtual printer appears in Bambu Studio/Orca Slicer via SSDP discovery - Secure TLS/MQTT communication with auto-generated certificates - Queue mode (pending uploads) or auto-start mode - Configurable access code for authentication - Docker support with network_mode: host and certificate persistence - Fix backup/restore for virtual printer settings (auto-save no longer overwrites)
324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""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()
|