mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Fix virtual printer model codes and serial prefixes
- Correct SSDP model codes: C11=P1P, C12=P1S, N7=P2S, C13=X1E - Fix serial prefixes based on actual Bambu serial format - Add confirmation modal for pending upload discard - Sort model dropdown alphabetically, remove internal codes - Add "Setup Required" warning with link to wiki documentation - Update wiki with certificate installation and platform setup guides
This commit is contained in:
@@ -21,14 +21,34 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Supports X1 series (X1C, X1, X1E), P series (P1S, P1P, P2S), A1 series (A1, A1 Mini), and H2 series (H2D, H2C, H2S)
|
||||
- Affects how slicers detect and interact with the virtual printer
|
||||
- Model change requires disabling/re-enabling the virtual printer
|
||||
- Models sorted alphabetically in dropdown
|
||||
- **Pending upload delete confirmation** - Confirmation modal when discarding pending uploads in queue review mode
|
||||
|
||||
### Fixed
|
||||
- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection
|
||||
- **Virtual printer SSDP model codes** - Corrected model codes for slicer compatibility:
|
||||
- C11=P1P, C12=P1S (were incorrectly swapped)
|
||||
- N7=P2S (was incorrectly using C13 which is X1E)
|
||||
- 3DPrinter-X1-Carbon for X1C (full model name format)
|
||||
- **Virtual printer serial prefixes** - Fixed serial number prefixes to match real printers:
|
||||
- Based on actual Bambu Lab serial number format (MMM??RYMDDUUUUU)
|
||||
- X1C=00M, P1S=01P, P1P=01S, P2S=22E, A1=039, A1M=030, H2D=094, X1E=03W
|
||||
- **Docker certificate persistence** - Fixed virtual printer certificate storage:
|
||||
- Removed unused `bambuddy_vprinter` volume (was mounting to wrong path)
|
||||
- Certificates now correctly persist in `bambuddy_data` volume
|
||||
- Added optional bind mount for sharing certs between Docker and native installations
|
||||
|
||||
### Changed
|
||||
- **Virtual printer setup documentation** - Improved setup instructions:
|
||||
- Prominent "Setup Required" warning in UI linking to documentation
|
||||
- Certificate must be appended to slicer's printer.cer file (not system cert store)
|
||||
- Platform-specific instructions for Linux, Docker, macOS, Windows, Unraid, Synology, TrueNAS, Proxmox
|
||||
|
||||
### Tests
|
||||
- Added integration tests for print queue API endpoints (16 new tests)
|
||||
- Tests cover queue CRUD, manual_start flag, and start/cancel endpoints
|
||||
- Added unit tests for virtual printer model configuration (3 new tests)
|
||||
- Updated VirtualPrinterSettings tests for new UI layout and model codes
|
||||
|
||||
## [0.1.6b5] - 2026-01-02
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Generates certificates that mimic real Bambu printer certificate format:
|
||||
- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
|
||||
- Printer certificate has CN = serial number, signed by the CA
|
||||
|
||||
The CA certificate is persistent and only regenerated if missing or expired.
|
||||
This allows users to add the CA to their slicer's trust store once.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,6 +24,9 @@ logger = logging.getLogger(__name__)
|
||||
# Default serial number for virtual printer (matches SSDP/MQTT config)
|
||||
DEFAULT_SERIAL = "00M09A391800001"
|
||||
|
||||
# Minimum days remaining before CA is considered expired and needs regeneration
|
||||
CA_EXPIRY_THRESHOLD_DAYS = 30
|
||||
|
||||
|
||||
def _get_local_ip() -> str:
|
||||
"""Get the local IP address."""
|
||||
@@ -67,8 +73,70 @@ class CertificateService:
|
||||
return self.cert_path, self.key_path
|
||||
return self.generate_certificates()
|
||||
|
||||
def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None:
|
||||
"""Try to load existing CA certificate and key.
|
||||
|
||||
Returns:
|
||||
Tuple of (ca_private_key, ca_certificate) if valid CA exists, None otherwise
|
||||
"""
|
||||
if not self.ca_cert_path.exists() or not self.ca_key_path.exists():
|
||||
logger.debug("CA certificate or key not found")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Load CA certificate
|
||||
ca_cert_pem = self.ca_cert_path.read_bytes()
|
||||
ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
|
||||
|
||||
# Check if CA is expired or about to expire
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = (ca_cert.not_valid_after_utc - now).days
|
||||
if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
|
||||
logger.warning(f"CA certificate expires in {days_remaining} days, will regenerate")
|
||||
return None
|
||||
|
||||
# Load CA private key
|
||||
ca_key_pem = self.ca_key_path.read_bytes()
|
||||
ca_key = serialization.load_pem_private_key(ca_key_pem, password=None)
|
||||
|
||||
logger.info(f"Using existing CA certificate (expires in {days_remaining} days)")
|
||||
return ca_key, ca_cert
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load existing CA: {e}")
|
||||
return None
|
||||
|
||||
def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
|
||||
"""Get existing CA or create a new one.
|
||||
|
||||
Returns:
|
||||
Tuple of (ca_private_key, ca_certificate)
|
||||
"""
|
||||
# Try to load existing CA first
|
||||
existing = self._load_existing_ca()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Generate new CA
|
||||
ca_key, ca_cert = self._generate_ca_certificate()
|
||||
|
||||
# Save CA certificate and key
|
||||
self.cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.ca_key_path.write_bytes(
|
||||
ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
self.ca_key_path.chmod(0o600)
|
||||
self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
logger.info("Saved new CA certificate")
|
||||
return ca_key, ca_cert
|
||||
|
||||
def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
|
||||
"""Generate a CA certificate for the virtual printer.
|
||||
"""Generate a new CA certificate for the virtual printer.
|
||||
|
||||
We use a generic name instead of mimicking BBL CA, since the slicer
|
||||
may specifically reject certificates claiming to be from BBL but
|
||||
@@ -77,7 +145,7 @@ class CertificateService:
|
||||
Returns:
|
||||
Tuple of (ca_private_key, ca_certificate)
|
||||
"""
|
||||
logger.info("Generating Virtual Printer CA certificate...")
|
||||
logger.info("Generating new Virtual Printer CA certificate...")
|
||||
|
||||
# Generate CA private key
|
||||
ca_key = rsa.generate_private_key(
|
||||
@@ -126,11 +194,11 @@ class CertificateService:
|
||||
return ca_key, ca_cert
|
||||
|
||||
def generate_certificates(self) -> tuple[Path, Path]:
|
||||
"""Generate CA and printer certificates.
|
||||
"""Generate printer certificate (reusing existing CA if available).
|
||||
|
||||
Creates a certificate chain mimicking real Bambu printers:
|
||||
- BBL CA (self-signed root)
|
||||
- Printer certificate (CN=serial, signed by BBL CA)
|
||||
- CA certificate (reused if exists and valid, otherwise generated)
|
||||
- Printer certificate (CN=serial, signed by CA)
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_path, key_path)
|
||||
@@ -140,19 +208,8 @@ class CertificateService:
|
||||
# Ensure directory exists
|
||||
self.cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate or load CA
|
||||
ca_key, ca_cert = self._generate_ca_certificate()
|
||||
|
||||
# Save CA certificate and key
|
||||
self.ca_key_path.write_bytes(
|
||||
ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
self.ca_key_path.chmod(0o600)
|
||||
self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
# Get or create CA (reuses existing if valid)
|
||||
ca_key, ca_cert = self._get_or_create_ca()
|
||||
|
||||
# Generate printer private key
|
||||
printer_key = rsa.generate_private_key(
|
||||
@@ -246,9 +303,30 @@ class CertificateService:
|
||||
logger.info(f" Printer: CN={self.serial}")
|
||||
return self.cert_path, self.key_path
|
||||
|
||||
def delete_certificates(self) -> None:
|
||||
"""Delete existing certificates."""
|
||||
for path in [self.cert_path, self.key_path, self.ca_cert_path, self.ca_key_path]:
|
||||
def delete_printer_certificate(self) -> None:
|
||||
"""Delete only the printer certificate (preserves CA)."""
|
||||
for path in [self.cert_path, self.key_path]:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.info("Deleted virtual printer certificates")
|
||||
logger.info("Deleted printer certificate (CA preserved)")
|
||||
|
||||
def delete_certificates(self, include_ca: bool = False) -> None:
|
||||
"""Delete existing certificates.
|
||||
|
||||
Args:
|
||||
include_ca: If True, also delete CA certificate and key.
|
||||
If False (default), only delete printer certificate.
|
||||
"""
|
||||
# Always delete printer certificate
|
||||
for path in [self.cert_path, self.key_path]:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
# Only delete CA if explicitly requested
|
||||
if include_ca:
|
||||
for path in [self.ca_cert_path, self.ca_key_path]:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.info("Deleted all certificates including CA")
|
||||
else:
|
||||
logger.info("Deleted printer certificate (CA preserved)")
|
||||
|
||||
@@ -196,6 +196,40 @@ class FTPSession:
|
||||
else:
|
||||
await self.send(504, "Type not supported")
|
||||
|
||||
async def cmd_EPSV(self, arg: str) -> None:
|
||||
"""Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
|
||||
# Close any existing data connection/server
|
||||
await self._close_data_connection()
|
||||
|
||||
# Reset connection state
|
||||
self._data_connected.clear()
|
||||
self._data_reader = None
|
||||
self._data_writer = None
|
||||
|
||||
# Find a free port for passive data connection
|
||||
self.data_port = random.randint(50000, 60000)
|
||||
|
||||
try:
|
||||
# Create data server with TLS - use same context for session reuse
|
||||
self.data_server = await asyncio.start_server(
|
||||
self._handle_data_connection,
|
||||
"0.0.0.0",
|
||||
self.data_port,
|
||||
ssl=self.ssl_context,
|
||||
)
|
||||
|
||||
# EPSV response format: 229 Entering Extended Passive Mode (|||port|)
|
||||
await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
|
||||
logger.info(f"FTP EPSV listening on port {self.data_port}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create EPSV data connection: {e}")
|
||||
await self.send(425, "Cannot open data connection")
|
||||
|
||||
async def cmd_PASV(self, arg: str) -> None:
|
||||
"""Handle PASV command - set up passive data connection."""
|
||||
if not self.authenticated:
|
||||
@@ -244,6 +278,16 @@ class FTPSession:
|
||||
|
||||
async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""Handle incoming data connection (used by PASV)."""
|
||||
# Log TLS details for debugging
|
||||
ssl_obj = writer.get_extra_info("ssl_object")
|
||||
if ssl_obj:
|
||||
logger.info(
|
||||
f"FTP data TLS from {self.remote_ip}: cipher={ssl_obj.cipher()}, "
|
||||
f"version={ssl_obj.version()}, session_reused={ssl_obj.session_reused}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"FTP data connection from {self.remote_ip} has no SSL!")
|
||||
|
||||
logger.info(f"FTP data connection established from {self.remote_ip}")
|
||||
self._data_reader = reader
|
||||
self._data_writer = writer
|
||||
@@ -252,6 +296,8 @@ class FTPSession:
|
||||
|
||||
async def _close_data_connection(self) -> None:
|
||||
"""Close the data connection and server."""
|
||||
had_connection = self._data_writer is not None or self.data_server is not None
|
||||
|
||||
if self._data_writer:
|
||||
try:
|
||||
self._data_writer.close()
|
||||
@@ -269,6 +315,10 @@ class FTPSession:
|
||||
pass
|
||||
self.data_server = None
|
||||
|
||||
# Only delay if we actually closed something
|
||||
if had_connection:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def cmd_STOR(self, arg: str) -> None:
|
||||
"""Handle STOR command - receive file upload."""
|
||||
if not self.authenticated:
|
||||
@@ -436,6 +486,7 @@ class VirtualPrinterFTPServer:
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
self._ssl_context: ssl.SSLContext | None = None
|
||||
self._active_sessions: list[asyncio.Task] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the implicit FTPS server."""
|
||||
@@ -454,6 +505,11 @@ class VirtualPrinterFTPServer:
|
||||
self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
|
||||
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
|
||||
# Use standard TLS settings for compatibility
|
||||
self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
|
||||
|
||||
logger.info("FTP SSL context created with standard settings")
|
||||
|
||||
try:
|
||||
# Create server with SSL - TLS handshake happens before any FTP data
|
||||
self._server = await asyncio.start_server(
|
||||
@@ -495,13 +551,31 @@ class VirtualPrinterFTPServer:
|
||||
on_file_received=self.on_file_received,
|
||||
)
|
||||
|
||||
await session.handle()
|
||||
# Track the session task so we can cancel it on stop
|
||||
task = asyncio.current_task()
|
||||
if task:
|
||||
self._active_sessions.append(task)
|
||||
try:
|
||||
await session.handle()
|
||||
finally:
|
||||
if task and task in self._active_sessions:
|
||||
self._active_sessions.remove(task)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the FTPS server."""
|
||||
logger.info("Stopping FTP server")
|
||||
self._running = False
|
||||
|
||||
# Cancel all active sessions first
|
||||
for task in self._active_sessions[:]: # Copy list to avoid modification during iteration
|
||||
task.cancel()
|
||||
|
||||
# Wait briefly for sessions to clean up
|
||||
if self._active_sessions:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self._active_sessions.clear()
|
||||
|
||||
if self._server:
|
||||
try:
|
||||
self._server.close()
|
||||
|
||||
@@ -17,15 +17,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Mapping of SSDP model codes to display names
|
||||
# These are the codes that slicers expect during discovery
|
||||
# Sources:
|
||||
# - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
|
||||
# - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
|
||||
VIRTUAL_PRINTER_MODELS = {
|
||||
# X1 Series
|
||||
"BL-P001": "X1C", # X1 Carbon
|
||||
"BL-P002": "X1", # X1
|
||||
"BL-P003": "X1E", # X1E
|
||||
"3DPrinter-X1-Carbon": "X1C", # X1 Carbon
|
||||
"3DPrinter-X1": "X1", # X1
|
||||
"C13": "X1E", # X1E
|
||||
# P Series
|
||||
"C11": "P1S", # P1S
|
||||
"C12": "P1P", # P1P
|
||||
"C13": "P2S", # P2S
|
||||
"C11": "P1P", # P1P
|
||||
"C12": "P1S", # P1S
|
||||
"N7": "P2S", # P2S
|
||||
# A1 Series
|
||||
"N2S": "A1", # A1
|
||||
"N1": "A1 Mini", # A1 Mini
|
||||
@@ -35,8 +38,35 @@ VIRTUAL_PRINTER_MODELS = {
|
||||
"O1S": "H2S", # H2S
|
||||
}
|
||||
|
||||
# Serial number prefixes for each model (based on Bambu Lab serial number format)
|
||||
# Format: MMM??RYMDDUUUUU (15 chars total)
|
||||
# MMM = Model prefix (3 chars)
|
||||
# ?? = Unknown/revision code (2 chars)
|
||||
# R = Revision letter (1 char)
|
||||
# Y = Year digit (1 char)
|
||||
# M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
|
||||
# DD = Day (2 chars)
|
||||
# UUUUU = Unit number (5 chars)
|
||||
MODEL_SERIAL_PREFIXES = {
|
||||
# X1 Series
|
||||
"3DPrinter-X1-Carbon": "00M00A", # X1C
|
||||
"3DPrinter-X1": "00M00A", # X1
|
||||
"C13": "03W00A", # X1E
|
||||
# P Series
|
||||
"C11": "01S00A", # P1P
|
||||
"C12": "01P00A", # P1S
|
||||
"N7": "22E00A", # P2S
|
||||
# A1 Series
|
||||
"N2S": "03900A", # A1
|
||||
"N1": "03000A", # A1 Mini
|
||||
# H2 Series
|
||||
"O1D": "09400A", # H2D
|
||||
"O1C": "09400A", # H2C
|
||||
"O1S": "09400A", # H2S
|
||||
}
|
||||
|
||||
# Default model
|
||||
DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
|
||||
DEFAULT_VIRTUAL_PRINTER_MODEL = "3DPrinter-X1-Carbon" # X1C
|
||||
|
||||
|
||||
class VirtualPrinterManager:
|
||||
@@ -44,7 +74,7 @@ class VirtualPrinterManager:
|
||||
|
||||
# Fixed configuration
|
||||
PRINTER_NAME = "Bambuddy"
|
||||
PRINTER_SERIAL = "00M09A391800001" # X1C serial format
|
||||
SERIAL_SUFFIX = "391800001" # Fixed suffix for virtual printer
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the virtual printer manager."""
|
||||
@@ -67,12 +97,29 @@ class VirtualPrinterManager:
|
||||
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)
|
||||
# Certificate service
|
||||
self._cert_service = CertificateService(self._cert_dir)
|
||||
|
||||
# Track pending uploads for MQTT correlation
|
||||
self._pending_files: dict[str, Path] = {}
|
||||
|
||||
def _get_serial_for_model(self, model: str) -> str:
|
||||
"""Get appropriate serial number for the given model.
|
||||
|
||||
Args:
|
||||
model: SSDP model code (e.g., 'BL-P001', 'C11')
|
||||
|
||||
Returns:
|
||||
Serial number with correct prefix for the model
|
||||
"""
|
||||
prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
|
||||
return f"{prefix}{self.SERIAL_SUFFIX}"
|
||||
|
||||
@property
|
||||
def printer_serial(self) -> str:
|
||||
"""Get the current printer serial number based on model."""
|
||||
return self._get_serial_for_model(self._model)
|
||||
|
||||
def set_session_factory(self, session_factory: Callable) -> None:
|
||||
"""Set the database session factory.
|
||||
|
||||
@@ -112,6 +159,7 @@ class VirtualPrinterManager:
|
||||
# Validate model if provided
|
||||
new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
|
||||
model_changed = new_model != self._model
|
||||
old_model = self._model
|
||||
|
||||
self._access_code = access_code
|
||||
self._mode = mode
|
||||
@@ -123,8 +171,12 @@ class VirtualPrinterManager:
|
||||
await self._stop()
|
||||
elif enabled and self._enabled and model_changed:
|
||||
# Model changed while running - restart services
|
||||
logger.info(f"Model changed from {old_model} to {new_model}, restarting...")
|
||||
await self._stop()
|
||||
# Give time for ports to be released
|
||||
await asyncio.sleep(0.5)
|
||||
await self._start()
|
||||
logger.info("Virtual printer restarted with new model")
|
||||
|
||||
self._enabled = enabled
|
||||
|
||||
@@ -132,8 +184,14 @@ class VirtualPrinterManager:
|
||||
"""Start all virtual printer services."""
|
||||
logger.info("Starting virtual printer services...")
|
||||
|
||||
# Ensure certificates exist
|
||||
cert_path, key_path = self._cert_service.ensure_certificates()
|
||||
# Update certificate service with current serial (based on model)
|
||||
current_serial = self.printer_serial
|
||||
self._cert_service.serial = current_serial
|
||||
|
||||
# Regenerate printer cert if serial changed (CA is preserved)
|
||||
self._cert_service.delete_printer_certificate()
|
||||
cert_path, key_path = self._cert_service.generate_certificates()
|
||||
logger.info(f"Generated certificate for serial: {current_serial}")
|
||||
|
||||
# Create directories
|
||||
self._upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -142,7 +200,7 @@ class VirtualPrinterManager:
|
||||
# Initialize services
|
||||
self._ssdp = VirtualPrinterSSDPServer(
|
||||
name=self.PRINTER_NAME,
|
||||
serial=self.PRINTER_SERIAL,
|
||||
serial=self.printer_serial,
|
||||
model=self._model,
|
||||
)
|
||||
|
||||
@@ -155,7 +213,7 @@ class VirtualPrinterManager:
|
||||
)
|
||||
|
||||
self._mqtt = SimpleMQTTServer(
|
||||
serial=self.PRINTER_SERIAL,
|
||||
serial=self.printer_serial,
|
||||
access_code=self._access_code,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
@@ -176,27 +234,13 @@ class VirtualPrinterManager:
|
||||
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})")
|
||||
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
|
||||
|
||||
# Stop services first - this closes servers and cancels active sessions
|
||||
if self._ftp:
|
||||
await self._ftp.stop()
|
||||
self._ftp = None
|
||||
@@ -205,6 +249,22 @@ class VirtualPrinterManager:
|
||||
await self._mqtt.stop()
|
||||
self._mqtt = None
|
||||
|
||||
if self._ssdp:
|
||||
await self._ssdp.stop()
|
||||
self._ssdp = None
|
||||
|
||||
# Cancel remaining tasks with short timeout
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
|
||||
if self._tasks:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
|
||||
except TimeoutError:
|
||||
logger.debug("Some tasks didn't stop in time")
|
||||
|
||||
self._tasks = []
|
||||
|
||||
logger.info("Virtual printer stopped")
|
||||
|
||||
async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
|
||||
@@ -348,7 +408,7 @@ class VirtualPrinterManager:
|
||||
"running": self.is_running,
|
||||
"mode": self._mode,
|
||||
"name": self.PRINTER_NAME,
|
||||
"serial": self.PRINTER_SERIAL,
|
||||
"serial": self.printer_serial,
|
||||
"model": self._model,
|
||||
"model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
|
||||
"pending_files": len(self._pending_files),
|
||||
|
||||
@@ -289,8 +289,8 @@ class SimpleMQTTServer:
|
||||
pass
|
||||
self._status_push_task = None
|
||||
|
||||
# Close all client connections
|
||||
for _client_id, writer in self._clients.items():
|
||||
# Close all client connections (iterate over copy to avoid modification during iteration)
|
||||
for _client_id, writer in list(self._clients.items()):
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestVirtualPrinterManager:
|
||||
assert status["running"] is True
|
||||
assert status["mode"] == "immediate"
|
||||
assert status["name"] == "Bambuddy"
|
||||
assert status["serial"] == "00M09A391800001"
|
||||
assert status["serial"] == "00M09A391800001" # Constant X1C serial for cert stability
|
||||
assert status["model"] == "C11"
|
||||
assert status["model_name"] == "P1S"
|
||||
assert status["pending_files"] == 1
|
||||
|
||||
+4
-2
@@ -18,7 +18,10 @@ services:
|
||||
volumes:
|
||||
- bambuddy_data:/app/data
|
||||
- bambuddy_logs:/app/logs
|
||||
- bambuddy_vprinter:/app/virtual_printer
|
||||
#
|
||||
# Share virtual printer certs with native installation
|
||||
# This ensures the slicer only needs to trust one CA certificate.
|
||||
- ./virtual_printer:/app/data/virtual_printer
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
restart: unless-stopped
|
||||
@@ -26,4 +29,3 @@ services:
|
||||
volumes:
|
||||
bambuddy_data:
|
||||
bambuddy_logs:
|
||||
bambuddy_vprinter:
|
||||
|
||||
@@ -23,7 +23,13 @@ vi.mock('../../api/client', () => ({
|
||||
virtualPrinterApi: {
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
getModels: vi.fn(),
|
||||
getModels: vi.fn().mockResolvedValue({
|
||||
models: {
|
||||
'3DPrinter-X1-Carbon': 'X1C',
|
||||
'C12': 'P1S',
|
||||
'N7': 'P2S',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -35,38 +41,26 @@ const createMockSettings = (overrides = {}) => ({
|
||||
enabled: false,
|
||||
access_code_set: false,
|
||||
mode: 'immediate' as const,
|
||||
model: 'BL-P001',
|
||||
model: '3DPrinter-X1-Carbon',
|
||||
status: {
|
||||
enabled: false,
|
||||
running: false,
|
||||
mode: 'immediate',
|
||||
name: 'Bambuddy',
|
||||
serial: '00M09A391800001',
|
||||
model: 'BL-P001',
|
||||
serial: '00M00A391800001',
|
||||
model: '3DPrinter-X1-Carbon',
|
||||
model_name: 'X1C',
|
||||
pending_files: 0,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockModelsData = {
|
||||
models: {
|
||||
'BL-P001': 'X1C',
|
||||
'BL-P002': 'X1',
|
||||
'BL-P003': 'X1E',
|
||||
'C11': 'P1S',
|
||||
'C12': 'P1P',
|
||||
},
|
||||
default: 'BL-P001',
|
||||
};
|
||||
|
||||
describe('VirtualPrinterSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementation
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings());
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings());
|
||||
vi.mocked(virtualPrinterApi.getModels).mockResolvedValue(mockModelsData);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
@@ -159,7 +153,9 @@ describe('VirtualPrinterSettings', () => {
|
||||
running: true,
|
||||
mode: 'immediate',
|
||||
name: 'Bambuddy',
|
||||
serial: '00M09A391800001',
|
||||
serial: '00M00A391800001',
|
||||
model: '3DPrinter-X1-Carbon',
|
||||
model_name: 'X1C',
|
||||
pending_files: 0,
|
||||
},
|
||||
})
|
||||
@@ -170,7 +166,7 @@ describe('VirtualPrinterSettings', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Status Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bambuddy')).toBeInTheDocument();
|
||||
expect(screen.getByText('00M09A391800001')).toBeInTheDocument();
|
||||
expect(screen.getByText('00M00A391800001')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -409,19 +405,28 @@ describe('VirtualPrinterSettings', () => {
|
||||
});
|
||||
|
||||
describe('info section', () => {
|
||||
it('shows required ports warning', async () => {
|
||||
it('shows setup required warning', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Required ports: 2021.*8883.*990/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Setup Required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows iptables instructions', async () => {
|
||||
it('shows link to setup guide', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/iptables -t nat -A PREROUTING/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Read the setup guide before enabling')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows how it works section', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How it works:')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Complete the setup guide for your platform/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ function PendingUploadItem({
|
||||
const [tags, setTags] = useState(upload.tags || '');
|
||||
const [notes, setNotes] = useState(upload.notes || '');
|
||||
const [projectId, setProjectId] = useState<number | null>(upload.project_id);
|
||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -100,7 +101,7 @@ function PendingUploadItem({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDiscard(upload.id)}
|
||||
onClick={() => setShowDiscardConfirm(true)}
|
||||
disabled={isDiscarding}
|
||||
>
|
||||
{isDiscarding ? (
|
||||
@@ -112,6 +113,21 @@ function PendingUploadItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discard Confirmation Modal */}
|
||||
{showDiscardConfirm && (
|
||||
<ConfirmModal
|
||||
title="Discard Upload"
|
||||
message={`Are you sure you want to discard "${upload.filename}"? This cannot be undone.`}
|
||||
confirmText="Discard"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
onDiscard(upload.id);
|
||||
setShowDiscardConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowDiscardConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expanded details for adding tags/notes/project */}
|
||||
{expanded && (
|
||||
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown } from 'lucide-react';
|
||||
import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import { virtualPrinterApi } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
@@ -13,8 +13,9 @@ export function VirtualPrinterSettings() {
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
const [localAccessCode, setLocalAccessCode] = useState('');
|
||||
const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
|
||||
const [localModel, setLocalModel] = useState('BL-P001');
|
||||
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
|
||||
|
||||
// Fetch current settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -45,6 +46,7 @@ export function VirtualPrinterSettings() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
|
||||
showToast('Virtual printer settings updated');
|
||||
setPendingAction(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to update settings', 'error');
|
||||
@@ -54,6 +56,7 @@ export function VirtualPrinterSettings() {
|
||||
setLocalMode(settings.mode);
|
||||
setLocalModel(settings.model);
|
||||
}
|
||||
setPendingAction(null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,6 +70,7 @@ export function VirtualPrinterSettings() {
|
||||
}
|
||||
|
||||
setLocalEnabled(newEnabled);
|
||||
setPendingAction('toggle');
|
||||
updateMutation.mutate({
|
||||
enabled: newEnabled,
|
||||
access_code: localAccessCode || undefined,
|
||||
@@ -85,6 +89,7 @@ export function VirtualPrinterSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction('accessCode');
|
||||
updateMutation.mutate({
|
||||
access_code: localAccessCode,
|
||||
});
|
||||
@@ -93,11 +98,13 @@ export function VirtualPrinterSettings() {
|
||||
|
||||
const handleModeChange = (mode: 'immediate' | 'queue') => {
|
||||
setLocalMode(mode);
|
||||
setPendingAction('mode');
|
||||
updateMutation.mutate({ mode });
|
||||
};
|
||||
|
||||
const handleModelChange = (model: string) => {
|
||||
setLocalModel(model);
|
||||
setPendingAction('model');
|
||||
updateMutation.mutate({ model });
|
||||
};
|
||||
|
||||
@@ -149,10 +156,10 @@ export function VirtualPrinterSettings() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={pendingAction === 'toggle'}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
} ${updateMutation.isPending ? 'opacity-50' : ''}`}
|
||||
} ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
@@ -172,18 +179,20 @@ export function VirtualPrinterSettings() {
|
||||
<select
|
||||
value={localModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={updateMutation.isPending || isRunning}
|
||||
disabled={pendingAction === 'model' || (localEnabled && isRunning)}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
|
||||
>
|
||||
{modelsData?.models && Object.entries(modelsData.models).map(([code, name]) => (
|
||||
{modelsData?.models && Object.entries(modelsData.models)
|
||||
.sort(([, a], [, b]) => (a as string).localeCompare(b as string))
|
||||
.map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name} ({code})
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
{isRunning && (
|
||||
{localEnabled && isRunning && (
|
||||
<p className="text-xs text-yellow-400 mt-2">
|
||||
<AlertTriangle className="w-3 h-3 inline mr-1" />
|
||||
Disable the virtual printer to change the model
|
||||
@@ -226,10 +235,10 @@ export function VirtualPrinterSettings() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAccessCodeChange}
|
||||
disabled={!localAccessCode || updateMutation.isPending}
|
||||
disabled={!localAccessCode || pendingAction === 'accessCode'}
|
||||
variant="primary"
|
||||
>
|
||||
{updateMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
|
||||
{pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
@@ -248,7 +257,7 @@ export function VirtualPrinterSettings() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleModeChange('immediate')}
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={pendingAction === 'mode'}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
localMode === 'immediate'
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
@@ -260,7 +269,7 @@ export function VirtualPrinterSettings() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('queue')}
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={pendingAction === 'mode'}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
localMode === 'queue'
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
@@ -278,7 +287,34 @@ export function VirtualPrinterSettings() {
|
||||
|
||||
{/* Right Column - Info & Status */}
|
||||
<div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
|
||||
{/* Info Card */}
|
||||
{/* Setup Required Warning */}
|
||||
<Card className="border-l-4 border-l-yellow-500">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="text-white font-medium mb-2">
|
||||
Setup Required
|
||||
</p>
|
||||
<p className="text-bambu-gray mb-3">
|
||||
The virtual printer feature requires additional system configuration before it will work.
|
||||
This includes port forwarding, firewall rules, and platform-specific settings.
|
||||
</p>
|
||||
<a
|
||||
href="https://wiki.bambuddy.cool/features/virtual-printer/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500/20 border border-yellow-500/50 rounded-md text-yellow-400 hover:bg-yellow-500/30 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Read the setup guide before enabling
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* How it works */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -288,39 +324,13 @@ export function VirtualPrinterSettings() {
|
||||
<strong className="text-white">How it works:</strong>
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Complete the setup guide for your platform</li>
|
||||
<li>Enable the virtual printer and set an access code</li>
|
||||
<li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
|
||||
<li>The "Bambuddy" printer should appear in the discovery list</li>
|
||||
<li>Connect using the access code you set</li>
|
||||
<li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
|
||||
</ol>
|
||||
<p className="mt-3 text-yellow-400/80">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Required ports: 2021 (SSDP), 8883 (MQTT), 990 (FTP)
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-bambu-gray space-y-1">
|
||||
<p>Port 990 requires root or iptables redirect:</p>
|
||||
<code className="block bg-bambu-dark-tertiary px-2 py-1 rounded text-[10px]">
|
||||
sudo iptables -t nat -A PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990
|
||||
</code>
|
||||
<code className="block bg-bambu-dark-tertiary px-2 py-1 rounded text-[10px]">
|
||||
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990
|
||||
</code>
|
||||
</div>
|
||||
<div className="mt-3 p-2 bg-blue-500/10 border border-blue-500/30 rounded text-xs">
|
||||
<strong className="text-blue-400">Docker users:</strong>{' '}
|
||||
<span className="text-bambu-gray">
|
||||
Host network mode is required for SSDP discovery.{' '}
|
||||
<a
|
||||
href="https://wiki.bambuddy.cool/features/virtual-printer/#docker-configuration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
See Docker configuration guide →
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-vV_B6YVc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B3w4bCH5.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cq6n_p-E.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ds1sabci.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user