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:
maziggy
2026-01-04 14:05:08 +01:00
parent 81c8dd7d0c
commit bb37a8c8a8
13 changed files with 418 additions and 153 deletions
+20
View File
@@ -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()
+91 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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>