Add virtual printer model selection

Features:
  - Configurable printer model for virtual printer emulation
  - Supports X1 series (X1C, X1, X1E), P series (P1S, P1P, P2S),
    A1 series (A1, A1 Mini), and H2 series (H2D, H2C, H2S)
  - Dropdown in Settings > Virtual Printer to select model
  - Model affects SSDP discovery and slicer compatibility
  - Model change restarts virtual printer services automatically

  Backend:
  - Added VIRTUAL_PRINTER_MODELS mapping in manager.py
  - Added virtual_printer_model setting in database
  - New GET /api/v1/settings/virtual-printer/models endpoint
  - Updated PUT /api/v1/settings/virtual-printer to accept model

  Frontend:
  - Added model dropdown to VirtualPrinterSettings component
  - Status display shows selected model name
  - Model change disabled while virtual printer is running

  Tests:
  - Added 3 unit tests for model configuration
  - Updated frontend test mocks for getModels API
This commit is contained in:
maziggy
2026-01-04 09:39:06 +01:00
parent 8639f39028
commit 81c8dd7d0c
13 changed files with 304 additions and 78 deletions
+6
View File
@@ -16,6 +16,11 @@ All notable changes to Bambuddy will be documented in this file.
- Play button to manually release staged prints to the queue
- Edit queue items to switch between ASAP, Scheduled, and Queue Only modes
- Useful for preparing print batches before activating
- **Virtual printer model selection** - Choose which Bambu printer model to emulate:
- Dropdown in Settings > Virtual Printer to select model
- 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
### Fixed
- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection
@@ -23,6 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
### 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)
## [0.1.6b5] - 2026-01-02
+1
View File
@@ -103,6 +103,7 @@
### 🖨️ Virtual Printer
- Emulates a Bambu Lab printer on your network
- Send prints directly from Bambu Studio/Orca Slicer
- Configurable printer model (X1C, P1S, A1, H2D, etc.)
- Queue mode or auto-start mode
- SSDP discovery (appears in slicer automatically)
- Secure TLS/MQTT communication
+42 -2
View File
@@ -1592,22 +1592,26 @@ async def import_backup(
vp_enabled = await get_setting(db, "virtual_printer_enabled")
vp_access_code = await get_setting(db, "virtual_printer_access_code")
vp_mode = await get_setting(db, "virtual_printer_mode")
vp_model = await get_setting(db, "virtual_printer_model")
enabled = vp_enabled and vp_enabled.lower() == "true"
access_code = vp_access_code or ""
mode = vp_mode or "immediate"
model = vp_model or ""
if enabled and access_code:
await virtual_printer_manager.configure(
enabled=True,
access_code=access_code,
mode=mode,
model=model,
)
elif not enabled and virtual_printer_manager.is_enabled:
await virtual_printer_manager.configure(
enabled=False,
access_code=access_code,
mode=mode,
model=model,
)
except Exception:
pass # Virtual printer config failed, but don't fail the restore
@@ -1649,19 +1653,38 @@ async def import_backup(
# =============================================================================
@router.get("/virtual-printer/models")
async def get_virtual_printer_models():
"""Get available virtual printer models."""
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
VIRTUAL_PRINTER_MODELS,
)
return {
"models": VIRTUAL_PRINTER_MODELS,
"default": DEFAULT_VIRTUAL_PRINTER_MODEL,
}
@router.get("/virtual-printer")
async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
"""Get virtual printer settings and status."""
from backend.app.services.virtual_printer import virtual_printer_manager
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
virtual_printer_manager,
)
enabled = await get_setting(db, "virtual_printer_enabled")
access_code = await get_setting(db, "virtual_printer_access_code")
mode = await get_setting(db, "virtual_printer_mode")
model = await get_setting(db, "virtual_printer_model")
return {
"enabled": enabled == "true" if enabled else False,
"access_code_set": bool(access_code),
"mode": mode or "immediate",
"model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
"status": virtual_printer_manager.get_status(),
}
@@ -1671,20 +1694,27 @@ async def update_virtual_printer_settings(
enabled: bool = None,
access_code: str = None,
mode: str = None,
model: str = None,
db: AsyncSession = Depends(get_db),
):
"""Update virtual printer settings and restart services if needed."""
from backend.app.services.virtual_printer import virtual_printer_manager
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
VIRTUAL_PRINTER_MODELS,
virtual_printer_manager,
)
# Get current values
current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
# Apply updates
new_enabled = enabled if enabled is not None else current_enabled
new_access_code = access_code if access_code is not None else current_access_code
new_mode = mode if mode is not None else current_mode
new_model = model if model is not None else current_model
# Validate mode
if new_mode not in ("immediate", "queue"):
@@ -1693,6 +1723,13 @@ async def update_virtual_printer_settings(
content={"detail": "Mode must be 'immediate' or 'queue'"},
)
# Validate model
if model is not None and model not in VIRTUAL_PRINTER_MODELS:
return JSONResponse(
status_code=400,
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
)
# Validate access code when enabling
if new_enabled and not new_access_code:
return JSONResponse(
@@ -1712,6 +1749,8 @@ async def update_virtual_printer_settings(
if access_code is not None:
await set_setting(db, "virtual_printer_access_code", access_code)
await set_setting(db, "virtual_printer_mode", new_mode)
if model is not None:
await set_setting(db, "virtual_printer_model", model)
await db.commit()
# Reconfigure virtual printer
@@ -1720,6 +1759,7 @@ async def update_virtual_printer_settings(
enabled=new_enabled,
access_code=new_access_code,
mode=new_mode,
model=new_model,
)
except ValueError as e:
return JSONResponse(
@@ -1,5 +1,13 @@
"""Virtual printer services for slicer integration."""
from backend.app.services.virtual_printer.manager import virtual_printer_manager
from backend.app.services.virtual_printer.manager import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
VIRTUAL_PRINTER_MODELS,
virtual_printer_manager,
)
__all__ = ["virtual_printer_manager"]
__all__ = [
"virtual_printer_manager",
"VIRTUAL_PRINTER_MODELS",
"DEFAULT_VIRTUAL_PRINTER_MODEL",
]
@@ -15,13 +15,36 @@ from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPS
logger = logging.getLogger(__name__)
# Mapping of SSDP model codes to display names
# These are the codes that slicers expect during discovery
VIRTUAL_PRINTER_MODELS = {
# X1 Series
"BL-P001": "X1C", # X1 Carbon
"BL-P002": "X1", # X1
"BL-P003": "X1E", # X1E
# P Series
"C11": "P1S", # P1S
"C12": "P1P", # P1P
"C13": "P2S", # P2S
# A1 Series
"N2S": "A1", # A1
"N1": "A1 Mini", # A1 Mini
# H2 Series
"O1D": "H2D", # H2D
"O1C": "H2C", # H2C
"O1S": "H2S", # H2S
}
# Default model
DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
class VirtualPrinterManager:
"""Manages the virtual printer lifecycle and coordinates all services."""
# Fixed configuration
PRINTER_NAME = "Bambuddy"
PRINTER_SERIAL = "00M09A391800001" # X1C serial format
PRINTER_MODEL = "3DPrinter-X1-Carbon" # Full model name for slicer compatibility
def __init__(self):
"""Initialize the virtual printer manager."""
@@ -29,6 +52,7 @@ class VirtualPrinterManager:
self._enabled = False
self._access_code = ""
self._mode = "immediate"
self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
# Service instances
self._ssdp: VirtualPrinterSSDPServer | None = None
@@ -72,6 +96,7 @@ class VirtualPrinterManager:
enabled: bool,
access_code: str = "",
mode: str = "immediate",
model: str = "",
) -> None:
"""Configure and start/stop virtual printer.
@@ -79,17 +104,27 @@ class VirtualPrinterManager:
enabled: Whether to enable the virtual printer
access_code: Authentication password for slicer connections
mode: Archive mode - 'immediate' or 'queue'
model: SSDP model code (e.g., 'BL-P001' for X1C)
"""
if enabled and not access_code:
raise ValueError("Access code is required when enabling virtual printer")
# Validate model if provided
new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
model_changed = new_model != self._model
self._access_code = access_code
self._mode = mode
self._model = new_model
if enabled and not self._enabled:
await self._start()
elif not enabled and self._enabled:
await self._stop()
elif enabled and self._enabled and model_changed:
# Model changed while running - restart services
await self._stop()
await self._start()
self._enabled = enabled
@@ -108,7 +143,7 @@ class VirtualPrinterManager:
self._ssdp = VirtualPrinterSSDPServer(
name=self.PRINTER_NAME,
serial=self.PRINTER_SERIAL,
model=self.PRINTER_MODEL,
model=self._model,
)
self._ftp = VirtualPrinterFTPServer(
@@ -314,7 +349,8 @@ class VirtualPrinterManager:
"mode": self._mode,
"name": self.PRINTER_NAME,
"serial": self.PRINTER_SERIAL,
"model": self.PRINTER_MODEL,
"model": self._model,
"model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
"pending_files": len(self._pending_files),
}
@@ -59,6 +59,54 @@ class TestVirtualPrinterManager:
with pytest.raises(ValueError, match="Access code is required"):
await manager.configure(enabled=True)
@pytest.mark.asyncio
async def test_configure_sets_model(self, manager):
"""Verify configure stores model correctly."""
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
mode="immediate",
model="C11", # P1S model code
)
assert manager._model == "C11"
@pytest.mark.asyncio
async def test_configure_ignores_invalid_model(self, manager):
"""Verify configure ignores invalid model codes."""
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
model="INVALID",
)
# Should keep default model
assert manager._model == "BL-P001"
@pytest.mark.asyncio
async def test_configure_restarts_on_model_change(self, manager):
"""Verify model change restarts services when running."""
# Simulate running state
manager._enabled = True
manager._model = "BL-P001"
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
manager._stop = AsyncMock()
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
model="C11",
)
# Should have stopped and started
manager._stop.assert_called_once()
manager._start.assert_called_once()
# ========================================================================
# Tests for status
# ========================================================================
@@ -67,6 +115,7 @@ class TestVirtualPrinterManager:
"""Verify get_status returns expected fields."""
manager._enabled = True
manager._mode = "immediate"
manager._model = "C11"
manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
# Simulate running tasks
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
@@ -78,6 +127,8 @@ class TestVirtualPrinterManager:
assert status["mode"] == "immediate"
assert status["name"] == "Bambuddy"
assert status["serial"] == "00M09A391800001"
assert status["model"] == "C11"
assert status["model_name"] == "P1S"
assert status["pending_files"] == 1
def test_get_status_when_stopped(self, manager):
@@ -23,6 +23,7 @@ vi.mock('../../api/client', () => ({
virtualPrinterApi: {
getSettings: vi.fn(),
updateSettings: vi.fn(),
getModels: vi.fn(),
},
}));
@@ -34,23 +35,38 @@ const createMockSettings = (overrides = {}) => ({
enabled: false,
access_code_set: false,
mode: 'immediate' as const,
model: 'BL-P001',
status: {
enabled: false,
running: false,
mode: 'immediate',
name: 'Bambuddy',
serial: '00M09A391800001',
model: 'BL-P001',
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', () => {
+11
View File
@@ -2450,6 +2450,7 @@ export interface VirtualPrinterStatus {
name: string;
serial: string;
model: string;
model_name: string;
pending_files: number;
}
@@ -2457,9 +2458,15 @@ export interface VirtualPrinterSettings {
enabled: boolean;
access_code_set: boolean;
mode: 'immediate' | 'queue';
model: string;
status: VirtualPrinterStatus;
}
export interface VirtualPrinterModels {
models: Record<string, string>; // SSDP code -> display name
default: string;
}
export interface PendingUpload {
id: number;
filename: string;
@@ -2476,15 +2483,19 @@ export interface PendingUpload {
export const virtualPrinterApi = {
getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
getModels: () => request<VirtualPrinterModels>('/settings/virtual-printer/models'),
updateSettings: (data: {
enabled?: boolean;
access_code?: string;
mode?: 'immediate' | 'queue';
model?: string;
}) => {
const params = new URLSearchParams();
if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
if (data.access_code !== undefined) params.set('access_code', data.access_code);
if (data.mode !== undefined) params.set('mode', data.mode);
if (data.model !== undefined) params.set('model', data.model);
return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
method: 'PUT',
@@ -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 } from 'lucide-react';
import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown } from 'lucide-react';
import { virtualPrinterApi } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
@@ -13,6 +13,7 @@ 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 [showAccessCode, setShowAccessCode] = useState(false);
// Fetch current settings
@@ -22,17 +23,24 @@ export function VirtualPrinterSettings() {
refetchInterval: 10000, // Refresh every 10 seconds for status updates
});
// Fetch available models
const { data: modelsData } = useQuery({
queryKey: ['virtual-printer-models'],
queryFn: virtualPrinterApi.getModels,
});
// Initialize local state from settings
useEffect(() => {
if (settings) {
setLocalEnabled(settings.enabled);
setLocalMode(settings.mode);
setLocalModel(settings.model);
}
}, [settings]);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue' }) =>
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue'; model?: string }) =>
virtualPrinterApi.updateSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -44,6 +52,7 @@ export function VirtualPrinterSettings() {
if (settings) {
setLocalEnabled(settings.enabled);
setLocalMode(settings.mode);
setLocalModel(settings.model);
}
},
});
@@ -87,6 +96,11 @@ export function VirtualPrinterSettings() {
updateMutation.mutate({ mode });
};
const handleModelChange = (model: string) => {
setLocalModel(model);
updateMutation.mutate({ model });
};
if (isLoading) {
return (
<Card>
@@ -148,6 +162,35 @@ export function VirtualPrinterSettings() {
</button>
</div>
{/* Printer Model */}
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">Printer Model</div>
<div className="text-sm text-bambu-gray mb-3">
Select which printer model to emulate.
</div>
<div className="relative">
<select
value={localModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={updateMutation.isPending || 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]) => (
<option key={code} value={code}>
{name} ({code})
</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 && (
<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
</p>
)}
</div>
{/* Access Code */}
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">Access Code</div>
@@ -264,6 +307,20 @@ export function VirtualPrinterSettings() {
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>
@@ -283,7 +340,7 @@ export function VirtualPrinterSettings() {
</div>
<div>
<div className="text-bambu-gray">Model</div>
<div className="text-white">{status.model?.replace('3DPrinter-', '').replace('-', ' ') || 'X1 Carbon'}</div>
<div className="text-white">{status.model_name || status.model}</div>
</div>
<div>
<div className="text-bambu-gray">Serial Number</div>
File diff suppressed because one or more lines are too long
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-DXIzheUf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YKsbWJQ9.css">
<script type="module" crossorigin src="/assets/index-vV_B6YVc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3w4bCH5.css">
</head>
<body>
<div id="root"></div>