From bb37a8c8a80a1ba35116f214e38fd954ca5888e1 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 4 Jan 2026 14:05:08 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 20 +++ .../services/virtual_printer/certificate.py | 122 ++++++++++++++---- .../services/virtual_printer/ftp_server.py | 76 ++++++++++- .../app/services/virtual_printer/manager.py | 122 +++++++++++++----- .../services/virtual_printer/mqtt_server.py | 4 +- .../unit/services/test_virtual_printer.py | 2 +- docker-compose.yml | 6 +- .../VirtualPrinterSettings.test.tsx | 49 +++---- .../src/components/PendingUploadsPanel.tsx | 18 ++- .../src/components/VirtualPrinterSettings.tsx | 90 +++++++------ .../{index-vV_B6YVc.js => index-Cq6n_p-E.js} | 56 ++++---- ...{index-B3w4bCH5.css => index-Ds1sabci.css} | 2 +- static/index.html | 4 +- 13 files changed, 418 insertions(+), 153 deletions(-) rename static/assets/{index-vV_B6YVc.js => index-Cq6n_p-E.js} (95%) rename static/assets/{index-B3w4bCH5.css => index-Ds1sabci.css} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e298fb8bb..0d3fc5ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/services/virtual_printer/certificate.py b/backend/app/services/virtual_printer/certificate.py index 9d5c0bdb5..5b1029a97 100644 --- a/backend/app/services/virtual_printer/certificate.py +++ b/backend/app/services/virtual_printer/certificate.py @@ -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)") diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py index ac8c5a90b..aa217ef25 100644 --- a/backend/app/services/virtual_printer/ftp_server.py +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -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() diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index fff78be3c..210ad4371 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -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), diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index f963e8fd1..438289dbb 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -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() diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 25a1b86c6..380866100 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 56f74136c..3c6f0fe24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx index 387e39a2a..6c7e4f090 100644 --- a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx +++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx @@ -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(); 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(); 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(); + + await waitFor(() => { + expect(screen.getByText('How it works:')).toBeInTheDocument(); + expect(screen.getByText(/Complete the setup guide for your platform/)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/PendingUploadsPanel.tsx b/frontend/src/components/PendingUploadsPanel.tsx index abc6cfacf..1feb54ea0 100644 --- a/frontend/src/components/PendingUploadsPanel.tsx +++ b/frontend/src/components/PendingUploadsPanel.tsx @@ -50,6 +50,7 @@ function PendingUploadItem({ const [tags, setTags] = useState(upload.tags || ''); const [notes, setNotes] = useState(upload.notes || ''); const [projectId, setProjectId] = useState(upload.project_id); + const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); return ( @@ -100,7 +101,7 @@ function PendingUploadItem({

@@ -248,7 +257,7 @@ export function VirtualPrinterSettings() {