Browse Source

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
maziggy 4 months ago
parent
commit
81c8dd7d0c

+ 6 - 0
CHANGELOG.md

@@ -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 - 0
README.md

@@ -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
backend/app/api/routes/settings.py

@@ -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(

+ 10 - 2
backend/app/services/virtual_printer/__init__.py

@@ -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",
+]

+ 39 - 3
backend/app/services/virtual_printer/manager.py

@@ -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),
         }
 

+ 51 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -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):

+ 16 - 0
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -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 - 0
frontend/src/api/client.ts

@@ -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',

+ 60 - 3
frontend/src/components/VirtualPrinterSettings.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-B3w4bCH5.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-YKsbWJQ9.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-vV_B6YVc.js


+ 2 - 2
static/index.html

@@ -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>

Some files were not shown because too many files changed in this diff