Browse Source

feat: multiple virtual printers with dedicated bind IPs + dual bind/detect ports (#445)

Multiple Virtual Printers:
- Each VP gets a dedicated bind IP with independent FTP, MQTT, SSDP, and Bind services
- New VirtualPrinter DB model, CRUD API (/api/virtual-printers), React UI
- VirtualPrinterList, VirtualPrinterCard, VirtualPrinterAddDialog components
- Per-instance TLS certificates (shared CA), 11 printer models, all 4 modes
- Auto-incremented serial suffixes, network interface override per VP

Dual Bind/Detect Ports (#445):
- Listen on both ports 3000 and 3002 for slicer bind/detect handshake
- Different BambuStudio/OrcaSlicer versions use different ports
- Applies to BindServer (server mode) and SlicerProxyManager (proxy mode)
- Updated Dockerfile, docker-compose.yml, firewall rules in wiki

Also:
- Rewrote VP test suite for new multi-instance architecture (75 tests)
- Rewritten "How it works" section with 3-step workflow explanation
- Updated all 5 locales (en, de, ja, fr, it)
- Updated wiki and website for multi-VP + dual ports
- New multi-VP screenshot
maziggy 3 months ago
parent
commit
d24e7cdf84
33 changed files with 2590 additions and 1181 deletions
  1. 3 1
      CHANGELOG.md
  2. 1 0
      Dockerfile
  3. 3 3
      backend/app/api/routes/settings.py
  4. 380 0
      backend/app/api/routes/virtual_printers.py
  5. 65 0
      backend/app/core/database.py
  6. 10 49
      backend/app/main.py
  7. 28 0
      backend/app/models/virtual_printer.py
  8. 97 2
      backend/app/services/network_utils.py
  9. 48 31
      backend/app/services/virtual_printer/bind_server.py
  10. 7 4
      backend/app/services/virtual_printer/certificate.py
  11. 8 2
      backend/app/services/virtual_printer/ftp_server.py
  12. 502 523
      backend/app/services/virtual_printer/manager.py
  13. 38 16
      backend/app/services/virtual_printer/mqtt_server.py
  14. 22 24
      backend/app/services/virtual_printer/ssdp_server.py
  15. 43 26
      backend/app/services/virtual_printer/tcp_proxy.py
  16. 417 430
      backend/tests/unit/services/test_virtual_printer.py
  17. 1 0
      docker-compose.yml
  18. BIN
      docs/screenshots/settings-virtual-printer.png
  19. 1 1
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  20. 65 0
      frontend/src/api/client.ts
  21. 156 0
      frontend/src/components/VirtualPrinterAddDialog.tsx
  22. 459 0
      frontend/src/components/VirtualPrinterCard.tsx
  23. 114 0
      frontend/src/components/VirtualPrinterList.tsx
  24. 32 13
      frontend/src/i18n/locales/de.ts
  25. 32 13
      frontend/src/i18n/locales/en.ts
  26. 11 13
      frontend/src/i18n/locales/fr.ts
  27. 11 13
      frontend/src/i18n/locales/it.ts
  28. 32 13
      frontend/src/i18n/locales/ja.ts
  29. 2 2
      frontend/src/pages/SettingsPage.tsx
  30. 0 0
      static/assets/index-ADBQB8en.js
  31. 0 0
      static/assets/index-DPf6CLKV.css
  32. 0 0
      static/assets/index-EqFdfChN.css
  33. 2 2
      static/index.html

+ 3 - 1
CHANGELOG.md

@@ -26,6 +26,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
 
 ### New Features
+- **Multiple Virtual Printers** — Run multiple virtual printers per Bambuddy installation. Each virtual printer gets a dedicated bind IP address with completely independent FTP, MQTT, SSDP, and Bind servers — no shared services or SNI routing. Full CRUD API (`/api/virtual-printers`) and React UI for creating, editing, and deleting virtual printers. Each instance supports all four modes (Immediate, Review, Print Queue, Proxy), any of the 11 supported printer models, per-instance TLS certificates (shared CA), and individual network interface override. Database-backed with auto-incremented serial suffixes.
+- **Virtual Printer: Dual Bind/Detect Ports** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — The slicer bind/detect handshake now listens on both ports 3000 and 3002. Different BambuStudio/OrcaSlicer versions use different ports for this handshake, so Bambuddy accepts connections on either. Applies to both server mode (BindServer) and proxy mode (SlicerProxyManager).
 - **Clear Plate Permission** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — New `printers:clear_plate` permission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting full `printers:control` (which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups with `printers:control` automatically receive the new permission on startup. The Operators default group includes it by default.
 - **Full-Page Group Permission Editor** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — Replaced the cramped permission modal with a dedicated full-page editor at `/groups/:id/edit`. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The old `GroupsPage.tsx` dead code has been removed.
 
@@ -90,7 +92,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.
 
 ### Improved
-- **Virtual Printer: Port 3000 Bind/Detect Server** — Recent BambuStudio/OrcaSlicer updates require a bind/detect handshake on port 3000 before connecting via MQTT/FTP. Added a BindServer that responds to the slicer's detect protocol in all server modes (immediate, review, print_queue). Without this, slicers cannot discover or connect to the virtual printer. Docker users in bridge mode need to expose port 3000 (`-p 3000:3000`). Proxy mode already forwards port 3000 via TCPProxy. Wiki documentation updated with revised port tables, Docker examples, and platform setup instructions.
+- **Virtual Printer: Dual Bind/Detect Ports 3000 + 3002** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — BambuStudio/OrcaSlicer require a bind/detect handshake before connecting via MQTT/FTP. Different slicer versions use port 3000 or 3002, so the BindServer and proxy now listen on both ports for full compatibility. Docker users in bridge mode need to expose both (`-p 3000:3000 -p 3002:3002`).
 - **Usage Tracking Diagnostic Logging** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Added INFO-level logging at print start and completion that dumps the printer's MQTT `mapping` field, `tray_now`, `last_loaded_tray`, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode.
 - **Skip Objects: Click-to-Enlarge Lightbox** ([#396](https://github.com/maziggy/bambuddy/issues/396)) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.
 - **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency.

+ 1 - 0
Dockerfile

@@ -47,6 +47,7 @@ ENV LOG_DIR=/app/logs
 ENV PORT=8000
 
 EXPOSE 3000
+EXPOSE 3002
 EXPOSE 8000
 EXPOSE 8883
 EXPOSE 9990

+ 3 - 3
backend/app/api/routes/settings.py

@@ -520,10 +520,10 @@ async def restore_backup(
 async def get_network_interfaces(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
-    """Get available network interfaces for SSDP proxy configuration."""
-    from backend.app.services.network_utils import get_network_interfaces
+    """Get available network interfaces with all IPs (primary + aliases)."""
+    from backend.app.services.network_utils import get_all_interface_ips
 
-    interfaces = get_network_interfaces()
+    interfaces = get_all_interface_ips()
     return {"interfaces": interfaces}
 
 

+ 380 - 0
backend/app/api/routes/virtual_printers.py

@@ -0,0 +1,380 @@
+import logging
+
+from fastapi import APIRouter, Depends
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
+
+
+class VirtualPrinterCreate(BaseModel):
+    name: str = "Bambuddy"
+    enabled: bool = False
+    mode: str = "immediate"
+    model: str | None = None
+    access_code: str | None = None
+    target_printer_id: int | None = None
+    bind_ip: str | None = None
+    remote_interface_ip: str | None = None
+
+
+class VirtualPrinterUpdate(BaseModel):
+    name: str | None = None
+    enabled: bool | None = None
+    mode: str | None = None
+    model: str | None = None
+    access_code: str | None = None
+    target_printer_id: int | None = None
+    bind_ip: str | None = None
+    remote_interface_ip: str | None = None
+
+
+def _vp_to_dict(vp, status: dict | None = None) -> dict:
+    """Convert VirtualPrinter model to response dict."""
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
+    from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
+
+    model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
+    serial = _get_serial_for_model(model_code, vp.serial_suffix)
+
+    return {
+        "id": vp.id,
+        "name": vp.name,
+        "enabled": vp.enabled,
+        "mode": vp.mode,
+        "model": model_code,
+        "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
+        "access_code_set": bool(vp.access_code),
+        "serial": serial,
+        "target_printer_id": vp.target_printer_id,
+        "bind_ip": vp.bind_ip,
+        "remote_interface_ip": vp.remote_interface_ip,
+        "position": vp.position,
+        "status": status or {"running": False, "pending_files": 0},
+    }
+
+
+@router.get("")
+async def list_virtual_printers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """List all virtual printers with status."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
+    vps = result.scalars().all()
+
+    printers = []
+    for vp in vps:
+        instance = virtual_printer_manager.get_instance(vp.id)
+        status = instance.get_status() if instance else {"running": False, "pending_files": 0}
+        printers.append(_vp_to_dict(vp, status))
+
+    return {
+        "printers": printers,
+        "models": VIRTUAL_PRINTER_MODELS,
+    }
+
+
+@router.post("")
+async def create_virtual_printer(
+    body: VirtualPrinterCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Create a new virtual printer."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+    from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
+
+    # Validate mode
+    if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+        return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
+
+    # Validate model
+    if body.model and body.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 length
+    if body.access_code and len(body.access_code) != 8:
+        return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
+
+    # Validation when enabling
+    if body.enabled:
+        if not body.bind_ip:
+            return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
+        if body.mode == "proxy":
+            if not body.target_printer_id:
+                return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
+        else:
+            if not body.access_code:
+                return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
+
+    # Validate proxy target printer exists
+    if body.target_printer_id:
+        from backend.app.models.printer import Printer
+
+        result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
+        if not result.scalar_one_or_none():
+            return JSONResponse(
+                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
+            )
+
+    # Validate bind_ip uniqueness (against all enabled VPs)
+    if body.bind_ip:
+        result = await db.execute(
+            select(VirtualPrinter).where(
+                VirtualPrinter.bind_ip == body.bind_ip,
+                VirtualPrinter.enabled == True,  # noqa: E712
+            )
+        )
+        if result.scalar_one_or_none():
+            return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
+
+    # Generate next serial suffix
+    result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
+    last_suffix = result.scalar()
+    if last_suffix:
+        try:
+            next_num = int(last_suffix) + 1
+            new_suffix = str(next_num).zfill(9)
+        except ValueError:
+            new_suffix = "391800002"
+    else:
+        new_suffix = "391800001"
+
+    # Get next position
+    result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
+    last_pos = result.scalar()
+    next_pos = (last_pos or 0) + 1
+
+    vp = VirtualPrinter(
+        name=body.name,
+        enabled=body.enabled,
+        mode=body.mode,
+        model=body.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+        access_code=body.access_code,
+        target_printer_id=body.target_printer_id,
+        bind_ip=body.bind_ip,
+        remote_interface_ip=body.remote_interface_ip,
+        serial_suffix=new_suffix,
+        position=next_pos,
+    )
+    db.add(vp)
+    await db.commit()
+    await db.refresh(vp)
+
+    logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
+
+    # Sync services if enabled
+    if body.enabled:
+        try:
+            await virtual_printer_manager.sync_from_db()
+        except Exception as e:
+            logger.error("Failed to start virtual printer after create: %s", e)
+
+    return _vp_to_dict(vp)
+
+
+@router.get("/{vp_id}")
+async def get_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Get a single virtual printer with status."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    instance = virtual_printer_manager.get_instance(vp.id)
+    status = instance.get_status() if instance else {"running": False, "pending_files": 0}
+
+    return _vp_to_dict(vp, status)
+
+
+@router.put("/{vp_id}")
+async def update_virtual_printer(
+    vp_id: int,
+    body: VirtualPrinterUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Update a virtual printer."""
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    logger.debug(
+        "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
+        vp_id,
+        body.model_dump(exclude_unset=True),
+        vp.mode,
+        vp.enabled,
+        bool(vp.access_code),
+        vp.bind_ip,
+        vp.target_printer_id,
+    )
+
+    # Apply updates
+    if body.name is not None:
+        vp.name = body.name
+    if body.mode is not None:
+        if body.mode not in ("immediate", "review", "print_queue", "proxy"):
+            return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
+        vp.mode = body.mode
+    if body.model is not None:
+        if body.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())}"},
+            )
+        vp.model = body.model
+    if body.access_code is not None:
+        if body.access_code and len(body.access_code) != 8:
+            return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
+        vp.access_code = body.access_code
+    if body.target_printer_id is not None:
+        from backend.app.models.printer import Printer
+
+        result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
+        if not result.scalar_one_or_none():
+            return JSONResponse(
+                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
+            )
+        vp.target_printer_id = body.target_printer_id
+    if body.bind_ip is not None:
+        vp.bind_ip = body.bind_ip
+    if body.remote_interface_ip is not None:
+        vp.remote_interface_ip = body.remote_interface_ip
+
+    # Determine final enabled state
+    explicitly_enabling = body.enabled is True
+    new_enabled = body.enabled if body.enabled is not None else vp.enabled
+    effective_mode = vp.mode
+
+    if explicitly_enabling:
+        # User is explicitly toggling on — enforce all requirements
+        if not vp.bind_ip:
+            logger.warning("Update VP %d rejected: no bind_ip", vp_id)
+            return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
+        # Validate bind_ip uniqueness (against all enabled VPs)
+        existing = await db.execute(
+            select(VirtualPrinter).where(
+                VirtualPrinter.bind_ip == vp.bind_ip,
+                VirtualPrinter.id != vp_id,
+                VirtualPrinter.enabled == True,  # noqa: E712
+            )
+        )
+        conflict = existing.scalar_one_or_none()
+        if conflict:
+            logger.warning(
+                "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
+                vp_id,
+                vp.bind_ip,
+                conflict.id,
+                conflict.enabled,
+                conflict.mode,
+            )
+            return JSONResponse(
+                status_code=400,
+                content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
+            )
+        if effective_mode == "proxy":
+            if not vp.target_printer_id:
+                logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
+                return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
+        else:
+            if not vp.access_code:
+                logger.warning(
+                    "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
+                )
+                return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
+    elif new_enabled and body.enabled is None:
+        # VP is already enabled and user is changing other fields —
+        # auto-disable if new state doesn't meet requirements
+        if not vp.bind_ip:
+            new_enabled = False
+        elif effective_mode == "proxy":
+            if not vp.target_printer_id:
+                new_enabled = False
+        else:
+            if not vp.access_code:
+                new_enabled = False
+
+    vp.enabled = new_enabled
+
+    await db.commit()
+    await db.refresh(vp)
+
+    logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
+
+    # Sync services
+    try:
+        await virtual_printer_manager.sync_from_db()
+    except Exception as e:
+        logger.error("Failed to sync virtual printers after update: %s", e)
+
+    instance = virtual_printer_manager.get_instance(vp.id)
+    status = instance.get_status() if instance else {"running": False, "pending_files": 0}
+
+    return _vp_to_dict(vp, status)
+
+
+@router.delete("/{vp_id}")
+async def delete_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Delete a virtual printer."""
+    from sqlalchemy import delete as sql_delete
+
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    vp_name = vp.name
+
+    # Stop instance if running
+    await virtual_printer_manager.remove_instance(vp_id)
+
+    # Delete from DB
+    await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    await db.commit()
+
+    logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
+
+    # Resync remaining services
+    try:
+        await virtual_printer_manager.sync_from_db()
+    except Exception as e:
+        logger.error("Failed to sync virtual printers after delete: %s", e)
+
+    return {"detail": "Deleted", "id": vp_id}

+ 65 - 0
backend/app/core/database.py

@@ -103,6 +103,7 @@ async def init_db():
         spool_k_profile,
         spool_usage_history,
         user,
+        virtual_printer,
     )
 
     async with engine.begin() as conn:
@@ -1222,6 +1223,70 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Migrate single virtual printer key-value settings to virtual_printers table
+    try:
+        # Check if virtual_printers table has any rows
+        result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
+        count = result.scalar() or 0
+
+        if count == 0:
+            # Check if old key-value settings exist
+            result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
+            row = result.fetchone()
+            if row:
+                # Old settings exist — migrate to first virtual printer row
+                old_enabled = row[0] == "true" if row[0] else False
+
+                result = await conn.execute(
+                    text("SELECT value FROM settings WHERE key = 'virtual_printer_access_code'")
+                )
+                row = result.fetchone()
+                old_access_code = row[0] if row else None
+
+                result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
+                row = result.fetchone()
+                old_mode = row[0] if row else "immediate"
+                if old_mode == "queue":
+                    old_mode = "review"
+
+                result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
+                row = result.fetchone()
+                old_model = row[0] if row else "3DPrinter-X1-Carbon"
+
+                result = await conn.execute(
+                    text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
+                )
+                row = result.fetchone()
+                old_target_id = int(row[0]) if row and row[0] else None
+
+                result = await conn.execute(
+                    text("SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'")
+                )
+                row = result.fetchone()
+                old_remote_iface = row[0] if row else None
+
+                await conn.execute(
+                    text("""
+                        INSERT INTO virtual_printers
+                            (name, enabled, mode, model, access_code, target_printer_id,
+                             bind_ip, remote_interface_ip, serial_suffix, position)
+                        VALUES
+                            (:name, :enabled, :mode, :model, :access_code, :target_id,
+                             NULL, :remote_iface, '391800001', 0)
+                    """),
+                    {
+                        "name": "Bambuddy",
+                        "enabled": old_enabled,
+                        "mode": old_mode or "immediate",
+                        "model": old_model,
+                        "access_code": old_access_code,
+                        "target_id": old_target_id,
+                        "remote_iface": old_remote_iface,
+                    },
+                )
+    except OperationalError:
+        pass  # Table may not exist yet on first run
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 10 - 49
backend/app/main.py

@@ -206,6 +206,7 @@ from backend.app.api.routes import (
     system,
     updates,
     users,
+    virtual_printers,
     webhook,
     websocket,
 )
@@ -3249,55 +3250,15 @@ async def lifespan(app: FastAPI):
     # Start printer runtime tracking
     start_runtime_tracking()
 
-    # Initialize virtual printer manager
+    # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
     virtual_printer_manager.set_session_factory(async_session)
-
-    # Auto-start virtual printer if enabled
-    async with async_session() as db:
-        from backend.app.api.routes.settings import get_setting
-
-        vp_enabled = await get_setting(db, "virtual_printer_enabled")
-        if vp_enabled and vp_enabled.lower() == "true":
-            vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
-            vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
-            vp_model = await get_setting(db, "virtual_printer_model") or ""
-            vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
-            vp_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
-
-            # Look up printer IP and serial if in proxy mode
-            vp_target_ip = ""
-            vp_target_serial = ""
-            if vp_mode == "proxy" and vp_target_printer_id:
-                from backend.app.models.printer import Printer
-
-                result = await db.execute(select(Printer).where(Printer.id == int(vp_target_printer_id)))
-                printer = result.scalar_one_or_none()
-                if printer:
-                    vp_target_ip = printer.ip_address
-                    vp_target_serial = printer.serial_number
-
-            # Proxy mode requires target IP, other modes require access code
-            can_start = (vp_mode == "proxy" and vp_target_ip) or (vp_mode != "proxy" and vp_access_code)
-
-            if can_start:
-                try:
-                    await virtual_printer_manager.configure(
-                        enabled=True,
-                        access_code=vp_access_code,
-                        mode=vp_mode,
-                        model=vp_model,
-                        target_printer_ip=vp_target_ip,
-                        target_printer_serial=vp_target_serial,
-                        remote_interface_ip=vp_remote_iface,
-                    )
-                    if vp_mode == "proxy":
-                        logging.info("Virtual printer proxy started (target=%s)", vp_target_ip)
-                    else:
-                        logging.info("Virtual printer started (model=%s)", vp_model or "default")
-                except Exception as e:
-                    logging.warning("Failed to start virtual printer: %s", e)
+    try:
+        await virtual_printer_manager.sync_from_db()
+        logging.info("Virtual printer manager synced from database")
+    except Exception as e:
+        logging.warning("Failed to sync virtual printers: %s", e)
 
     yield
 
@@ -3311,9 +3272,8 @@ async def lifespan(app: FastAPI):
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
-    # Stop virtual printer if running
-    if virtual_printer_manager.is_enabled:
-        await virtual_printer_manager.configure(enabled=False)
+    # Stop all virtual printer services
+    await virtual_printer_manager.stop_all()
 
     await mqtt_smart_plug_service.disconnect(timeout=2)
 
@@ -3510,6 +3470,7 @@ app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(metrics.router, prefix=app_settings.api_prefix)
+app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
 
 
 # Serve static files (React build)

+ 28 - 0
backend/app/models/virtual_printer.py

@@ -0,0 +1,28 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class VirtualPrinter(Base):
+    """Virtual printer configuration for multi-instance support."""
+
+    __tablename__ = "virtual_printers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
+    enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    mode: Mapped[str] = mapped_column(String(20), default="immediate")  # immediate|review|print_queue|proxy
+    model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
+    access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
+    target_printer_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
+    )  # proxy mode
+    bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)
+    remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP
+    serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001")  # unique per printer
+    position: Mapped[int] = mapped_column(Integer, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 97 - 2
backend/app/services/network_utils.py

@@ -1,15 +1,26 @@
 """Network utility functions for interface detection."""
 
 import ipaddress
+import json
 import logging
+import shutil
 import socket
 import struct
+import subprocess
 
 logger = logging.getLogger(__name__)
 
 # Interfaces to exclude from selection
 EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
 
+# Resolve full path to `ip` command (may not be in PATH for service users)
+_IP_CMD: str | None = shutil.which("ip") or shutil.which("ip", path="/usr/sbin:/sbin:/usr/bin:/bin")
+
+
+def _is_excluded(name: str) -> bool:
+    """Check if an interface name should be excluded."""
+    return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
+
 
 def get_network_interfaces() -> list[dict]:
     """Get all network interfaces with their IPs and subnets.
@@ -26,7 +37,7 @@ def get_network_interfaces() -> list[dict]:
             name = iface[1]
 
             # Skip excluded interfaces
-            if any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES):
+            if _is_excluded(name):
                 continue
 
             try:
@@ -76,6 +87,88 @@ def get_network_interfaces() -> list[dict]:
     return interfaces
 
 
+def get_all_interface_ips() -> list[dict]:
+    """Get all IPs (primary + aliases) for all non-excluded interfaces.
+
+    Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
+    Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
+
+    Returns:
+        List of dicts with name, ip, netmask, subnet, is_alias, label
+    """
+    if not _IP_CMD:
+        logger.debug("ip command not found, using ioctl fallback")
+        return _fallback_get_all_ips()
+
+    try:
+        result = subprocess.run(
+            [_IP_CMD, "-j", "addr", "show"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        if result.returncode != 0:
+            logger.warning("ip addr show failed: %s", result.stderr)
+            return _fallback_get_all_ips()
+
+        interfaces_data = json.loads(result.stdout)
+    except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
+        logger.warning("Failed to run ip -j addr show: %s", e)
+        return _fallback_get_all_ips()
+
+    entries = []
+    for iface in interfaces_data:
+        ifname = iface.get("ifname", "")
+        if _is_excluded(ifname):
+            continue
+
+        ipv4_count = 0
+        for addr_info in iface.get("addr_info", []):
+            if addr_info.get("family") != "inet":
+                continue
+
+            ip = addr_info.get("local", "")
+            prefix = addr_info.get("prefixlen", 24)
+            label = addr_info.get("label", ifname)
+
+            try:
+                network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
+                netmask = str(network.netmask)
+            except ValueError:
+                continue
+
+            # An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
+            is_alias = ":" in label or ipv4_count > 0
+
+            entries.append(
+                {
+                    "name": ifname,
+                    "ip": ip,
+                    "netmask": netmask,
+                    "subnet": str(network),
+                    "is_alias": is_alias,
+                    "label": label,
+                }
+            )
+            ipv4_count += 1
+
+    # Sort: primary IPs first per interface, then by interface name
+    entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
+    return entries
+
+
+def _fallback_get_all_ips() -> list[dict]:
+    """Fallback: wrap get_network_interfaces() result with alias fields."""
+    return [
+        {
+            **iface,
+            "is_alias": False,
+            "label": iface["name"],
+        }
+        for iface in get_network_interfaces()
+    ]
+
+
 def find_interface_for_ip(target_ip: str) -> dict | None:
     """Find which interface is on the same subnet as the target IP.
 
@@ -91,9 +184,11 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
         logger.error("Invalid target IP: %s", target_ip)
         return None
 
-    interfaces = get_network_interfaces()
+    interfaces = get_all_interface_ips()
 
     for iface in interfaces:
+        if iface.get("is_alias"):
+            continue
         try:
             network = ipaddress.IPv4Network(iface["subnet"], strict=False)
             if target in network:

+ 48 - 31
backend/app/services/virtual_printer/bind_server.py

@@ -1,7 +1,8 @@
-"""Bind/detect server for virtual printer discovery (port 3000).
+"""Bind/detect server for virtual printer discovery (ports 3000 + 3002).
 
-Bambu slicers (BambuStudio, OrcaSlicer) connect to port 3000 on a printer
-to perform the "bind with access code" handshake before using MQTT/FTP.
+Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
+or 3002 to perform the "bind with access code" handshake before using
+MQTT/FTP. The port varies by slicer version, so we listen on both.
 
 Protocol:
   - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
@@ -19,7 +20,7 @@ import struct
 
 logger = logging.getLogger(__name__)
 
-BIND_PORT = 3000
+BIND_PORTS = [3000, 3002]
 FRAME_HEADER = b"\xa5\xa5"
 FRAME_TRAILER = b"\xa7\xa7"
 HEADER_SIZE = 4  # 2 bytes magic + 2 bytes length
@@ -27,10 +28,13 @@ TRAILER_SIZE = 2
 
 
 class BindServer:
-    """Responds to slicer bind/detect requests on port 3000.
+    """Responds to slicer bind/detect requests on ports 3000 and 3002.
 
     In server mode, Bambuddy IS the printer — it responds with its own
     identity so the slicer can discover and bind to it.
+
+    Different BambuStudio versions connect on different ports (3000 or 3002),
+    so we listen on both to ensure compatibility.
     """
 
     def __init__(
@@ -39,42 +43,55 @@ class BindServer:
         model: str,
         name: str,
         version: str = "01.00.00.00",
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.serial = serial
         self.model = model
         self.name = name
         self.version = version
+        self.bind_address = bind_address
 
-        self._server: asyncio.Server | None = None
+        self._servers: list[asyncio.Server] = []
         self._running = False
 
     async def start(self) -> None:
-        """Start the bind server on port 3000."""
+        """Start the bind server on ports 3000 and 3002."""
         if self._running:
             return
 
-        logger.info("Starting bind server on port %s (serial=%s, model=%s)", BIND_PORT, self.serial, self.model)
+        self._running = True
+        logger.info(
+            "Starting bind server on ports %s (serial=%s, model=%s)",
+            BIND_PORTS,
+            self.serial,
+            self.model,
+        )
 
         try:
-            self._running = True
-            self._server = await asyncio.start_server(
-                self._handle_client,
-                "0.0.0.0",  # nosec B104
-                BIND_PORT,
-            )
-
-            logger.info("Bind server listening on port %s", BIND_PORT)
-
-            async with self._server:
-                await self._server.serve_forever()
-
-        except OSError as e:
-            if e.errno == 98:
-                logger.error("Bind server port %s is already in use", BIND_PORT)
-            elif e.errno == 13:
-                logger.error("Bind server: cannot bind to port %s (permission denied)", BIND_PORT)
-            else:
-                logger.error("Bind server error: %s", e)
+            for port in BIND_PORTS:
+                try:
+                    server = await asyncio.start_server(
+                        self._handle_client,
+                        self.bind_address,
+                        port,
+                    )
+                    self._servers.append(server)
+                    logger.info("Bind server listening on %s:%s", self.bind_address, port)
+                except OSError as e:
+                    if e.errno == 98:
+                        logger.warning("Bind server port %s already in use, skipping", port)
+                    elif e.errno == 13:
+                        logger.warning("Bind server: cannot bind to port %s (permission denied), skipping", port)
+                    else:
+                        logger.warning("Bind server: failed to bind port %s: %s", port, e)
+
+            if not self._servers:
+                logger.error("Bind server: could not bind to any port")
+                return
+
+            # Serve all successfully bound ports
+            await asyncio.gather(*(s.serve_forever() for s in self._servers))
+
         except asyncio.CancelledError:
             logger.debug("Bind server task cancelled")
         except Exception as e:
@@ -87,13 +104,13 @@ class BindServer:
         logger.info("Stopping bind server")
         self._running = False
 
-        if self._server:
+        for server in self._servers:
             try:
-                self._server.close()
-                await self._server.wait_closed()
+                server.close()
+                await server.wait_closed()
             except OSError as e:
                 logger.debug("Error closing bind server: %s", e)
-            self._server = None
+        self._servers = []
 
     async def _handle_client(
         self,

+ 7 - 4
backend/app/services/virtual_printer/certificate.py

@@ -48,17 +48,20 @@ class CertificateService:
     - Printer cert with CN=serial_number, signed by the CA
     """
 
-    def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL):
+    def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL, shared_ca_dir: Path | None = None):
         """Initialize the certificate service.
 
         Args:
-            cert_dir: Directory to store certificates
+            cert_dir: Directory to store per-instance certificates
             serial: Serial number to use as CN in printer certificate
+            shared_ca_dir: If set, CA cert/key are read from this directory
+                instead of cert_dir (for multi-instance shared CA)
         """
         self.cert_dir = cert_dir
         self.serial = serial
-        self.ca_cert_path = cert_dir / "bbl_ca.crt"
-        self.ca_key_path = cert_dir / "bbl_ca.key"
+        ca_dir = shared_ca_dir or cert_dir
+        self.ca_cert_path = ca_dir / "bbl_ca.crt"
+        self.ca_key_path = ca_dir / "bbl_ca.key"
         self.cert_path = cert_dir / "virtual_printer.crt"
         self.key_path = cert_dir / "virtual_printer.key"
 

+ 8 - 2
backend/app/services/virtual_printer/ftp_server.py

@@ -34,6 +34,7 @@ class FTPSession:
         on_file_received: Callable[[Path, str], None] | None,
         passive_port_range: tuple[int, int] = (50000, 50100),
         pasv_address: str = "",
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.reader = reader
         self.writer = writer
@@ -43,6 +44,7 @@ class FTPSession:
         self.on_file_received = on_file_received
         self.passive_port_range = passive_port_range
         self.pasv_address = pasv_address
+        self.bind_address = bind_address
 
         self.authenticated = False
         self.username: str | None = None
@@ -218,7 +220,7 @@ class FTPSession:
             try:
                 self.data_server = await asyncio.start_server(
                     self._handle_data_connection,
-                    "0.0.0.0",  # nosec B104
+                    self.bind_address,
                     port,
                     ssl=self.ssl_context,
                 )
@@ -524,6 +526,7 @@ class VirtualPrinterFTPServer:
         key_path: Path,
         port: int = FTP_PORT,
         on_file_received: Callable[[Path, str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         """Initialize the FTPS server.
 
@@ -534,6 +537,7 @@ class VirtualPrinterFTPServer:
             key_path: Path to TLS private key file
             port: Port to listen on (default 990)
             on_file_received: Callback when file upload completes (path, source_ip)
+            bind_address: IP address to bind to (default 0.0.0.0)
         """
         self.upload_dir = upload_dir
         self.access_code = access_code
@@ -541,6 +545,7 @@ class VirtualPrinterFTPServer:
         self.key_path = key_path
         self.port = port
         self.on_file_received = on_file_received
+        self.bind_address = bind_address
         self._server: asyncio.Server | None = None
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
@@ -575,7 +580,7 @@ class VirtualPrinterFTPServer:
             # Create server with SSL - TLS handshake happens before any FTP data
             self._server = await asyncio.start_server(
                 self._handle_client,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.port,
                 ssl=self._ssl_context,  # This makes it implicit FTPS!
             )
@@ -619,6 +624,7 @@ class VirtualPrinterFTPServer:
             on_file_received=self.on_file_received,
             passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
             pasv_address=self._pasv_address,
+            bind_address=self.bind_address,
         )
 
         # Track the session task so we can cancel it on stop

+ 502 - 523
backend/app/services/virtual_printer/manager.py

@@ -1,10 +1,7 @@
 """Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
 
-Supports multiple modes:
-- immediate: Archive uploads immediately
-- review: Queue uploads for user review before archiving
-- print_queue: Archive and add to print queue (unassigned)
-- proxy: Transparent TCP proxy to a real printer (for remote slicer access)
+Each virtual printer runs its own independent services (FTP, MQTT, SSDP, Bind)
+bound to its dedicated IP address, regardless of mode.
 """
 
 import asyncio
@@ -78,497 +75,143 @@ MODEL_SERIAL_PREFIXES = {
 DEFAULT_VIRTUAL_PRINTER_MODEL = "3DPrinter-X1-Carbon"  # X1C
 
 
-class VirtualPrinterManager:
-    """Manages the virtual printer lifecycle and coordinates all services."""
-
-    # Fixed configuration
-    PRINTER_NAME = "Bambuddy"
-    SERIAL_SUFFIX = "391800001"  # Fixed suffix for virtual printer
+def _get_serial_for_model(model: str, serial_suffix: str) -> str:
+    """Get serial number for the given model and suffix."""
+    prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
+    return f"{prefix}{serial_suffix}"
 
-    def __init__(self):
-        """Initialize the virtual printer manager."""
-        self._session_factory: Callable | None = None
-        self._enabled = False
-        self._access_code = ""
-        self._mode = "immediate"
-        self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
-        self._target_printer_ip = ""  # For proxy mode
-        self._target_printer_serial = ""  # For proxy mode (real printer's serial)
-        self._remote_interface_ip = ""  # For proxy mode SSDP (LAN B - slicer network)
-
-        # Service instances
-        self._ssdp: VirtualPrinterSSDPServer | None = None
-        self._ssdp_proxy: SSDPProxy | None = None
-        self._ftp: VirtualPrinterFTPServer | None = None
-        self._mqtt: SimpleMQTTServer | None = None
-        self._bind: BindServer | None = None  # For server mode (bind/detect on port 3000)
-        self._proxy: SlicerProxyManager | None = None  # For proxy mode
 
-        # Background tasks
-        self._tasks: list[asyncio.Task] = []
+class VirtualPrinterInstance:
+    """Per-printer state and file handling logic.
 
-        # Directories
-        self._base_dir = app_settings.base_dir / "virtual_printer"
-        self._upload_dir = self._base_dir / "uploads"
-        self._cert_dir = self._base_dir / "certs"
+    Each instance represents one virtual printer with its own config,
+    upload directory, certificates, and file handling mode.
+    """
 
-        # Create directories early to avoid permission issues later
-        # If running in Docker, these need to be on a writable volume
-        self._ensure_directories()
+    def __init__(
+        self,
+        *,
+        vp_id: int,
+        name: str,
+        mode: str,
+        model: str,
+        access_code: str,
+        serial_suffix: str,
+        target_printer_ip: str = "",
+        target_printer_serial: str = "",
+        bind_ip: str = "",
+        remote_interface_ip: str = "",
+        base_dir: Path,
+        session_factory: Callable | None = None,
+    ):
+        self.id = vp_id
+        self.name = name
+        self.mode = mode
+        self.model = model
+        self.access_code = access_code
+        self.serial_suffix = serial_suffix
+        self.target_printer_ip = target_printer_ip
+        self.target_printer_serial = target_printer_serial
+        self.bind_ip = bind_ip
+        self.remote_interface_ip = remote_interface_ip
+        self._session_factory = session_factory
 
-        # Certificate service
-        self._cert_service = CertificateService(self._cert_dir)
+        # Directories
+        self.upload_dir = base_dir / "uploads" / str(vp_id)
+        self.cert_dir = base_dir / "certs" / str(vp_id)
+        shared_ca_dir = base_dir / "certs"
+
+        # Ensure directories exist
+        self.upload_dir.mkdir(parents=True, exist_ok=True)
+        (self.upload_dir / "cache").mkdir(exist_ok=True)
+        self.cert_dir.mkdir(parents=True, exist_ok=True)
+
+        # Certificate service (shared CA, per-instance printer cert)
+        self._cert_service = CertificateService(
+            cert_dir=self.cert_dir,
+            serial=self.serial,
+            shared_ca_dir=shared_ca_dir,
+        )
 
-        # Track pending uploads for MQTT correlation
+        # Pending files for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
-    def _ensure_directories(self) -> None:
-        """Create and verify virtual printer directories are writable.
-
-        Creates all required directories at startup to catch permission
-        issues early rather than when the user tries to enable features.
-        """
-        dirs_to_create = [
-            self._base_dir,
-            self._upload_dir,
-            self._upload_dir / "cache",
-            self._cert_dir,
-        ]
-
-        logger.info("Checking virtual printer directories in %s", self._base_dir)
-
-        for dir_path in dirs_to_create:
-            try:
-                dir_path.mkdir(parents=True, exist_ok=True)
-            except PermissionError:
-                logger.error(
-                    f"Cannot create directory {dir_path}: Permission denied. "
-                    f"For Docker: ensure the data volume is writable by the container user. "
-                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
-                )
-                continue
-
-            # Verify directory is writable by attempting to create a test file
-            test_file = dir_path / ".write_test"
-            try:
-                test_file.touch()
-                test_file.unlink(missing_ok=True)
-            except PermissionError:
-                logger.error(
-                    f"Directory {dir_path} exists but is not writable. "
-                    f"For Docker: ensure the data volume is writable by the container user (uid/gid). "
-                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
-                )
-
-    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}"
+        # Per-instance services
+        self._proxy: SlicerProxyManager | None = None
+        self._ftp: VirtualPrinterFTPServer | None = None
+        self._mqtt: SimpleMQTTServer | None = None
+        self._bind: BindServer | None = None
+        self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ssdp_proxy: SSDPProxy | None = None
+        self._tasks: list[asyncio.Task] = []
 
     @property
-    def printer_serial(self) -> str:
-        """Get the current printer serial number based on model."""
-        return self._get_serial_for_model(self._model)
+    def serial(self) -> str:
+        """Full serial number for this virtual printer."""
+        return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)
 
-    def set_session_factory(self, session_factory: Callable) -> None:
-        """Set the database session factory.
+    @property
+    def cert_path(self) -> Path:
+        return self._cert_service.cert_path
 
-        Args:
-            session_factory: Async context manager for database sessions
-        """
-        self._session_factory = session_factory
+    @property
+    def key_path(self) -> Path:
+        return self._cert_service.key_path
 
     @property
-    def is_enabled(self) -> bool:
-        """Check if virtual printer is enabled."""
-        return self._enabled
+    def is_proxy(self) -> bool:
+        return self.mode == "proxy"
 
     @property
     def is_running(self) -> bool:
-        """Check if virtual printer services are running."""
         return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
 
-    async def configure(
-        self,
-        enabled: bool,
-        access_code: str = "",
-        mode: str = "immediate",
-        model: str = "",
-        target_printer_ip: str = "",
-        target_printer_serial: str = "",
-        remote_interface_ip: str = "",
-    ) -> None:
-        """Configure and start/stop virtual printer.
-
-        Args:
-            enabled: Whether to enable the virtual printer
-            access_code: Authentication password for slicer connections
-            mode: Archive mode - 'immediate', 'review', 'print_queue', or 'proxy'
-            model: SSDP model code (e.g., 'BL-P001' for X1C)
-            target_printer_ip: Target printer IP for proxy mode
-            target_printer_serial: Target printer serial for proxy mode
-            remote_interface_ip: IP of interface on slicer network (LAN B) for SSDP proxy
-        """
-        # Proxy mode has different requirements
-        if mode == "proxy":
-            if enabled and not target_printer_ip:
-                raise ValueError("Target printer IP is required for proxy mode")
-            # Access code not required for proxy mode (uses printer's credentials)
-        else:
-            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
-        mode_changed = mode != self._mode
-        target_changed = target_printer_ip != self._target_printer_ip
-        serial_changed = target_printer_serial != self._target_printer_serial
-        remote_iface_changed = remote_interface_ip != self._remote_interface_ip
-        old_mode = self._mode
-
-        logger.debug(
-            f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
-            f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
-            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}, "
-            f"remote_interface_ip={remote_interface_ip}"
-        )
-
-        self._access_code = access_code
-        self._mode = mode
-        self._model = new_model
-        self._target_printer_ip = target_printer_ip
-        self._target_printer_serial = target_printer_serial
-        self._remote_interface_ip = remote_interface_ip
-
-        needs_restart = (
-            model_changed
-            or mode_changed
-            or remote_iface_changed
-            or (mode == "proxy" and (target_changed or serial_changed))
-        )
-
-        if enabled and not self._enabled:
-            logger.info("Starting virtual printer (was disabled)")
-            await self._start()
-        elif not enabled and self._enabled:
-            logger.info("Stopping virtual printer (was enabled)")
-            await self._stop()
-        elif enabled and self._enabled and needs_restart:
-            # Configuration changed while running - restart services
-            logger.info("Configuration changed (mode=%s→%s), restarting...", old_mode, mode)
-            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 configuration")
-        else:
-            logger.debug("No state change needed (enabled=%s, self._enabled=%s)", enabled, self._enabled)
-
-        self._enabled = enabled
-
-    async def _start(self) -> None:
-        """Start all virtual printer services."""
-        logger.info("Starting virtual printer services (mode=%s)...", self._mode)
-
-        # Proxy mode uses different services
-        if self._mode == "proxy":
-            await self._start_proxy_mode()
-            return
-
-        # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
-        await self._start_server_mode()
-
-    async def _start_proxy_mode(self) -> None:
-        """Start virtual printer in proxy mode (TLS terminating relay)."""
-        logger.info("Starting proxy mode to %s", self._target_printer_ip)
-
-        # In proxy mode, use the REAL printer's serial number
-        # This ensures MQTT topic subscriptions match the real printer's topics
-        proxy_serial = self._target_printer_serial or self.printer_serial
-        logger.info("Proxy mode using serial: %s", proxy_serial)
-
-        # Update certificate service with the real printer's serial
-        self._cert_service.serial = proxy_serial
-
-        # Regenerate printer cert if needed (CA is preserved)
-        # Include remote interface IP in SAN so slicer TLS succeeds
-        additional_ips = []
-        if self._remote_interface_ip:
-            additional_ips.append(self._remote_interface_ip)
+    def generate_certificates(self) -> tuple[Path, Path]:
+        """Generate certificates for this instance."""
+        self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)
+        additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None
+        if self.bind_ip:
+            additional_ips = additional_ips or []
+            additional_ips.append(self.bind_ip)
         self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
-        logger.info("Generated certificate for proxy serial: %s", proxy_serial)
-
-        # Initialize TLS proxy with our certificates
-        self._proxy = SlicerProxyManager(
-            target_host=self._target_printer_ip,
-            cert_path=cert_path,
-            key_path=key_path,
-            on_activity=self._on_proxy_activity,
-        )
-
-        # Start services as background tasks
-        async def run_with_logging(coro, name):
-            try:
-                await coro
-            except Exception as e:
-                logger.error("Virtual printer %s failed: %s", name, e)
+        return self._cert_service.generate_certificates(additional_ips=additional_ips)
 
-        self._tasks = []
+    # -- File handling callbacks --
 
-        # SSDP setup: use SSDPProxy if remote interface is configured
-        # Local interface is auto-detected from target printer IP
-        if self._remote_interface_ip:
-            # Auto-detect local interface based on target printer IP
-            from backend.app.services.network_utils import find_interface_for_ip
+    async def on_file_received(self, file_path: Path, source_ip: str) -> None:
+        """Handle file upload completion from FTP."""
+        logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
 
-            local_iface = find_interface_for_ip(self._target_printer_ip)
-            if local_iface:
-                local_interface_ip = local_iface["ip"]
-                logger.info(
-                    f"SSDP proxy mode: LAN A ({local_interface_ip}, auto-detected) -> LAN B ({self._remote_interface_ip})"
-                )
-                self._ssdp_proxy = SSDPProxy(
-                    local_interface_ip=local_interface_ip,
-                    remote_interface_ip=self._remote_interface_ip,
-                    target_printer_ip=self._target_printer_ip,
-                )
-                self._tasks.append(
-                    asyncio.create_task(
-                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
-                        name="virtual_printer_ssdp_proxy",
-                    )
-                )
-            else:
-                logger.warning(
-                    f"Could not auto-detect local interface for printer {self._target_printer_ip}, "
-                    "falling back to single-interface SSDP"
-                )
-                self._start_fallback_ssdp(proxy_serial, run_with_logging)
-        else:
-            # Single interface: broadcast SSDP on same network (fallback)
-            self._start_fallback_ssdp(proxy_serial, run_with_logging)
-
-        # Add TLS proxy task
-        self._tasks.append(
-            asyncio.create_task(
-                run_with_logging(self._proxy.start(), "Proxy"),
-                name="virtual_printer_proxy",
-            )
-        )
-
-        logger.info(
-            "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d, Bind %s:%d",
-            self._target_printer_ip,
-            SlicerProxyManager.PRINTER_FTP_PORT,
-            self._target_printer_ip,
-            SlicerProxyManager.PRINTER_MQTT_PORT,
-            self._target_printer_ip,
-            SlicerProxyManager.PRINTER_BIND_PORT,
-        )
-
-    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
-        """Start single-interface SSDP server as fallback."""
-        logger.info("SSDP broadcast mode (single interface)")
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=f"{self.PRINTER_NAME} (Proxy)",
-            serial=proxy_serial,
-            model=self._model,
-        )
-        self._tasks.append(
-            asyncio.create_task(
-                run_with_logging(self._ssdp.start(), "SSDP"),
-                name="virtual_printer_ssdp",
-            )
-        )
-
-    async def _start_server_mode(self) -> None:
-        """Start virtual printer in server mode (FTP/MQTT servers)."""
-        # 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)
-        # Include remote interface IP in SAN so slicer TLS succeeds on that interface
-        additional_ips = []
-        if self._remote_interface_ip:
-            additional_ips.append(self._remote_interface_ip)
-        self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
-        logger.info("Generated certificate for serial: %s", current_serial)
-
-        # Create directories
-        self._upload_dir.mkdir(parents=True, exist_ok=True)
-        (self._upload_dir / "cache").mkdir(exist_ok=True)
-
-        # Initialize services
-        self._ssdp = VirtualPrinterSSDPServer(
-            name=self.PRINTER_NAME,
-            serial=self.printer_serial,
-            model=self._model,
-            advertise_ip=self._remote_interface_ip,
-        )
-
-        self._ftp = VirtualPrinterFTPServer(
-            upload_dir=self._upload_dir,
-            access_code=self._access_code,
-            cert_path=cert_path,
-            key_path=key_path,
-            on_file_received=self._on_file_received,
-        )
-
-        self._mqtt = SimpleMQTTServer(
-            serial=self.printer_serial,
-            access_code=self._access_code,
-            cert_path=cert_path,
-            key_path=key_path,
-            on_print_command=self._on_print_command,
-        )
-
-        # Bind server responds to slicer detect/bind requests on port 3000
-        self._bind = BindServer(
-            serial=self.printer_serial,
-            model=self._model,
-            name=self.PRINTER_NAME,
-        )
-
-        # Start services as background tasks
-        # Wrap each in error handler so one failure doesn't stop others
-        async def run_with_logging(coro, name):
-            try:
-                await coro
-            except Exception as e:
-                logger.error("Virtual printer %s failed: %s", name, e)
-
-        self._tasks = [
-            asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
-            asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
-            asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
-            asyncio.create_task(run_with_logging(self._bind.start(), "Bind"), name="virtual_printer_bind"),
-        ]
-
-        logger.info("Virtual printer '%s' started (serial: %s)", self.PRINTER_NAME, self.printer_serial)
-
-    def _on_proxy_activity(self, name: str, message: str) -> None:
-        """Handle proxy activity for logging."""
-        logger.info("Proxy %s: %s", name, message)
-
-    async def _stop(self) -> None:
-        """Stop all virtual printer services."""
-        logger.info("Stopping virtual printer services...")
-
-        # Stop services first - this closes servers and cancels active sessions
-        if self._ftp:
-            await self._ftp.stop()
-            self._ftp = None
-
-        if self._mqtt:
-            await self._mqtt.stop()
-            self._mqtt = None
-
-        if self._ssdp:
-            await self._ssdp.stop()
-            self._ssdp = None
-
-        if self._bind:
-            await self._bind.stop()
-            self._bind = None
-
-        if self._ssdp_proxy:
-            await self._ssdp_proxy.stop()
-            self._ssdp_proxy = None
-
-        if self._proxy:
-            await self._proxy.stop()
-            self._proxy = 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:
-        """Handle file upload completion from FTP.
-
-        Args:
-            file_path: Path to uploaded file
-            source_ip: IP address of the uploading slicer
-        """
-        logger.info("Virtual printer received file: %s from %s", file_path.name, source_ip)
-
-        # Store file reference for MQTT correlation
         self._pending_files[file_path.name] = file_path
 
-        # Handle based on mode:
-        # - immediate: archive right away
-        # - review: create pending upload record for user review before archiving
-        # - print_queue: archive and add to print queue (unassigned)
-        if self._mode == "immediate":
+        if self.mode == "immediate":
             await self._archive_file(file_path, source_ip)
-        elif self._mode == "print_queue":
+        elif self.mode == "print_queue":
             await self._add_to_print_queue(file_path, source_ip)
         else:
-            # "review" mode (or legacy "queue" mode)
             await self._queue_file(file_path, source_ip)
 
-        # Reset MQTT status back to IDLE after file processing
-        # This tells the slicer the printer is done with the file
+        # Reset MQTT status back to IDLE
         if self._mqtt and file_path.suffix.lower() == ".3mf":
             self._mqtt.set_gcode_state("IDLE")
 
-    async def _on_print_command(self, filename: str, data: dict) -> None:
-        """Handle print command from MQTT.
-
-        In a real printer, this would start the print. For virtual printer,
-        we just log it since archiving is handled by file upload.
-
-        Args:
-            filename: Name of the file to print
-            data: Print command data (contains settings like timelapse, bed_leveling, etc.)
-        """
-        logger.info("Virtual printer received print command for: %s", filename)
-        logger.debug("Print command data: %s", data)
-
-        # The file should already be archived from FTP upload
-        # This command just confirms the slicer's intent to "print"
+    async def on_print_command(self, filename: str, data: dict) -> None:
+        """Handle print command from MQTT."""
+        logger.info("[VP %s] Print command for: %s", self.name, filename)
 
     async def _archive_file(self, file_path: Path, source_ip: str) -> None:
-        """Archive file immediately.
-
-        Args:
-            file_path: Path to the 3MF file
-            source_ip: IP address of uploader
-        """
+        """Archive file immediately."""
         if not self._session_factory:
             logger.error("Cannot archive: no database session factory configured")
             return
 
-        # Only archive 3MF files
         if file_path.suffix.lower() != ".3mf":
             logger.debug("Skipping non-3MF file: %s", file_path.name)
-            # Remove from pending and clean up
             self._pending_files.pop(file_path.name, None)
             try:
                 file_path.unlink()
             except OSError:
-                pass  # Best-effort removal of non-3MF file; may already be gone
+                pass
             return
 
         try:
@@ -576,10 +219,8 @@ class VirtualPrinterManager:
 
             async with self._session_factory() as db:
                 service = ArchiveService(db)
-
-                # Archive the print
                 archive = await service.archive_print(
-                    printer_id=None,  # No physical printer
+                    printer_id=None,
                     source_file=file_path,
                     print_data={
                         "status": "archived",
@@ -587,42 +228,30 @@ class VirtualPrinterManager:
                         "source_ip": source_ip,
                     },
                 )
-
                 if archive:
-                    logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
-
-                    # Clean up uploaded file (it's now copied to archive)
+                    logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     try:
                         file_path.unlink()
                     except OSError:
-                        pass  # Best-effort cleanup of uploaded file after archiving
-                    # Remove from pending
+                        pass
                     self._pending_files.pop(file_path.name, None)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
-
-        except Exception as e:  # Mixed async DB + archive operations
+        except Exception as e:
             logger.error("Error archiving file: %s", e)
 
     async def _queue_file(self, file_path: Path, source_ip: str) -> None:
-        """Queue file for user review.
-
-        Args:
-            file_path: Path to the 3MF file
-            source_ip: IP address of uploader
-        """
+        """Queue file for user review."""
         if not self._session_factory:
             logger.error("Cannot queue: no database session factory configured")
             return
 
-        # Only queue 3MF files
         if file_path.suffix.lower() != ".3mf":
-            logger.debug("Skipping non-3MF file: %s", file_path.name)
             self._pending_files.pop(file_path.name, None)
             try:
                 file_path.unlink()
             except OSError:
-                pass  # Best-effort removal of non-3MF file; may already be gone
+                pass
             return
 
         try:
@@ -639,34 +268,23 @@ class VirtualPrinterManager:
                 )
                 db.add(pending)
                 await db.commit()
-
-                logger.info("Queued virtual printer upload: %s - %s", pending.id, file_path.name)
-
-                # Remove from pending files dict
+                logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
                 self._pending_files.pop(file_path.name, None)
-
         except Exception as e:
             logger.error("Error queueing file: %s", e)
 
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
-        """Archive file and add to print queue (unassigned).
-
-        Args:
-            file_path: Path to the 3MF file
-            source_ip: IP address of uploader
-        """
+        """Archive file and add to print queue (unassigned)."""
         if not self._session_factory:
             logger.error("Cannot add to print queue: no database session factory configured")
             return
 
-        # Only process 3MF files
         if file_path.suffix.lower() != ".3mf":
-            logger.debug("Skipping non-3MF file: %s", file_path.name)
             self._pending_files.pop(file_path.name, None)
             try:
                 file_path.unlink()
             except OSError:
-                pass  # Best-effort removal of non-3MF file; may already be gone
+                pass
             return
 
         try:
@@ -675,10 +293,8 @@ class VirtualPrinterManager:
 
             async with self._session_factory() as db:
                 service = ArchiveService(db)
-
-                # First, archive the print
                 archive = await service.archive_print(
-                    printer_id=None,  # No physical printer
+                    printer_id=None,
                     source_file=file_path,
                     print_data={
                         "status": "archived",
@@ -686,62 +302,425 @@ class VirtualPrinterManager:
                         "source_ip": source_ip,
                     },
                 )
-
                 if archive:
-                    logger.info("Archived virtual printer upload: %s - %s", archive.id, archive.print_name)
-
-                    # Now add to print queue (unassigned)
+                    logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     queue_item = PrintQueueItem(
-                        printer_id=None,  # Unassigned - user will assign later
+                        printer_id=None,
                         archive_id=archive.id,
-                        position=1,  # Will be adjusted when assigned to a printer
+                        position=1,
                         status="pending",
                     )
                     db.add(queue_item)
                     await db.commit()
-
-                    logger.info(
-                        "Added to print queue (unassigned): queue_id=%s, archive_id=%s", queue_item.id, archive.id
-                    )
-
-                    # Clean up uploaded file (it's now copied to archive)
+                    logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
                     try:
                         file_path.unlink()
                     except OSError:
-                        pass  # Best-effort cleanup of uploaded file after archiving and queuing
-                    # Remove from pending
+                        pass
                     self._pending_files.pop(file_path.name, None)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
-
-        except Exception as e:  # Mixed async DB + archive + queue operations
+        except Exception as e:
             logger.error("Error adding to print queue: %s", e)
 
-    def get_status(self) -> dict:
-        """Get virtual printer status.
+    # -- Service lifecycle --
+
+    async def start_server(self) -> None:
+        """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
+        logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
+
+        cert_path, key_path = self.generate_certificates()
+        bind_addr = self.bind_ip or "0.0.0.0"  # nosec B104
+
+        async def run_with_logging(coro, svc_name):
+            try:
+                await coro
+            except Exception as e:
+                logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
+
+        self._tasks = []
+
+        # FTP server
+        self._ftp = VirtualPrinterFTPServer(
+            upload_dir=self.upload_dir,
+            access_code=self.access_code,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_file_received=self.on_file_received,
+            bind_address=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ftp.start(), "FTP"),
+                name=f"vp_{self.id}_ftp",
+            )
+        )
+
+        # MQTT server
+        self._mqtt = SimpleMQTTServer(
+            serial=self.serial,
+            access_code=self.access_code,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_print_command=self.on_print_command,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            bind_address=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._mqtt.start(), "MQTT"),
+                name=f"vp_{self.id}_mqtt",
+            )
+        )
+
+        # Bind server
+        self._bind = BindServer(
+            serial=self.serial,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            name=self.name,
+            bind_address=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._bind.start(), "Bind"),
+                name=f"vp_{self.id}_bind",
+            )
+        )
+
+        # SSDP server
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=self.name,
+            serial=self.serial,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            advertise_ip=self.remote_interface_ip or self.bind_ip or "",
+            bind_ip=bind_addr,
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name=f"vp_{self.id}_ssdp",
+            )
+        )
+
+        logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
+
+    async def stop_server(self) -> None:
+        """Stop server-mode services."""
+        if self._ftp:
+            await self._ftp.stop()
+            self._ftp = None
+        if self._mqtt:
+            await self._mqtt.stop()
+            self._mqtt = None
+        if self._bind:
+            await self._bind.stop()
+            self._bind = None
+        if self._ssdp:
+            await self._ssdp.stop()
+            self._ssdp = None
+        await self._cancel_tasks()
 
-        Returns:
-            Status dictionary with enabled, running, mode, etc.
-        """
-        status = {
-            "enabled": self._enabled,
+    async def start_proxy(self) -> None:
+        """Start proxy mode services for this instance."""
+        logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
+
+        cert_path, key_path = self.generate_certificates()
+
+        self._proxy = SlicerProxyManager(
+            target_host=self.target_printer_ip,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
+            bind_address=self.bind_ip or "0.0.0.0",  # nosec B104
+        )
+
+        async def run_with_logging(coro, svc_name):
+            try:
+                await coro
+            except Exception as e:
+                logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
+
+        self._tasks = []
+
+        # SSDP for proxy
+        proxy_serial = self.target_printer_serial or self.serial
+        if self.remote_interface_ip:
+            from backend.app.services.network_utils import find_interface_for_ip
+
+            local_iface = find_interface_for_ip(self.target_printer_ip)
+            if local_iface:
+                self._ssdp_proxy = SSDPProxy(
+                    local_interface_ip=local_iface["ip"],
+                    remote_interface_ip=self.remote_interface_ip,
+                    target_printer_ip=self.target_printer_ip,
+                )
+                self._tasks.append(
+                    asyncio.create_task(
+                        run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
+                        name=f"vp_{self.id}_ssdp_proxy",
+                    )
+                )
+            else:
+                self._start_fallback_ssdp(proxy_serial, run_with_logging)
+        else:
+            self._start_fallback_ssdp(proxy_serial, run_with_logging)
+
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._proxy.start(), "Proxy"),
+                name=f"vp_{self.id}_proxy",
+            )
+        )
+
+    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
+        """Start single-interface SSDP server as fallback for proxy mode."""
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.name} (Proxy)",
+            serial=proxy_serial,
+            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+            advertise_ip=self.bind_ip or "",
+            bind_ip=self.bind_ip or "",
+        )
+        self._tasks.append(
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name=f"vp_{self.id}_ssdp",
+            )
+        )
+
+    async def stop_proxy(self) -> None:
+        """Stop proxy mode services for this instance."""
+        if self._proxy:
+            await self._proxy.stop()
+            self._proxy = None
+        if self._ssdp:
+            await self._ssdp.stop()
+            self._ssdp = None
+        if self._ssdp_proxy:
+            await self._ssdp_proxy.stop()
+            self._ssdp_proxy = None
+        await self._cancel_tasks()
+
+    async def _cancel_tasks(self) -> None:
+        """Cancel all running tasks and wait for cleanup."""
+        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:
+                pass
+        self._tasks = []
+
+    def get_status(self) -> dict:
+        """Get status for this instance."""
+        status: dict = {
             "running": self.is_running,
-            "mode": self._mode,
-            "name": self.PRINTER_NAME,
-            "serial": self.printer_serial,
-            "model": self._model,
-            "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
             "pending_files": len(self._pending_files),
         }
+        if self.is_proxy and self._proxy:
+            status["proxy"] = self._proxy.get_status()
+        return status
 
-        # Add proxy-specific status
-        if self._mode == "proxy":
-            status["target_printer_ip"] = self._target_printer_ip
-            if self._proxy:
-                proxy_status = self._proxy.get_status()
-                status["proxy"] = proxy_status
 
-        return status
+class VirtualPrinterManager:
+    """Multi-instance virtual printer registry and orchestrator.
+
+    Every VP runs its own independent services on a dedicated bind IP.
+    """
+
+    def __init__(self):
+        self._session_factory: Callable | None = None
+        self._instances: dict[int, VirtualPrinterInstance] = {}
+
+        # Directories
+        self._base_dir = app_settings.base_dir / "virtual_printer"
+
+        # Ensure base directories exist
+        self._ensure_base_directories()
+
+    def _ensure_base_directories(self) -> None:
+        """Create base directories at startup."""
+        for dir_path in [self._base_dir, self._base_dir / "uploads", self._base_dir / "certs"]:
+            try:
+                dir_path.mkdir(parents=True, exist_ok=True)
+            except PermissionError:
+                logger.error(
+                    f"Cannot create directory {dir_path}: Permission denied. "
+                    f"For Docker: ensure the data volume is writable by the container user. "
+                    f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
+                )
+
+    def set_session_factory(self, session_factory: Callable) -> None:
+        """Set the database session factory."""
+        self._session_factory = session_factory
+
+    @property
+    def is_enabled(self) -> bool:
+        """Check if any virtual printer is running."""
+        return len(self._instances) > 0
+
+    async def sync_from_db(self) -> None:
+        """Load all VPs from DB, reconcile running state."""
+        if not self._session_factory:
+            logger.warning("Cannot sync virtual printers: no session factory")
+            return
+
+        from sqlalchemy import select
+
+        from backend.app.models.printer import Printer
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        async with self._session_factory() as db:
+            result = await db.execute(
+                select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position)  # noqa: E712
+            )
+            enabled_vps = result.scalars().all()
+
+        # Stop instances that are no longer enabled or changed mode
+        enabled_ids = {vp.id for vp in enabled_vps}
+        for vp_id in list(self._instances.keys()):
+            if vp_id not in enabled_ids:
+                await self.remove_instance(vp_id)
+
+        # Look up printer IPs for proxy VPs
+        proxy_vps = [vp for vp in enabled_vps if vp.mode == "proxy"]
+        proxy_ips: dict[int, tuple[str, str]] = {}
+        if proxy_vps:
+            async with self._session_factory() as db:
+                for pvp in proxy_vps:
+                    if pvp.target_printer_id:
+                        result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))
+                        printer = result.scalar_one_or_none()
+                        if printer:
+                            proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
+
+        # Start instances for all enabled VPs (skip already running)
+        for vp in enabled_vps:
+            if vp.id in self._instances:
+                continue
+
+            if vp.mode == "proxy":
+                ip_info = proxy_ips.get(vp.id)
+                if not ip_info:
+                    logger.warning("Proxy VP %s: target printer not found, skipping", vp.name)
+                    continue
+                target_ip, target_serial = ip_info
+                instance = VirtualPrinterInstance(
+                    vp_id=vp.id,
+                    name=vp.name,
+                    mode=vp.mode,
+                    model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                    access_code=vp.access_code or "",
+                    serial_suffix=vp.serial_suffix,
+                    target_printer_ip=target_ip,
+                    target_printer_serial=target_serial,
+                    bind_ip=vp.bind_ip or "",
+                    remote_interface_ip=vp.remote_interface_ip or "",
+                    base_dir=self._base_dir,
+                    session_factory=self._session_factory,
+                )
+                self._instances[vp.id] = instance
+                await instance.start_proxy()
+                logger.info("Started proxy VP: %s → %s", instance.name, target_ip)
+            else:
+                instance = VirtualPrinterInstance(
+                    vp_id=vp.id,
+                    name=vp.name,
+                    mode=vp.mode,
+                    model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                    access_code=vp.access_code or "",
+                    serial_suffix=vp.serial_suffix,
+                    bind_ip=vp.bind_ip or "",
+                    remote_interface_ip=vp.remote_interface_ip or "",
+                    base_dir=self._base_dir,
+                    session_factory=self._session_factory,
+                )
+                self._instances[vp.id] = instance
+                await instance.start_server()
+                logger.info("Started server-mode VP: %s on %s", instance.name, vp.bind_ip)
+
+    async def remove_instance(self, vp_id: int) -> None:
+        """Stop and remove a single VP instance."""
+        instance = self._instances.pop(vp_id, None)
+        if instance:
+            if instance.is_proxy:
+                await instance.stop_proxy()
+            else:
+                await instance.stop_server()
+            logger.info("Removed VP instance: %s", instance.name)
+
+    async def stop_all(self) -> None:
+        """Shutdown all virtual printer services."""
+        logger.info("Stopping all virtual printer services...")
+
+        for vp_id in list(self._instances.keys()):
+            await self.remove_instance(vp_id)
+
+        logger.info("All virtual printer services stopped")
+
+    def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:
+        """Get a running instance by ID."""
+        return self._instances.get(vp_id)
+
+    def get_all_status(self) -> list[dict]:
+        """Get status for all running instances."""
+        return [
+            {
+                "id": inst.id,
+                "name": inst.name,
+                "mode": inst.mode,
+                **inst.get_status(),
+            }
+            for inst in self._instances.values()
+        ]
+
+    # -- Legacy single-printer compat --
+
+    def get_status(self) -> dict:
+        """Get status for first virtual printer (backward compat)."""
+        if self._instances:
+            first = next(iter(self._instances.values()))
+            return {
+                "enabled": True,
+                "running": first.is_running,
+                "mode": first.mode,
+                "name": first.name,
+                "serial": first.serial,
+                "model": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                "model_name": VIRTUAL_PRINTER_MODELS.get(
+                    first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                    first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                ),
+                "pending_files": first.get_status().get("pending_files", 0),
+                **({"target_printer_ip": first.target_printer_ip} if first.is_proxy else {}),
+                **({"proxy": first.get_status().get("proxy", {})} if first.is_proxy else {}),
+            }
+        return {
+            "enabled": False,
+            "running": False,
+            "mode": "immediate",
+            "name": "Bambuddy",
+            "serial": "",
+            "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
+            "model_name": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],
+            "pending_files": 0,
+        }
+
+    async def configure(
+        self,
+        enabled: bool,
+        access_code: str = "",
+        mode: str = "immediate",
+        model: str = "",
+        target_printer_ip: str = "",
+        target_printer_serial: str = "",
+        remote_interface_ip: str = "",
+    ) -> None:
+        """Legacy single-printer configure. Delegates to sync_from_db()."""
+        # This method is kept for backward compat with the settings endpoint.
+        # The actual work is done by sync_from_db() which reads from the DB.
+        await self.sync_from_db()
 
 
 # Global instance

+ 38 - 16
backend/app/services/virtual_printer/mqtt_server.py

@@ -16,6 +16,21 @@ logger = logging.getLogger(__name__)
 # Default MQTT port for Bambu printers (MQTT over TLS)
 MQTT_PORT = 8883
 
+# Model code → product_name for version response (must match what slicer expects)
+MODEL_PRODUCT_NAMES = {
+    "3DPrinter-X1-Carbon": "X1 Carbon",
+    "3DPrinter-X1": "X1",
+    "C13": "X1E",
+    "C11": "P1P",
+    "C12": "P1S",
+    "N7": "P2S",
+    "N2S": "A1",
+    "N1": "A1 mini",
+    "O1D": "H2D",
+    "O1C": "H2C",
+    "O1S": "H2S",
+}
+
 
 class VirtualPrinterMQTTServer:
     """MQTT broker that accepts connections from slicers.
@@ -168,13 +183,17 @@ class SimpleMQTTServer:
         key_path: Path,
         port: int = MQTT_PORT,
         on_print_command: Callable[[str, dict], None] | None = None,
+        model: str = "",
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.serial = serial
         self.access_code = access_code
+        self.model = model
         self.cert_path = cert_path
         self.key_path = key_path
         self.port = port
         self.on_print_command = on_print_command
+        self.bind_address = bind_address
         self._running = False
         self._server = None
         self._clients: dict[str, asyncio.StreamWriter] = {}
@@ -255,7 +274,7 @@ class SimpleMQTTServer:
 
             self._server = await asyncio.start_server(
                 connection_handler,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.port,
                 ssl=ssl_context,
             )
@@ -594,7 +613,7 @@ class SimpleMQTTServer:
                 }
             }
 
-            await self._publish_to_report(writer, status)
+            await self._publish_to_report(writer, status, self.serial)
 
         except OSError as e:
             logger.error("Failed to send status report: %s", e)
@@ -602,6 +621,9 @@ class SimpleMQTTServer:
     async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
         """Send version info response to the slicer."""
         try:
+            product_name = MODEL_PRODUCT_NAMES.get(self.model, self.model or "X1 Carbon")
+            serial = self.serial
+
             # Build version response matching OrcaSlicer expectations
             # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
             version_info = {
@@ -611,55 +633,55 @@ class SimpleMQTTServer:
                     "module": [
                         {
                             "name": "ota",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "01.07.00.00",
                             "sw_new_ver": "",
                             "hw_ver": "OTA",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "esp32",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "01.07.22.25",
                             "sw_new_ver": "",
                             "hw_ver": "AP05",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "rv1126",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "00.00.27.38",
                             "sw_new_ver": "",
                             "hw_ver": "AP05",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "th",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "00.00.04.00",
                             "sw_new_ver": "",
                             "hw_ver": "TH07",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                         {
                             "name": "mc",
-                            "product_name": "X1 Carbon",
+                            "product_name": product_name,
                             "sw_ver": "00.00.10.00",
                             "sw_new_ver": "",
                             "hw_ver": "MC07",
-                            "sn": self.serial,
+                            "sn": serial,
                             "flag": 0,
                         },
                     ],
                 }
             }
 
-            await self._publish_to_report(writer, version_info)
-            logger.info("Sent version response")
+            await self._publish_to_report(writer, version_info, serial)
+            logger.info("Sent version response (product_name=%s)", product_name)
 
         except OSError as e:
             logger.error("Failed to send version response: %s", e)
@@ -673,9 +695,9 @@ class SimpleMQTTServer:
         self._current_file = filename
         self._prepare_percent = prepare_percent
 
-    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None:
+    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = "") -> None:
         """Publish a message on the device report topic."""
-        topic = f"device/{self.serial}/report"
+        topic = f"device/{serial or self.serial}/report"
         message = json.dumps(payload)
 
         topic_bytes = topic.encode("utf-8")

+ 22 - 24
backend/app/services/virtual_printer/ssdp_server.py

@@ -34,21 +34,24 @@ class VirtualPrinterSSDPServer:
         serial: str = "00M09A391800001",  # X1C serial format for compatibility
         model: str = "BL-P001",  # X1C model code for best compatibility
         advertise_ip: str = "",
+        bind_ip: str = "",
     ):
         """Initialize the SSDP server.
 
         Args:
             name: Display name shown in slicer discovery
-            serial: Unique serial number for this virtual printer (must match cert CN)
-            model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
+            serial: Unique serial number
+            model: Model code
             advertise_ip: Override IP to advertise instead of auto-detecting
+            bind_ip: IP address to bind the SSDP socket to
         """
         self.name = name
         self.serial = serial
         self.model = model
+        self._bind_ip = bind_ip
         self._running = False
         self._socket: socket.socket | None = None
-        self._local_ip: str | None = advertise_ip or None
+        self._local_ip: str | None = advertise_ip or bind_ip or None
 
     def _get_local_ip(self) -> str:
         """Get the local IP address to advertise."""
@@ -67,14 +70,8 @@ class VirtualPrinterSSDPServer:
             return "127.0.0.1"
 
     def _build_notify_message(self) -> bytes:
-        """Build SSDP NOTIFY message for periodic announcements.
-
-        Format matches real Bambu printer SSDP broadcasts observed on the network.
-        Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
-        """
+        """Build SSDP NOTIFY message for periodic announcements."""
         ip = self._get_local_ip()
-        # Match exact format of real Bambu printers (captured via tcpdump)
-        # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "NOTIFY * HTTP/1.1\r\n"
             f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
@@ -98,13 +95,8 @@ class VirtualPrinterSSDPServer:
         return message.encode()
 
     def _build_response_message(self) -> bytes:
-        """Build SSDP response message for M-SEARCH requests.
-
-        Format matches real Bambu printer SSDP responses.
-        """
+        """Build SSDP response message for M-SEARCH requests."""
         ip = self._get_local_ip()
-        # Match format of real Bambu printers
-        # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
         message = (
             "HTTP/1.1 200 OK\r\n"
             "Server: UPnP/1.0\r\n"
@@ -147,11 +139,18 @@ class VirtualPrinterSSDPServer:
             # Set non-blocking mode
             self._socket.setblocking(False)
 
-            # Bind to SSDP port
-            self._socket.bind(("", SSDP_PORT))
+            # Bind to SSDP port on specific interface (or all interfaces)
+            self._socket.bind((self._bind_ip or "", SSDP_PORT))
 
-            # Join multicast group
-            mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
+            # Join multicast group (on specific interface if bind_ip is set)
+            if self._bind_ip:
+                mreq = struct.pack(
+                    "4s4s",
+                    socket.inet_aton(SSDP_MULTICAST_ADDR),
+                    socket.inet_aton(self._bind_ip),
+                )
+            else:
+                mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
             self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
 
             # Enable broadcast
@@ -226,17 +225,16 @@ class VirtualPrinterSSDPServer:
             self._socket = None
 
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
+        """Send SSDP NOTIFY message via broadcast."""
         if not self._socket:
             return
 
         try:
             msg = self._build_notify_message()
-            # Real Bambu printers broadcast to 255.255.255.255, not multicast
             self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             logger.debug("Sent SSDP NOTIFY for %s", self.name)
         except OSError as e:
-            logger.debug("Failed to send NOTIFY: %s", e)
+            logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
 
     async def _send_byebye(self) -> None:
         """Send SSDP byebye message when shutting down."""
@@ -282,7 +280,7 @@ class VirtualPrinterSSDPServer:
                 self._socket.sendto(response, addr)
                 logger.info("Sent SSDP response to %s for virtual printer '%s'", addr[0], self.name)
             except OSError as e:
-                logger.debug("Failed to send SSDP response: %s", e)
+                logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
 
 
 class SSDPProxy:

+ 43 - 26
backend/app/services/virtual_printer/tcp_proxy.py

@@ -81,6 +81,7 @@ class TLSProxy:
         server_key_path: Path,
         on_connect: Callable[[str], None] | None = None,
         on_disconnect: Callable[[str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         """Initialize the TLS proxy.
 
@@ -93,6 +94,7 @@ class TLSProxy:
             server_key_path: Path to server private key
             on_connect: Optional callback when client connects (receives client_id)
             on_disconnect: Optional callback when client disconnects (receives client_id)
+            bind_address: IP address to bind to (default: all interfaces)
         """
         self.name = name
         self.listen_port = listen_port
@@ -102,6 +104,7 @@ class TLSProxy:
         self.server_key_path = server_key_path
         self.on_connect = on_connect
         self.on_disconnect = on_disconnect
+        self.bind_address = bind_address
 
         self._server: asyncio.Server | None = None
         self._running = False
@@ -134,7 +137,7 @@ class TLSProxy:
             return
 
         logger.info(
-            f"Starting {self.name} TLS proxy: 0.0.0.0:{self.listen_port} → {self.target_host}:{self.target_port}"
+            f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
         )
 
         try:
@@ -147,7 +150,7 @@ class TLSProxy:
             # Start server with TLS
             self._server = await asyncio.start_server(
                 self._handle_client,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.listen_port,
                 ssl=self._server_ssl_context,
             )
@@ -343,7 +346,7 @@ class TLSProxy:
 class TCPProxy:
     """Raw TCP proxy that forwards data without TLS termination.
 
-    Used for protocols where the printer doesn't use TLS (e.g., port 3000
+    Used for protocols where the printer doesn't use TLS (e.g., port 3002
     binding/authentication protocol).
     """
 
@@ -355,6 +358,7 @@ class TCPProxy:
         target_port: int,
         on_connect: Callable[[str], None] | None = None,
         on_disconnect: Callable[[str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         self.name = name
         self.listen_port = listen_port
@@ -362,6 +366,7 @@ class TCPProxy:
         self.target_port = target_port
         self.on_connect = on_connect
         self.on_disconnect = on_disconnect
+        self.bind_address = bind_address
 
         self._server: asyncio.Server | None = None
         self._running = False
@@ -373,8 +378,9 @@ class TCPProxy:
             return
 
         logger.info(
-            "Starting %s TCP proxy: 0.0.0.0:%s → %s:%s",
+            "Starting %s TCP proxy: %s:%s → %s:%s",
             self.name,
+            self.bind_address,
             self.listen_port,
             self.target_host,
             self.target_port,
@@ -385,7 +391,7 @@ class TCPProxy:
 
             self._server = await asyncio.start_server(
                 self._handle_client,
-                "0.0.0.0",  # nosec B104
+                self.bind_address,
                 self.listen_port,
             )
 
@@ -1039,13 +1045,12 @@ class SlicerProxyManager:
     # Bambu printer ports
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
-    PRINTER_BIND_PORT = 3000
+    PRINTER_BIND_PORTS = [3000, 3002]
 
     # Local listen ports - must match what Bambu Studio expects
     # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
     LOCAL_FTP_PORT = 990
     LOCAL_MQTT_PORT = 8883
-    LOCAL_BIND_PORT = 3000
 
     def __init__(
         self,
@@ -1053,6 +1058,7 @@ class SlicerProxyManager:
         cert_path: Path,
         key_path: Path,
         on_activity: Callable[[str, str], None] | None = None,
+        bind_address: str = "0.0.0.0",  # nosec B104
     ):
         """Initialize the slicer proxy manager.
 
@@ -1061,15 +1067,17 @@ class SlicerProxyManager:
             cert_path: Path to server certificate
             key_path: Path to server private key
             on_activity: Optional callback for activity logging (name, message)
+            bind_address: IP address to bind proxy listeners to
         """
         self.target_host = target_host
         self.cert_path = cert_path
         self.key_path = key_path
         self.on_activity = on_activity
+        self.bind_address = bind_address
 
         self._ftp_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
-        self._bind_proxy: TCPProxy | None = None
+        self._bind_proxies: list[TCPProxy] = []
         self._tasks: list[asyncio.Task] = []
 
     async def start(self) -> None:
@@ -1100,6 +1108,7 @@ class SlicerProxyManager:
             server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
         )
 
         self._mqtt_proxy = TLSProxy(
@@ -1111,17 +1120,22 @@ class SlicerProxyManager:
             server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
         )
 
-        # Bind/auth proxy (port 3000) - raw TCP, no TLS
-        self._bind_proxy = TCPProxy(
-            name="Bind",
-            listen_port=self.LOCAL_BIND_PORT,
-            target_host=self.target_host,
-            target_port=self.PRINTER_BIND_PORT,
-            on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-            on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-        )
+        # Bind/auth proxy (ports 3000 + 3002) - raw TCP, no TLS
+        # Different BambuStudio versions use different ports
+        for bind_port in self.PRINTER_BIND_PORTS:
+            proxy = TCPProxy(
+                name="Bind",
+                listen_port=bind_port,
+                target_host=self.target_host,
+                target_port=bind_port,
+                on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                bind_address=self.bind_address,
+            )
+            self._bind_proxies.append(proxy)
 
         # Start as background tasks
         async def run_with_logging(proxy: TLSProxy) -> None:
@@ -1139,11 +1153,14 @@ class SlicerProxyManager:
                 run_with_logging(self._mqtt_proxy),
                 name="slicer_proxy_mqtt",
             ),
-            asyncio.create_task(
-                run_with_logging(self._bind_proxy),
-                name="slicer_proxy_bind",
-            ),
         ]
+        for bp in self._bind_proxies:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(bp),
+                    name=f"slicer_proxy_bind_{bp.listen_port}",
+                )
+            )
 
         logger.info("Slicer TLS proxy started for %s", self.target_host)
 
@@ -1167,9 +1184,9 @@ class SlicerProxyManager:
             await self._mqtt_proxy.stop()
             self._mqtt_proxy = None
 
-        if self._bind_proxy:
-            await self._bind_proxy.stop()
-            self._bind_proxy = None
+        for bp in self._bind_proxies:
+            await bp.stop()
+        self._bind_proxies = []
 
         # Cancel tasks
         for task in self._tasks:
@@ -1207,8 +1224,8 @@ class SlicerProxyManager:
             "target_host": self.target_host,
             "ftp_port": self.LOCAL_FTP_PORT,
             "mqtt_port": self.LOCAL_MQTT_PORT,
-            "bind_port": self.LOCAL_BIND_PORT,
+            "bind_ports": self.PRINTER_BIND_PORTS,
             "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
             "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
-            "bind_connections": (len(self._bind_proxy._active_connections) if self._bind_proxy else 0),
+            "bind_connections": sum(len(bp._active_connections) for bp in self._bind_proxies),
         }

+ 417 - 430
backend/tests/unit/services/test_virtual_printer.py

@@ -10,193 +10,305 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 
-class TestVirtualPrinterManager:
-    """Tests for VirtualPrinterManager class."""
+class TestVirtualPrinterInstance:
+    """Tests for VirtualPrinterInstance class."""
 
     @pytest.fixture
-    def manager(self):
-        """Create a VirtualPrinterManager instance."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+    def instance(self, tmp_path):
+        """Create a VirtualPrinterInstance with test defaults."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        return VirtualPrinterManager()
+        return VirtualPrinterInstance(
+            vp_id=1,
+            name="TestPrinter",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
 
     # ========================================================================
-    # Tests for configuration
+    # Tests for instance properties
     # ========================================================================
 
-    @pytest.mark.asyncio
-    async def test_configure_sets_parameters(self, manager):
-        """Verify configure stores parameters correctly."""
-        # Mock the start/stop methods to avoid actually starting services
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
+    def test_instance_stores_parameters(self, instance):
+        """Verify constructor stores parameters correctly."""
+        assert instance.id == 1
+        assert instance.name == "TestPrinter"
+        assert instance.mode == "immediate"
+        assert instance.model == "C11"
+        assert instance.access_code == "12345678"
+        assert instance.serial_suffix == "391800001"
+
+    def test_instance_serial_property(self, instance):
+        """Verify serial is generated from model prefix + suffix."""
+        # C11 = P1P, prefix = 01S00A
+        assert instance.serial == "01S00A391800001"
+
+    def test_instance_serial_x1c(self, tmp_path):
+        """Verify X1C serial uses correct prefix."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=2,
+            name="X1C",
             mode="immediate",
-        )
-
-        assert manager._enabled is True
-        assert manager._access_code == "12345678"
-        assert manager._mode == "immediate"
-
-    @pytest.mark.asyncio
-    async def test_configure_disabled_stops_services(self, manager):
-        """Verify disabling stops all services."""
-        # First simulate enabled state
-        manager._enabled = True
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-
-        await manager.configure(enabled=False, access_code="12345678")
-
-        assert manager._enabled is False
-        manager._stop.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_configure_requires_access_code_when_enabling(self, manager):
-        """Verify access code is required when enabling."""
-        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,
+            model="3DPrinter-X1-Carbon",
             access_code="12345678",
-            mode="immediate",
-            model="C11",  # P1S model code
+            serial_suffix="391800002",
+            base_dir=tmp_path,
         )
+        assert inst.serial == "00M00A391800002"
 
-        assert manager._model == "C11"
+    def test_instance_is_proxy_false(self, instance):
+        """Verify is_proxy is False for non-proxy mode."""
+        assert instance.is_proxy is False
 
-    @pytest.mark.asyncio
-    async def test_configure_ignores_invalid_model(self, manager):
-        """Verify configure ignores invalid model codes."""
-        manager._start = AsyncMock()
+    def test_instance_is_proxy_true(self, tmp_path):
+        """Verify is_proxy is True for proxy mode."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            model="INVALID",
+        inst = VirtualPrinterInstance(
+            vp_id=3,
+            name="Proxy",
+            mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800003",
+            target_printer_ip="192.168.1.100",
+            base_dir=tmp_path,
         )
+        assert inst.is_proxy is True
 
-        # Should keep default model (3DPrinter-X1-Carbon = X1C)
-        assert manager._model == "3DPrinter-X1-Carbon"
+    def test_instance_is_running_with_active_tasks(self, instance):
+        """Verify is_running is True when tasks are active."""
+        mock_task = MagicMock()
+        mock_task.done.return_value = False
+        instance._tasks = [mock_task]
+        assert instance.is_running is True
 
-    @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 = "3DPrinter-X1-Carbon"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            model="C11",  # P1P
-        )
+    def test_instance_is_running_with_no_tasks(self, instance):
+        """Verify is_running is False when no tasks."""
+        assert instance.is_running is False
 
-        # Should have stopped and started
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
+    def test_instance_creates_directories(self, instance, tmp_path):
+        """Verify instance creates upload and cert directories."""
+        assert (tmp_path / "uploads" / "1").exists()
+        assert (tmp_path / "uploads" / "1" / "cache").exists()
+        assert (tmp_path / "certs" / "1").exists()
 
     # ========================================================================
     # Tests for status
     # ========================================================================
 
-    def test_get_status_returns_correct_format(self, manager):
+    def test_get_status_returns_correct_format(self, instance):
         """Verify get_status returns expected fields."""
-        manager._enabled = True
-        manager._mode = "immediate"
-        manager._model = "C11"  # P1P
-        manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
-        # Simulate running tasks
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
+        mock_task = MagicMock(done=MagicMock(return_value=False))
+        instance._tasks = [mock_task]
 
-        status = manager.get_status()
-
-        assert status["enabled"] is True
+        status = instance.get_status()
         assert status["running"] is True
-        assert status["mode"] == "immediate"
-        assert status["name"] == "Bambuddy"
-        assert status["serial"] == "01S00A391800001"  # C11 (P1P) serial prefix
-        assert status["model"] == "C11"
-        assert status["model_name"] == "P1P"
         assert status["pending_files"] == 1
 
-    def test_get_status_when_stopped(self, manager):
-        """Verify get_status when not running."""
-        manager._enabled = False
-        manager._tasks = []
-
-        status = manager.get_status()
-
-        assert status["enabled"] is False
+    def test_get_status_not_running(self, instance):
+        """Verify get_status when no tasks."""
+        status = instance.get_status()
         assert status["running"] is False
-
-    def test_is_running_with_active_tasks(self, manager):
-        """Verify is_running is True when tasks are active."""
-        mock_task = MagicMock()
-        mock_task.done.return_value = False
-        manager._tasks = [mock_task]
-
-        assert manager.is_running is True
-
-    def test_is_running_with_no_tasks(self, manager):
-        """Verify is_running is False when no tasks."""
-        manager._tasks = []
-
-        assert manager.is_running is False
+        assert status["pending_files"] == 0
 
     # ========================================================================
     # Tests for file handling
     # ========================================================================
 
     @pytest.mark.asyncio
-    async def test_on_file_received_adds_to_pending(self, manager):
-        """Verify received file is added to pending list."""
-        manager._mode = "queue"
-        manager._session_factory = None  # Disable actual archiving
+    async def test_on_file_received_adds_to_pending(self, instance):
+        """Verify received file is added to pending list in review mode."""
+        instance.mode = "review"
 
         file_path = Path("/tmp/test.3mf")
 
-        with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue:
-            await manager._on_file_received(file_path, "192.168.1.100")
+        with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue:
+            await instance.on_file_received(file_path, "192.168.1.100")
 
-            assert "test.3mf" in manager._pending_files
+            assert "test.3mf" in instance._pending_files
             mock_queue.assert_called_once()
 
     @pytest.mark.asyncio
-    async def test_on_file_received_archives_immediately(self, manager):
+    async def test_on_file_received_archives_immediately(self, instance):
         """Verify file is archived in immediate mode."""
-        manager._mode = "immediate"
-        manager._session_factory = None  # Will prevent actual archiving
-
         file_path = Path("/tmp/test.3mf")
 
-        with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive:
-            await manager._on_file_received(file_path, "192.168.1.100")
+        with patch.object(instance, "_archive_file", new_callable=AsyncMock) as mock_archive:
+            await instance.on_file_received(file_path, "192.168.1.100")
 
             mock_archive.assert_called_once_with(file_path, "192.168.1.100")
 
     @pytest.mark.asyncio
-    async def test_archive_file_skips_non_3mf(self, manager):
+    async def test_archive_file_skips_non_3mf(self, instance):
         """Verify non-3MF files are skipped and cleaned up."""
-        manager._session_factory = MagicMock()
-        manager._pending_files["verify_job"] = Path("/tmp/verify_job")
+        instance._session_factory = MagicMock()
+        instance._pending_files["verify_job"] = Path("/tmp/verify_job")
 
         with patch("pathlib.Path.unlink"):
-            await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
+            await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
+
+            assert "verify_job" not in instance._pending_files
+
+
+class TestVirtualPrinterManager:
+    """Tests for VirtualPrinterManager orchestrator."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a VirtualPrinterManager instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        return VirtualPrinterManager()
+
+    def test_manager_starts_empty(self, manager):
+        """Verify manager starts with no instances."""
+        assert len(manager._instances) == 0
+        assert manager.is_enabled is False
+
+    def test_manager_get_status_empty(self, manager):
+        """Verify get_status returns disabled state when no instances."""
+        status = manager.get_status()
+        assert status["enabled"] is False
+        assert status["running"] is False
+        assert status["mode"] == "immediate"
+
+    def test_manager_is_enabled_with_instance(self, manager, tmp_path):
+        """Verify is_enabled is True when instances exist."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="Test",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        manager._instances[1] = inst
+        assert manager.is_enabled is True
+
+    @pytest.mark.asyncio
+    async def test_manager_remove_instance_server(self, manager, tmp_path):
+        """Verify remove_instance stops and removes a server-mode instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="Test",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        await manager.remove_instance(1)
+
+        assert 1 not in manager._instances
+        inst.stop_server.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_manager_remove_instance_proxy(self, manager, tmp_path):
+        """Verify remove_instance stops proxy-mode instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=2,
+            name="Proxy",
+            mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800002",
+            target_printer_ip="192.168.1.100",
+            base_dir=tmp_path,
+        )
+        inst.stop_proxy = AsyncMock()
+        manager._instances[2] = inst
+
+        await manager.remove_instance(2)
+
+        assert 2 not in manager._instances
+        inst.stop_proxy.assert_called_once()
+
+    def test_manager_get_status_with_instance(self, manager, tmp_path):
+        """Verify legacy get_status returns first instance data."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="Bambuddy",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            base_dir=tmp_path,
+        )
+        mock_task = MagicMock(done=MagicMock(return_value=False))
+        inst._tasks = [mock_task]
+        inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
+        manager._instances[1] = inst
+
+        status = manager.get_status()
+        assert status["enabled"] is True
+        assert status["running"] is True
+        assert status["mode"] == "immediate"
+        assert status["name"] == "Bambuddy"
+        assert status["serial"] == "01S00A391800001"
+        assert status["model"] == "C11"
+        assert status["model_name"] == "P1P"
+        assert status["pending_files"] == 1
+
+    def test_manager_get_all_status(self, manager, tmp_path):
+        """Verify get_all_status returns status for all instances."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        for i in range(1, 3):
+            inst = VirtualPrinterInstance(
+                vp_id=i,
+                name=f"VP{i}",
+                mode="immediate",
+                model="C11",
+                access_code="12345678",
+                serial_suffix=f"39180000{i}",
+                base_dir=tmp_path,
+            )
+            manager._instances[i] = inst
 
-            # Should be removed from pending
-            assert "verify_job" not in manager._pending_files
+        statuses = manager.get_all_status()
+        assert len(statuses) == 2
+        assert statuses[0]["name"] == "VP1"
+        assert statuses[1]["name"] == "VP2"
+
+    @pytest.mark.asyncio
+    async def test_manager_stop_all(self, manager, tmp_path):
+        """Verify stop_all removes all instances."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        for i in range(1, 3):
+            inst = VirtualPrinterInstance(
+                vp_id=i,
+                name=f"VP{i}",
+                mode="immediate",
+                model="C11",
+                access_code="12345678",
+                serial_suffix=f"39180000{i}",
+                base_dir=tmp_path,
+            )
+            inst.stop_server = AsyncMock()
+            manager._instances[i] = inst
+
+        await manager.stop_all()
+        assert len(manager._instances) == 0
 
 
 class TestFTPSession:
@@ -510,7 +622,7 @@ class TestCertificateService:
 
 
 class TestBindServer:
-    """Tests for BindServer (port 3000 bind/detect protocol)."""
+    """Tests for BindServer (port 3002 bind/detect protocol)."""
 
     @pytest.fixture
     def bind_server(self):
@@ -628,6 +740,18 @@ class TestBindServer:
         )
         assert server.version == "02.03.04.05"
 
+    def test_bind_ports_constant(self):
+        """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
+        from backend.app.services.virtual_printer.bind_server import BIND_PORTS
+
+        assert 3000 in BIND_PORTS
+        assert 3002 in BIND_PORTS
+
+    def test_bind_server_initializes_empty_servers_list(self, bind_server):
+        """Verify bind server starts with empty servers list."""
+        assert bind_server._servers == []
+        assert bind_server._running is False
+
 
 class TestSlicerProxyManager:
     """Tests for SlicerProxyManager (proxy mode)."""
@@ -657,6 +781,8 @@ class TestSlicerProxyManager:
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
+        # Bind ports: both 3000 and 3002 for slicer compatibility
+        assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
 
     def test_proxy_manager_stores_target_host(self, proxy_manager):
         """Verify proxy manager stores target host."""
@@ -740,42 +866,28 @@ class TestSSDPProxy:
 class TestVirtualPrinterManagerDirectories:
     """Tests for VirtualPrinterManager directory management."""
 
-    def test_ensure_directories_creates_subdirs(self, tmp_path):
-        """Verify _ensure_directories creates all required subdirectories."""
+    def test_ensure_base_directories_creates_subdirs(self, tmp_path):
+        """Verify _ensure_base_directories creates required base directories."""
         from backend.app.services.virtual_printer.manager import VirtualPrinterManager
 
-        # Create a manager and manually call _ensure_directories with our tmp path
         manager = VirtualPrinterManager()
-        # Override the paths
         manager._base_dir = tmp_path / "virtual_printer"
-        manager._upload_dir = manager._base_dir / "uploads"
-        manager._cert_dir = manager._base_dir / "certs"
-
-        # Call the method
-        manager._ensure_directories()
+        manager._ensure_base_directories()
 
-        # All directories should be created
         assert (tmp_path / "virtual_printer").exists()
         assert (tmp_path / "virtual_printer" / "uploads").exists()
-        assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
         assert (tmp_path / "virtual_printer" / "certs").exists()
 
-    def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
-        """Verify _ensure_directories logs error on permission failure."""
+    def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
+        """Verify _ensure_base_directories logs error on permission failure."""
         import logging
-        from unittest.mock import patch
 
         from backend.app.services.virtual_printer.manager import VirtualPrinterManager
 
-        # Create manager and override paths
         manager = VirtualPrinterManager()
         vp_dir = tmp_path / "virtual_printer"
-
         manager._base_dir = vp_dir
-        manager._upload_dir = vp_dir / "uploads"
-        manager._cert_dir = vp_dir / "certs"
 
-        # Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
         original_mkdir = type(vp_dir).mkdir
 
         def mock_mkdir(self, *args, **kwargs):
@@ -784,333 +896,190 @@ class TestVirtualPrinterManagerDirectories:
             return original_mkdir(self, *args, **kwargs)
 
         with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
-            # This should log errors but not raise
-            manager._ensure_directories()
-            # Check that error was logged
+            manager._ensure_base_directories()
             assert "Permission denied" in caplog.text
 
+    def test_instance_creates_per_vp_directories(self, tmp_path):
+        """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-class TestVirtualPrinterManagerProxyMode:
-    """Tests for VirtualPrinterManager proxy mode."""
+        VirtualPrinterInstance(
+            vp_id=42,
+            name="Test",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800042",
+            base_dir=tmp_path,
+        )
 
-    @pytest.fixture
-    def manager(self):
-        """Create a VirtualPrinterManager instance."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+        assert (tmp_path / "uploads" / "42").exists()
+        assert (tmp_path / "uploads" / "42" / "cache").exists()
+        assert (tmp_path / "certs" / "42").exists()
 
-        return VirtualPrinterManager()
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_requires_target_ip(self, manager):
-        """Verify proxy mode requires target_printer_ip."""
-        with pytest.raises(ValueError, match="Target printer IP is required"):
-            await manager.configure(
-                enabled=True,
-                mode="proxy",
-                target_printer_ip="",  # Empty target IP
-            )
+class TestVirtualPrinterInstanceProxyMode:
+    """Tests for VirtualPrinterInstance proxy mode."""
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
-        """Verify proxy mode does not require access code (uses real printer's)."""
-        manager._start = AsyncMock()
+    @pytest.fixture
+    def proxy_instance(self, tmp_path):
+        """Create a proxy-mode VirtualPrinterInstance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        # Should not raise - proxy mode doesn't need access code
-        await manager.configure(
-            enabled=True,
+        return VirtualPrinterInstance(
+            vp_id=10,
+            name="ProxyTest",
             mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800010",
             target_printer_ip="192.168.1.100",
+            target_printer_serial="01P00A000000001",
+            base_dir=tmp_path,
         )
 
-        assert manager._mode == "proxy"
-        assert manager._target_printer_ip == "192.168.1.100"
+    def test_proxy_instance_properties(self, proxy_instance):
+        """Verify proxy instance stores config correctly."""
+        assert proxy_instance.is_proxy is True
+        assert proxy_instance.mode == "proxy"
+        assert proxy_instance.target_printer_ip == "192.168.1.100"
+        assert proxy_instance.target_printer_serial == "01P00A000000001"
 
-    def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
-        """Verify get_status includes proxy-specific fields in proxy mode."""
-        manager._enabled = True
-        manager._mode = "proxy"
-        manager._target_printer_ip = "192.168.1.100"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+    def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
+        """Verify proxy mode can have empty access code."""
+        assert proxy_instance.access_code == ""
 
-        # Create a mock proxy with get_status
+    def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
+        """Verify get_status includes proxy fields when proxy is active."""
         mock_proxy = MagicMock()
         mock_proxy.get_status.return_value = {
             "running": True,
-            "ftp_port": 990,  # Privileged port for Bambu Studio compatibility
+            "ftp_port": 990,
             "mqtt_port": 8883,
             "ftp_connections": 1,
             "mqtt_connections": 2,
             "target_host": "192.168.1.100",
         }
-        manager._proxy = mock_proxy
+        proxy_instance._proxy = mock_proxy
 
-        status = manager.get_status()
-
-        assert status["mode"] == "proxy"
-        assert status["target_printer_ip"] == "192.168.1.100"
+        status = proxy_instance.get_status()
         assert "proxy" in status
-        assert status["proxy"]["ftp_port"] == 990  # Privileged port for Bambu Studio compatibility
-        assert status["proxy"]["mqtt_port"] == 8883
-        assert status["proxy"]["ftp_connections"] == 1
+        assert status["proxy"]["ftp_port"] == 990
         assert status["proxy"]["mqtt_connections"] == 2
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_with_remote_interface(self, manager):
-        """Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
-        manager._start = AsyncMock()
+    def test_proxy_instance_stores_remote_interface(self, tmp_path):
+        """Verify proxy instance stores remote_interface_ip."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        await manager.configure(
-            enabled=True,
+        inst = VirtualPrinterInstance(
+            vp_id=11,
+            name="Proxy2",
             mode="proxy",
+            model="C11",
+            access_code="",
+            serial_suffix="391800011",
             target_printer_ip="192.168.1.100",
             remote_interface_ip="10.0.0.50",
+            base_dir=tmp_path,
         )
+        assert inst.remote_interface_ip == "10.0.0.50"
 
-        assert manager._mode == "proxy"
-        assert manager._target_printer_ip == "192.168.1.100"
-        assert manager._remote_interface_ip == "10.0.0.50"
 
-    @pytest.mark.asyncio
-    async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
-        """Verify changing remote_interface_ip restarts services in proxy mode."""
-        # Simulate running state
-        manager._enabled = True
-        manager._mode = "proxy"
-        manager._target_printer_ip = "192.168.1.100"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            mode="proxy",
-            target_printer_ip="192.168.1.100",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        # Should have stopped and started
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-
-class TestVirtualPrinterManagerServerModeIPOverride:
-    """Tests for remote_interface_ip in server mode (immediate/review/print_queue)."""
+class TestVirtualPrinterInstanceIPOverride:
+    """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
 
     @pytest.fixture
-    def manager(self):
-        """Create a VirtualPrinterManager instance."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
-
-        return VirtualPrinterManager()
-
-    @pytest.mark.asyncio
-    async def test_configure_immediate_mode_stores_remote_interface_ip(self, manager):
-        """Verify immediate mode stores remote_interface_ip."""
-        manager._start = AsyncMock()
+    def instance_with_remote_ip(self, tmp_path):
+        """Create an instance with remote_interface_ip set."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
+        return VirtualPrinterInstance(
+            vp_id=20,
+            name="IPTest",
             mode="immediate",
-            remote_interface_ip="10.0.0.50",
-        )
-
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_configure_review_mode_stores_remote_interface_ip(self, manager):
-        """Verify review mode stores remote_interface_ip."""
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
+            model="3DPrinter-X1-Carbon",
             access_code="12345678",
-            mode="review",
+            serial_suffix="391800020",
+            bind_ip="192.168.1.50",
             remote_interface_ip="10.0.0.50",
+            base_dir=tmp_path,
         )
 
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_configure_print_queue_mode_stores_remote_interface_ip(self, manager):
-        """Verify print_queue mode stores remote_interface_ip."""
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="print_queue",
-            remote_interface_ip="10.0.0.50",
-        )
+    def test_instance_stores_bind_ip(self, instance_with_remote_ip):
+        """Verify bind_ip is stored."""
+        assert instance_with_remote_ip.bind_ip == "192.168.1.50"
 
-        assert manager._remote_interface_ip == "10.0.0.50"
-
-    @pytest.mark.asyncio
-    async def test_remote_interface_change_restarts_immediate_mode(self, manager):
-        """Verify changing remote_interface_ip restarts services in immediate mode."""
-        manager._enabled = True
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="immediate",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_remote_interface_change_restarts_review_mode(self, manager):
-        """Verify changing remote_interface_ip restarts services in review mode."""
-        manager._enabled = True
-        manager._mode = "review"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="review",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_remote_interface_change_restarts_print_queue_mode(self, manager):
-        """Verify changing remote_interface_ip restarts services in print_queue mode."""
-        manager._enabled = True
-        manager._mode = "print_queue"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="print_queue",
-            remote_interface_ip="10.0.0.99",  # Changed
-        )
-
-        manager._stop.assert_called_once()
-        manager._start.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_no_restart_when_remote_interface_unchanged(self, manager):
-        """Verify no restart if remote_interface_ip hasn't changed."""
-        manager._enabled = True
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
-        manager._stop = AsyncMock()
-        manager._start = AsyncMock()
-
-        await manager.configure(
-            enabled=True,
-            access_code="12345678",
-            mode="immediate",
-            remote_interface_ip="10.0.0.50",  # Same
-        )
-
-        manager._stop.assert_not_called()
-        manager._start.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_server_mode_passes_advertise_ip_to_ssdp(self, manager):
-        """Verify _start_server_mode passes remote_interface_ip as advertise_ip to SSDP."""
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._model = "3DPrinter-X1-Carbon"
+    def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
+        """Verify remote_interface_ip is stored."""
+        assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
 
+    def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
+        """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer") as mock_ssdp_cls,
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer"),
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                instance_with_remote_ip._cert_service,
                 "generate_certificates",
-                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
-            ),
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),
+            ) as mock_gen,
         ):
-            mock_ssdp_cls.return_value.start = AsyncMock()
-            await manager._start_server_mode()
+            instance_with_remote_ip.generate_certificates()
+            mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
 
-            mock_ssdp_cls.assert_called_once_with(
-                name="Bambuddy",
-                serial=manager.printer_serial,
-                model="3DPrinter-X1-Carbon",
-                advertise_ip="10.0.0.50",
-            )
+    def test_generate_certificates_no_remote_ip(self, tmp_path):
+        """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-    @pytest.mark.asyncio
-    async def test_server_mode_passes_additional_ips_to_certificate(self, manager):
-        """Verify _start_server_mode includes remote_interface_ip in certificate SANs."""
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = "10.0.0.50"
-        manager._model = "3DPrinter-X1-Carbon"
+        inst = VirtualPrinterInstance(
+            vp_id=21,
+            name="NoRemote",
+            mode="immediate",
+            model="3DPrinter-X1-Carbon",
+            access_code="12345678",
+            serial_suffix="391800021",
+            bind_ip="192.168.1.50",
+            base_dir=tmp_path,
+        )
 
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer"),
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                inst._cert_service,
                 "generate_certificates",
-                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
-            ) as mock_gen_certs,
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),
+            ) as mock_gen,
         ):
-            await manager._start_server_mode()
+            inst.generate_certificates()
+            mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
 
-            mock_gen_certs.assert_called_once_with(additional_ips=["10.0.0.50"])
+    def test_generate_certificates_no_ips(self, tmp_path):
+        """Verify generate_certificates passes None when no IPs configured."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-    @pytest.mark.asyncio
-    async def test_server_mode_no_additional_ips_without_remote_interface(self, manager):
-        """Verify _start_server_mode passes None for additional_ips when no remote interface."""
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = ""
-        manager._model = "3DPrinter-X1-Carbon"
+        inst = VirtualPrinterInstance(
+            vp_id=22,
+            name="NoIPs",
+            mode="immediate",
+            model="3DPrinter-X1-Carbon",
+            access_code="12345678",
+            serial_suffix="391800022",
+            base_dir=tmp_path,
+        )
 
         with (
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
-            patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
-            patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
-            patch("backend.app.services.virtual_printer.manager.BindServer"),
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                inst._cert_service,
                 "generate_certificates",
-                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
-            ) as mock_gen_certs,
+                return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),
+            ) as mock_gen,
         ):
-            await manager._start_server_mode()
-
-            mock_gen_certs.assert_called_once_with(additional_ips=None)
+            inst.generate_certificates()
+            mock_gen.assert_called_once_with(additional_ips=None)
 
 
 class TestBindServer:
-    """Tests for the BindServer (port 3000 bind/detect protocol)."""
+    """Tests for the BindServer (port 3002 bind/detect protocol)."""
 
     @pytest.fixture
     def bind_server(self):
@@ -1203,33 +1172,51 @@ class TestBindServer:
         )
         assert server.version == "01.09.00.10"
 
+    def test_bind_ports_includes_both(self):
+        """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
+        from backend.app.services.virtual_printer.bind_server import BIND_PORTS
+
+        assert 3000 in BIND_PORTS
+        assert 3002 in BIND_PORTS
+
+    def test_bind_server_initializes_empty_servers_list(self, bind_server):
+        """Verify bind server starts with empty servers list."""
+        assert bind_server._servers == []
+        assert bind_server._running is False
+
     @pytest.mark.asyncio
-    async def test_server_mode_creates_bind_server(self):
-        """Verify _start_server_mode creates BindServer with correct params."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+    async def test_start_server_creates_bind_server(self, tmp_path):
+        """Verify start_server creates BindServer with correct params."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
-        manager = VirtualPrinterManager()
-        manager._mode = "immediate"
-        manager._access_code = "12345678"
-        manager._remote_interface_ip = ""
-        manager._model = "3DPrinter-X1-Carbon"
+        inst = VirtualPrinterInstance(
+            vp_id=99,
+            name="Bambuddy",
+            mode="immediate",
+            model="3DPrinter-X1-Carbon",
+            access_code="12345678",
+            serial_suffix="391800099",
+            bind_ip="192.168.1.50",
+            base_dir=tmp_path,
+        )
 
         with (
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
             patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
             patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
             patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls,
-            patch.object(manager._cert_service, "delete_printer_certificate"),
+            patch.object(inst._cert_service, "delete_printer_certificate"),
             patch.object(
-                manager._cert_service,
+                inst._cert_service,
                 "generate_certificates",
                 return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")),  # nosec B108
             ),
         ):
-            await manager._start_server_mode()
+            await inst.start_server()
 
             mock_bind_cls.assert_called_once_with(
-                serial=manager.printer_serial,
+                serial=inst.serial,
                 model="3DPrinter-X1-Carbon",
                 name="Bambuddy",
+                bind_address="192.168.1.50",
             )

+ 1 - 0
docker-compose.yml

@@ -24,6 +24,7 @@ services:
     #ports:
     #  - "${PORT:-8000}:8000"
     #  - "3000:3000"                  # Virtual printer bind/detect
+    #  - "3002:3002"                  # Virtual printer bind/detect
     #  - "8883:8883"                  # Virtual printer MQTT
     #  - "9990:9990"                  # Virtual printer FTP control
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data

BIN
docs/screenshots/settings-virtual-printer.png


+ 1 - 1
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -472,7 +472,7 @@ describe('VirtualPrinterSettings', () => {
 
       await waitFor(() => {
         expect(screen.getByText('How it works:')).toBeInTheDocument();
-        expect(screen.getByText(/Complete the setup guide for your platform/)).toBeInTheDocument();
+        expect(screen.getByText(/virtual printers appear in your slicer/)).toBeInTheDocument();
       });
     });
   });

+ 65 - 0
frontend/src/api/client.ts

@@ -4505,6 +4505,8 @@ export interface NetworkInterface {
   ip: string;
   netmask: string;
   subnet: string;
+  is_alias?: boolean;
+  label?: string;
 }
 
 export interface VirtualPrinterModels {
@@ -4552,6 +4554,69 @@ export const virtualPrinterApi = {
   },
 };
 
+// Multi Virtual Printer API
+export interface VirtualPrinterConfig {
+  id: number;
+  name: string;
+  enabled: boolean;
+  mode: VirtualPrinterMode;
+  model: string | null;
+  model_name: string | null;
+  access_code_set: boolean;
+  serial: string;
+  target_printer_id: number | null;
+  bind_ip: string | null;
+  remote_interface_ip: string | null;
+  position: number;
+  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };
+}
+
+export interface VirtualPrinterListResponse {
+  printers: VirtualPrinterConfig[];
+  models: Record<string, string>;
+}
+
+export const multiVirtualPrinterApi = {
+  list: () => request<VirtualPrinterListResponse>('/virtual-printers'),
+
+  get: (id: number) => request<VirtualPrinterConfig>(`/virtual-printers/${id}`),
+
+  create: (data: {
+    name?: string;
+    enabled?: boolean;
+    mode?: string;
+    model?: string;
+    access_code?: string;
+    target_printer_id?: number;
+    bind_ip?: string;
+    remote_interface_ip?: string;
+  }) =>
+    request<VirtualPrinterConfig>('/virtual-printers', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
+  update: (id: number, data: {
+    name?: string;
+    enabled?: boolean;
+    mode?: string;
+    model?: string;
+    access_code?: string;
+    target_printer_id?: number;
+    bind_ip?: string;
+    remote_interface_ip?: string;
+  }) =>
+    request<VirtualPrinterConfig>(`/virtual-printers/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+
+  remove: (id: number) =>
+    request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {
+      method: 'DELETE',
+    }),
+};
+
 // Pending Uploads API
 export const pendingUploadsApi = {
   list: () => request<PendingUpload[]>('/pending-uploads/'),

+ 156 - 0
frontend/src/components/VirtualPrinterAddDialog.tsx

@@ -0,0 +1,156 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Loader2, ChevronDown, ArrowRightLeft } from 'lucide-react';
+import { api, multiVirtualPrinterApi } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+type Mode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+
+const MODE_LABELS: Record<string, string> = {
+  immediate: 'archive',
+  review: 'review',
+  print_queue: 'queue',
+  proxy: 'proxy',
+};
+
+interface VirtualPrinterAddDialogProps {
+  onClose: () => void;
+}
+
+export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [name, setName] = useState('');
+  const [mode, setMode] = useState<Mode>('immediate');
+  const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const createMutation = useMutation({
+    mutationFn: () =>
+      multiVirtualPrinterApi.create({
+        name: name.trim() || 'Bambuddy',
+        mode,
+        target_printer_id: mode === 'proxy' ? (targetPrinterId ?? undefined) : undefined,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
+      showToast(t('virtualPrinter.toast.created'));
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('virtualPrinter.toast.failedToCreate'), 'error');
+    },
+  });
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card
+        className="w-full max-w-md"
+        onClick={(e: React.MouseEvent) => e.stopPropagation()}
+      >
+        <CardContent className="p-6 space-y-4">
+          <h3 className="text-lg font-semibold text-white">{t('virtualPrinter.addDialog.title')}</h3>
+
+          {/* Name */}
+          <div>
+            <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.addDialog.name')}</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="Bambuddy"
+              className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm placeholder-bambu-gray"
+              autoFocus
+            />
+          </div>
+
+          {/* Mode */}
+          <div>
+            <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.mode.title')}</label>
+            <div className="grid grid-cols-2 gap-2">
+              {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((m) => (
+                <button
+                  key={m}
+                  onClick={() => setMode(m)}
+                  className={`p-2 rounded-lg border text-left transition-colors ${
+                    mode === m
+                      ? m === 'proxy'
+                        ? 'border-blue-500 bg-blue-500/10'
+                        : 'border-bambu-green bg-bambu-green/10'
+                      : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                  }`}
+                >
+                  <div className="flex items-center gap-1.5 text-white text-xs font-medium">
+                    {m === 'proxy' && <ArrowRightLeft className="w-3 h-3" />}
+                    {t(`virtualPrinter.mode.${MODE_LABELS[m]}`)}
+                  </div>
+                  <div className="text-[10px] text-bambu-gray">
+                    {t(`virtualPrinter.mode.${MODE_LABELS[m]}Desc`)}
+                  </div>
+                </button>
+              ))}
+            </div>
+          </div>
+
+          {/* Target Printer - only for proxy mode */}
+          {mode === 'proxy' && (
+            <div>
+              <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.targetPrinter.title')}</label>
+              <div className="relative">
+                <select
+                  value={targetPrinterId ?? ''}
+                  onChange={(e) => {
+                    const id = parseInt(e.target.value, 10);
+                    setTargetPrinterId(isNaN(id) ? null : id);
+                  }}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm appearance-none cursor-pointer pr-10"
+                >
+                  <option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
+                  {printers?.map((p) => (
+                    <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</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>
+            </div>
+          )}
+
+          <p className="text-xs text-bambu-gray">
+            {t('virtualPrinter.addDialog.hint')}
+          </p>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button variant="secondary" onClick={onClose} className="flex-1" disabled={createMutation.isPending}>
+              {t('common.cancel')}
+            </Button>
+            <Button
+              variant="primary"
+              onClick={() => createMutation.mutate()}
+              className="flex-1"
+              disabled={createMutation.isPending}
+            >
+              {createMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                t('virtualPrinter.addDialog.create')
+              )}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 459 - 0
frontend/src/components/VirtualPrinterCard.tsx

@@ -0,0 +1,459 @@
+import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import {
+  Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
+  ChevronDown, ChevronRight, ArrowRightLeft, Trash2,
+} from 'lucide-react';
+import { api, multiVirtualPrinterApi } from '../api/client';
+import type { VirtualPrinterConfig } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+
+type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+
+const MODE_LABELS: Record<string, string> = {
+  immediate: 'archive',
+  review: 'review',
+  print_queue: 'queue',
+  proxy: 'proxy',
+};
+
+interface VirtualPrinterCardProps {
+  printer: VirtualPrinterConfig;
+  models: Record<string, string>;
+}
+
+export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [expanded, setExpanded] = useState(false);
+  const [localEnabled, setLocalEnabled] = useState(printer.enabled);
+  const [localName, setLocalName] = useState(printer.name);
+  const [localAccessCode, setLocalAccessCode] = useState('');
+  const [localMode, setLocalMode] = useState<LocalMode>(
+    (printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode
+  );
+  const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(printer.target_printer_id);
+  const [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');
+  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
+  const [localModel, setLocalModel] = useState(printer.model || '');
+  const [showAccessCode, setShowAccessCode] = useState(false);
+  const [pendingAction, setPendingAction] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  // Sync local state when props change (e.g., after backend auto-disable)
+  useEffect(() => {
+    if (!pendingAction) {
+      setLocalEnabled(printer.enabled);
+      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
+      setLocalName(printer.name);
+      setLocalTargetPrinterId(printer.target_printer_id);
+      setLocalBindIp(printer.bind_ip || '');
+      setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
+      setLocalModel(printer.model || '');
+    }
+  }, [printer, pendingAction]);
+
+  // Fetch printers for dropdown
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Fetch network interfaces
+  const { data: networkInterfaces } = useQuery({
+    queryKey: ['network-interfaces'],
+    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: (data: Parameters<typeof multiVirtualPrinterApi.update>[1]) =>
+      multiVirtualPrinterApi.update(printer.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
+      showToast(t('virtualPrinter.toast.updated'));
+      setPendingAction(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
+      setLocalEnabled(printer.enabled);
+      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
+      setLocalTargetPrinterId(printer.target_printer_id);
+      setLocalBindIp(printer.bind_ip || '');
+      setPendingAction(null);
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: () => multiVirtualPrinterApi.remove(printer.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
+      showToast(t('virtualPrinter.toast.deleted'));
+      setShowDeleteConfirm(false);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('virtualPrinter.toast.failedToDelete'), 'error');
+      setShowDeleteConfirm(false);
+    },
+  });
+
+  const handleToggleEnabled = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    const newEnabled = !localEnabled;
+    if (newEnabled) {
+      if (!localBindIp) {
+        showToast(t('virtualPrinter.toast.bindIpRequired'), 'error');
+        return;
+      }
+      if (localMode === 'proxy') {
+        if (!localTargetPrinterId) {
+          showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');
+          return;
+        }
+      } else {
+        if (!localAccessCode && !printer.access_code_set) {
+          showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');
+          return;
+        }
+      }
+    }
+    setLocalEnabled(newEnabled);
+    setPendingAction('toggle');
+    updateMutation.mutate({ enabled: newEnabled });
+  };
+
+  const handleNameChange = () => {
+    if (!localName.trim()) return;
+    setPendingAction('name');
+    updateMutation.mutate({ name: localName.trim() });
+  };
+
+  const handleAccessCodeChange = () => {
+    if (!localAccessCode) {
+      showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');
+      return;
+    }
+    if (localAccessCode.length !== 8) {
+      showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');
+      return;
+    }
+    setPendingAction('accessCode');
+    updateMutation.mutate({ access_code: localAccessCode });
+    setLocalAccessCode('');
+  };
+
+  const handleModeChange = (mode: LocalMode) => {
+    setLocalMode(mode);
+    setPendingAction('mode');
+    updateMutation.mutate({ mode });
+  };
+
+  const handleModelChange = (model: string) => {
+    setLocalModel(model);
+    setPendingAction('model');
+    updateMutation.mutate({ model });
+  };
+
+  const handleTargetPrinterChange = (printerId: number) => {
+    setLocalTargetPrinterId(printerId);
+    setPendingAction('targetPrinter');
+    updateMutation.mutate({ target_printer_id: printerId });
+  };
+
+  const handleRemoteInterfaceChange = (ip: string) => {
+    setLocalRemoteInterfaceIp(ip);
+    setPendingAction('remoteInterface');
+    updateMutation.mutate({ remote_interface_ip: ip });
+  };
+
+  const isRunning = printer.status?.running || false;
+  const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'archive'}`);
+  const targetPrinterName = printers?.find(p => p.id === localTargetPrinterId)?.name;
+
+  return (
+    <>
+      <Card>
+        {/* Collapsed header - always visible, clickable to expand */}
+        <div
+          className="px-4 py-3 flex items-center gap-3 cursor-pointer select-none"
+          onClick={() => setExpanded(!expanded)}
+        >
+          <button className="text-bambu-gray flex-shrink-0">
+            {expanded
+              ? <ChevronDown className="w-4 h-4" />
+              : <ChevronRight className="w-4 h-4" />
+            }
+          </button>
+          <span className={`w-2 h-2 rounded-full flex-shrink-0 ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
+          <span className="text-white font-medium truncate">{printer.name}</span>
+          <span className="text-xs text-bambu-gray flex-shrink-0">{modeLabel}</span>
+          {printer.model_name && (
+            <span className="text-xs text-bambu-gray flex-shrink-0">{printer.model_name}</span>
+          )}
+          {targetPrinterName && (
+            <span className="text-xs text-bambu-gray flex-shrink-0 truncate">
+              {localMode === 'proxy' && <ArrowRightLeft className="w-3 h-3 inline mr-1" />}
+              {targetPrinterName}
+            </span>
+          )}
+          {localBindIp && (
+            <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localBindIp}</span>
+          )}
+          {localRemoteInterfaceIp && (
+            <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
+          )}
+          <div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
+            <button
+              onClick={handleToggleEnabled}
+              disabled={pendingAction === 'toggle'}
+              className={`relative w-10 h-5 rounded-full transition-colors ${
+                localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              } ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}
+            >
+              <span
+                className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                  localEnabled ? 'translate-x-5' : ''
+                }`}
+              />
+            </button>
+          </div>
+        </div>
+
+        {/* Expanded content */}
+        {expanded && (
+          <CardContent className="pt-0 space-y-4">
+            <div className="border-t border-bambu-dark-tertiary" />
+
+            {/* Name + delete */}
+            <div className="flex items-center gap-2">
+              <input
+                type="text"
+                value={localName}
+                onChange={(e) => setLocalName(e.target.value)}
+                onBlur={handleNameChange}
+                onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}
+                className="flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none"
+              />
+              <span className="text-xs text-bambu-gray font-mono">{printer.serial}</span>
+              <button
+                onClick={() => setShowDeleteConfirm(true)}
+                className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors"
+                title={t('common.delete')}
+              >
+                <Trash2 className="w-4 h-4" />
+              </button>
+            </div>
+
+            {/* Mode */}
+            <div>
+              <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
+              <div className="grid grid-cols-2 gap-2">
+                {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((mode) => (
+                  <button
+                    key={mode}
+                    onClick={() => handleModeChange(mode)}
+                    disabled={pendingAction === 'mode'}
+                    className={`p-2 rounded-lg border text-left transition-colors ${
+                      localMode === mode
+                        ? mode === 'proxy'
+                          ? 'border-blue-500 bg-blue-500/10'
+                          : 'border-bambu-green bg-bambu-green/10'
+                        : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                    }`}
+                  >
+                    <div className="flex items-center gap-1.5 text-white text-xs font-medium">
+                      {mode === 'proxy' && <ArrowRightLeft className="w-3 h-3" />}
+                      {t(`virtualPrinter.mode.${MODE_LABELS[mode]}`)}
+                    </div>
+                    <div className="text-[10px] text-bambu-gray">
+                      {t(`virtualPrinter.mode.${MODE_LABELS[mode]}Desc`)}
+                    </div>
+                  </button>
+                ))}
+              </div>
+            </div>
+
+            {/* Printer Model - for non-proxy modes */}
+            {localMode !== 'proxy' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.model.title')}</div>
+                <p className="text-xs text-bambu-gray mb-2">{t('virtualPrinter.model.description')}</p>
+                <div className="relative">
+                  <select
+                    value={localModel}
+                    onChange={(e) => handleModelChange(e.target.value)}
+                    disabled={pendingAction === 'model'}
+                    className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
+                  >
+                    {Object.entries(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>
+              </div>
+            )}
+
+            {/* Proxy mode: hint about using target printer's access code */}
+            {localMode === 'proxy' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-start gap-2 p-2 rounded bg-blue-500/10 border border-blue-500/30">
+                  <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+                  <p className="text-xs text-bambu-gray">
+                    {t('virtualPrinter.proxy.accessCodeHint')}
+                  </p>
+                </div>
+              </div>
+            )}
+
+            {/* Access Code - only for non-proxy modes */}
+            {localMode !== 'proxy' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center gap-2 mb-2">
+                  <div className="text-white text-sm font-medium">{t('virtualPrinter.accessCode.title')}</div>
+                  {printer.access_code_set ? (
+                    <span className="flex items-center gap-1 text-xs text-green-400">
+                      <Check className="w-3 h-3" />
+                      {t('virtualPrinter.accessCode.isSet')}
+                    </span>
+                  ) : (
+                    <span className="flex items-center gap-1 text-xs text-yellow-400">
+                      <AlertTriangle className="w-3 h-3" />
+                      {t('virtualPrinter.accessCode.notSet')}
+                    </span>
+                  )}
+                </div>
+                <div className="flex gap-2">
+                  <div className="relative flex-1">
+                    <input
+                      type={showAccessCode ? 'text' : 'password'}
+                      value={localAccessCode}
+                      onChange={(e) => setLocalAccessCode(e.target.value)}
+                      placeholder={printer.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
+                      maxLength={8}
+                      className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm placeholder-bambu-gray pr-10 font-mono"
+                    />
+                    <button
+                      onClick={() => setShowAccessCode(!showAccessCode)}
+                      className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                    >
+                      {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+                    </button>
+                  </div>
+                  <Button
+                    onClick={handleAccessCodeChange}
+                    disabled={!localAccessCode || pendingAction === 'accessCode'}
+                    variant="primary"
+                  >
+                    {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
+                  </Button>
+                </div>
+                {localAccessCode && (
+                  <p className="text-xs text-bambu-gray mt-1">
+                    <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
+                      {t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
+                    </span>
+                  </p>
+                )}
+              </div>
+            )}
+
+            {/* Target Printer */}
+            <div className="pt-2 border-t border-bambu-dark-tertiary">
+              <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.targetPrinter.title')}</div>
+              <div className="relative">
+                <select
+                  value={localTargetPrinterId ?? ''}
+                  onChange={(e) => {
+                    const id = parseInt(e.target.value, 10);
+                    if (!isNaN(id)) handleTargetPrinterChange(id);
+                  }}
+                  disabled={pendingAction === 'targetPrinter'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
+                >
+                  <option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
+                  {printers?.map((p) => (
+                    <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</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>
+            </div>
+
+            {/* Bind Interface */}
+            <div className="pt-2 border-t border-bambu-dark-tertiary">
+              <div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.bindIp.title')}</div>
+              <div className="relative">
+                <select
+                  value={localBindIp}
+                  onChange={(e) => {
+                    setLocalBindIp(e.target.value);
+                    setPendingAction('bindIp');
+                    updateMutation.mutate({ bind_ip: e.target.value });
+                  }}
+                  disabled={pendingAction === 'bindIp'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
+                >
+                  <option value="">{t('virtualPrinter.bindIp.placeholder')}</option>
+                  {networkInterfaces?.map((iface) => (
+                    <option key={iface.ip} value={iface.ip}>
+                      {iface.name} ({iface.ip}){iface.is_alias ? ' [alias]' : ''} - {iface.subnet}
+                    </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>
+              <p className="text-xs text-bambu-gray mt-1">{t('virtualPrinter.bindIp.hint')}</p>
+            </div>
+
+            {/* Remote Interface - always visible for configuration */}
+            <div className="pt-2 border-t border-bambu-dark-tertiary">
+              <div className="flex items-center gap-2 mb-1">
+                <div className="text-white text-sm font-medium">{t('virtualPrinter.remoteInterface.title')}</div>
+                {localRemoteInterfaceIp ? (
+                  <span className="flex items-center gap-1 text-xs text-green-400"><Check className="w-3 h-3" /></span>
+                ) : (
+                  <span className="flex items-center gap-1 text-xs text-bambu-gray"><Info className="w-3 h-3" /></span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localRemoteInterfaceIp}
+                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
+                  disabled={pendingAction === 'remoteInterface'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
+                >
+                  <option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
+                  {networkInterfaces?.map((iface) => (
+                    <option key={iface.ip} value={iface.ip}>
+                      {iface.name} ({iface.ip}) - {iface.subnet}
+                    </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>
+            </div>
+          </CardContent>
+        )}
+      </Card>
+
+      {showDeleteConfirm && (
+        <ConfirmModal
+          title={t('virtualPrinter.deleteConfirm.title')}
+          message={t('virtualPrinter.deleteConfirm.message', { name: printer.name })}
+          variant="danger"
+          confirmText={t('common.delete')}
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate()}
+          onCancel={() => setShowDeleteConfirm(false)}
+        />
+      )}
+
+    </>
+  );
+}

+ 114 - 0
frontend/src/components/VirtualPrinterList.tsx

@@ -0,0 +1,114 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info } from 'lucide-react';
+import { multiVirtualPrinterApi } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { VirtualPrinterCard } from './VirtualPrinterCard';
+import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
+
+export function VirtualPrinterList() {
+  const { t } = useTranslation();
+  const [showAddDialog, setShowAddDialog] = useState(false);
+
+  const { data, isLoading } = useQuery({
+    queryKey: ['virtual-printers'],
+    queryFn: multiVirtualPrinterApi.list,
+    refetchInterval: 10000,
+  });
+
+  if (isLoading) {
+    return (
+      <Card>
+        <CardContent className="py-8 flex justify-center">
+          <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  const printers = data?.printers || [];
+  const models = data?.models || {};
+
+  return (
+    <div className="space-y-4">
+      {/* Top row - Setup Required (25%) + How it works (75%) */}
+      <div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-start">
+        <Card className="border-l-4 border-l-yellow-500">
+          <CardContent className="py-3 px-4">
+            <div className="flex items-start gap-2">
+              <AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
+              <div className="text-xs">
+                <p className="text-white font-medium">{t('virtualPrinter.setupRequired.title')}</p>
+                <p className="text-bambu-gray mt-1">{t('virtualPrinter.setupRequired.description')}</p>
+                <a
+                  href="https://wiki.bambuddy.cool/features/virtual-printer/"
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/50 rounded text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs"
+                >
+                  <ExternalLink className="w-3 h-3" />
+                  {t('virtualPrinter.setupRequired.readGuide')}
+                </a>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card className="lg:col-span-3">
+          <CardContent className="py-3 px-4">
+            <div className="flex items-start gap-2">
+              <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+              <div className="text-xs text-bambu-gray">
+                <p className="text-white font-medium mb-1">{t('virtualPrinter.howItWorks.title')}</p>
+                <ul className="space-y-1 list-disc list-inside">
+                  <li>{t('virtualPrinter.howItWorks.step1')}</li>
+                  <li>{t('virtualPrinter.howItWorks.step2')}</li>
+                  <li>{t('virtualPrinter.howItWorks.step3')}</li>
+                </ul>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Header with add button */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <Printer className="w-5 h-5 text-bambu-green" />
+          <h2 className="text-lg font-semibold text-white">{t('virtualPrinter.list.title')}</h2>
+          <span className="text-sm text-bambu-gray">({printers.length})</span>
+        </div>
+        <Button variant="primary" onClick={() => setShowAddDialog(true)}>
+          <Plus className="w-4 h-4 mr-1" />
+          {t('virtualPrinter.list.add')}
+        </Button>
+      </div>
+
+      {/* Printer cards - 3 column grid */}
+      {printers.length === 0 ? (
+        <Card>
+          <CardContent className="py-8 text-center">
+            <Printer className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
+            <p className="text-bambu-gray mb-4">{t('virtualPrinter.list.empty')}</p>
+            <Button variant="primary" onClick={() => setShowAddDialog(true)}>
+              <Plus className="w-4 h-4 mr-1" />
+              {t('virtualPrinter.list.addFirst')}
+            </Button>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-start">
+          {printers.map((printer) => (
+            <VirtualPrinterCard key={printer.id} printer={printer} models={models} />
+          ))}
+        </div>
+      )}
+
+      {showAddDialog && (
+        <VirtualPrinterAddDialog onClose={() => setShowAddDialog(false)} />
+      )}
+    </div>
+  );
+}

+ 32 - 13
frontend/src/i18n/locales/de.ts

@@ -2990,19 +2990,9 @@ export default {
     },
     howItWorks: {
       title: 'So funktioniert es',
-      titleProxy: 'So funktioniert es (Proxy-Modus)',
-      step1: 'Schließe die Einrichtungsanleitung für deine Plattform ab',
-      step2: 'Aktiviere den virtuellen Drucker und setze einen Zugangscode',
-      step3: 'In Bambu Studio oder OrcaSlicer gehe zu "Drucker hinzufügen"',
-      step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
-      step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
-      step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
-      proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
-      proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
-      proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
-      proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
-      proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
-      proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
+      step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',
+      step2: 'Im Archiv-, Überprüfungs- und Warteschlangen-Modus verwende die "Senden"-Funktion im Slicer, um 3MF-Dateien an Bambuddy zu senden. Der Slicer zeigt "Druck erfolgreich" — die Datei wird gespeichert, nicht gedruckt.',
+      step3: 'Im Proxy-Modus leitet der virtuelle Drucker den gesamten Datenverkehr an einen echten Drucker weiter — Drucke starten sofort wie bei einer direkten Verbindung.',
     },
     status: {
       title: 'Status-Details',
@@ -3022,8 +3012,37 @@ export default {
       failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',
       accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
       targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',
+      bindIpRequired: 'Bitte zuerst eine Bind-IP setzen',
       accessCodeEmpty: 'Zugangscode darf nicht leer sein',
       accessCodeLength: 'Zugangscode muss genau 8 Zeichen lang sein',
+      created: 'Virtueller Drucker erstellt',
+      failedToCreate: 'Virtueller Drucker konnte nicht erstellt werden',
+      deleted: 'Virtueller Drucker gelöscht',
+      failedToDelete: 'Virtueller Drucker konnte nicht gelöscht werden',
+    },
+    list: {
+      title: 'Virtuelle Drucker',
+      add: 'Hinzufügen',
+      addFirst: 'Virtuellen Drucker hinzufügen',
+      empty: 'Keine virtuellen Drucker konfiguriert. Fügen Sie einen hinzu, um zu beginnen.',
+    },
+    bindIp: {
+      title: 'Bind-Interface',
+      placeholder: 'Interface auswählen...',
+      hint: 'Netzwerkinterface, an das dieser virtuelle Drucker gebunden wird. Muss pro Drucker eindeutig sein.',
+    },
+    proxy: {
+      accessCodeHint: 'Im Proxy-Modus den Zugangscode des Zieldruckers im Slicer verwenden. Die Verbindung wird transparent zum echten Drucker weitergeleitet.',
+    },
+    addDialog: {
+      title: 'Virtuellen Drucker hinzufügen',
+      name: 'Name',
+      hint: 'Sie können Zugangscode, Zieldrucker und andere Einstellungen nach dem Erstellen konfigurieren.',
+      create: 'Erstellen',
+    },
+    deleteConfirm: {
+      title: 'Virtuellen Drucker löschen',
+      message: 'Möchten Sie "{{name}}" wirklich löschen? Dies stoppt alle Dienste für diesen Drucker.',
     },
   },
 

+ 32 - 13
frontend/src/i18n/locales/en.ts

@@ -2995,19 +2995,9 @@ export default {
     },
     howItWorks: {
       title: 'How it works',
-      titleProxy: 'How it works (Proxy Mode)',
-      step1: 'Complete the setup guide for your platform',
-      step2: 'Enable the virtual printer and set an access code',
-      step3: 'In Bambu Studio or OrcaSlicer, go to "Add Printer"',
-      step4: 'The "Bambuddy" printer should appear in the discovery list',
-      step5: 'Connect using the access code you set',
-      step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
-      proxyStep1: 'Select the target printer (must be in LAN mode)',
-      proxyStep2: 'For cross-network: select the slicer network interface',
-      proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
-      proxyStep4: 'Connect using the printer\'s access code',
-      proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
-      proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
+      step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',
+      step2: 'In Archive, Review, and Queue modes, use the "Send" button in your slicer to upload 3MF files to Bambuddy. The slicer will show "Print success" — the file is stored, not printed.',
+      step3: 'In Proxy mode, the virtual printer relays all traffic to a real printer — prints start immediately as if connected directly.',
     },
     status: {
       title: 'Status Details',
@@ -3027,8 +3017,37 @@ export default {
       failedToUpdate: 'Failed to update settings',
       accessCodeRequired: 'Please set an access code first',
       targetPrinterRequired: 'Please select a target printer first',
+      bindIpRequired: 'Please set a bind IP first',
       accessCodeEmpty: 'Access code cannot be empty',
       accessCodeLength: 'Access code must be exactly 8 characters',
+      created: 'Virtual printer created',
+      failedToCreate: 'Failed to create virtual printer',
+      deleted: 'Virtual printer deleted',
+      failedToDelete: 'Failed to delete virtual printer',
+    },
+    list: {
+      title: 'Virtual Printers',
+      add: 'Add',
+      addFirst: 'Add Virtual Printer',
+      empty: 'No virtual printers configured. Add one to get started.',
+    },
+    bindIp: {
+      title: 'Bind Interface',
+      placeholder: 'Select interface...',
+      hint: 'Network interface for this virtual printer to bind to. Must be unique per printer.',
+    },
+    proxy: {
+      accessCodeHint: 'In proxy mode, use your target printer\'s access code in the slicer. The connection is forwarded transparently to the real printer.',
+    },
+    addDialog: {
+      title: 'Add Virtual Printer',
+      name: 'Name',
+      hint: 'You can configure access code, target printer, and other settings after creating.',
+      create: 'Create',
+    },
+    deleteConfirm: {
+      title: 'Delete Virtual Printer',
+      message: 'Are you sure you want to delete "{{name}}"? This will stop all services for this printer.',
     },
   },
 

+ 11 - 13
frontend/src/i18n/locales/fr.ts

@@ -2965,6 +2965,14 @@ export default {
       placeholder: 'Auto (défaut)...',
       hint: 'Force l\'IP annoncée via SSDP.',
     },
+    bindIp: {
+      title: 'Interface réseau',
+      placeholder: 'Sélectionner interface...',
+      hint: 'Interface réseau sur laquelle cette imprimante virtuelle écoute. Doit être unique par imprimante.',
+    },
+    proxy: {
+      accessCodeHint: 'En mode proxy, utilisez le code d\'accès de l\'imprimante cible dans le slicer. La connexion est transmise de manière transparente à l\'imprimante réelle.',
+    },
     mode: {
       title: 'Mode',
       archive: 'Archiver',
@@ -2983,19 +2991,9 @@ export default {
     },
     howItWorks: {
       title: 'Fonctionnement',
-      titleProxy: 'Fonctionnement (Mode Proxy)',
-      step1: 'Suivez le guide pour votre plateforme',
-      step2: 'Activez et réglez le code d\'accès',
-      step3: 'Dans le Slicer, allez dans "Ajouter Imprimante"',
-      step4: '"Bambuddy" apparaîtra dans la découverte',
-      step5: 'Connectez avec votre code d\'accès',
-      step6: 'Imprimez vers Bambuddy : le 3MF est archivé',
-      proxyStep1: 'Cible réelle en mode LAN',
-      proxyStep2: 'Choisissez l\'interface réseau',
-      proxyStep3: 'Activez le proxy',
-      proxyStep4: 'Connectez avec le code de la vraie imprimante',
-      proxyStep5: 'Le trafic est relayé par Bambuddy',
-      proxyStep6: 'Streaming caméra : voir doc NAT/IP forwarding',
+      step1: 'Sur le même LAN, les imprimantes virtuelles apparaissent automatiquement dans votre slicer (Bambu Studio / OrcaSlicer). Depuis d\'autres réseaux, ajoutez-les manuellement par adresse IP et code d\'accès.',
+      step2: 'En mode Archive, Revue et File d\'attente, utilisez le bouton "Envoyer" dans votre slicer pour envoyer des fichiers 3MF à Bambuddy. Le slicer affichera "Impression réussie" — le fichier est stocké, pas imprimé.',
+      step3: 'En mode Proxy, l\'imprimante virtuelle relaie tout le trafic vers une vraie imprimante — les impressions démarrent immédiatement comme en connexion directe.',
     },
     status: {
       title: 'Détails du statut',

+ 11 - 13
frontend/src/i18n/locales/it.ts

@@ -2686,6 +2686,14 @@ export default {
       placeholder: 'Rilevamento automatico (predefinito)...',
       hint: 'Sovrascrive l\'indirizzo IP pubblicizzato via SSDP e usato nel certificato TLS. Utile quando Bambuddy ha piu interfacce di rete.',
     },
+    bindIp: {
+      title: 'Interfaccia di rete',
+      placeholder: 'Seleziona interfaccia...',
+      hint: 'Interfaccia di rete a cui questa stampante virtuale si collega. Deve essere unica per stampante.',
+    },
+    proxy: {
+      accessCodeHint: 'In modalita proxy, usa il codice di accesso della stampante di destinazione nello slicer. La connessione viene inoltrata in modo trasparente alla stampante reale.',
+    },
     mode: {
       title: 'Modalita',
       archive: 'Archivio',
@@ -2704,19 +2712,9 @@ export default {
     },
     howItWorks: {
       title: 'Come funziona',
-      titleProxy: 'Come funziona (Modalita proxy)',
-      step1: 'Completa la guida di configurazione per la tua piattaforma',
-      step2: 'Abilita la stampante virtuale e imposta un codice accesso',
-      step3: 'In Bambu Studio o OrcaSlicer, vai su "Aggiungi stampante"',
-      step4: 'La stampante "Bambuddy" dovrebbe apparire nella lista',
-      step5: 'Connettiti usando il codice accesso impostato',
-      step6: 'Quando "stampi" su Bambuddy, il file 3MF viene archiviato',
-      proxyStep1: 'Seleziona la stampante target (deve essere in modalita LAN)',
-      proxyStep2: 'Per rete diversa: seleziona l\'interfaccia rete slicer',
-      proxyStep3: 'Abilita il proxy - la stampante appare via SSDP',
-      proxyStep4: 'Connettiti usando il codice accesso della stampante',
-      proxyStep5: 'Stampa normalmente - il traffico è inoltrato via Bambuddy',
-      proxyStep6: 'Lo streaming della camera richiede NAT/IP forwarding (vedi docs)',
+      step1: 'Sulla stessa LAN, le stampanti virtuali appaiono automaticamente nel tuo slicer (Bambu Studio / OrcaSlicer). Da altre reti, aggiungile manualmente tramite indirizzo IP e codice di accesso.',
+      step2: 'In modalità Archivio, Revisione e Coda, usa il pulsante "Invia" nel tuo slicer per caricare file 3MF su Bambuddy. Lo slicer mostrerà "Stampa riuscita" — il file viene salvato, non stampato.',
+      step3: 'In modalità Proxy, la stampante virtuale inoltra tutto il traffico a una stampante reale — le stampe partono immediatamente come con una connessione diretta.',
     },
     status: {
       title: 'Dettagli stato',

+ 32 - 13
frontend/src/i18n/locales/ja.ts

@@ -2829,20 +2829,10 @@ export default {
       hint: 'SSDPで広告され、TLS証明書に使用されるIPアドレスを上書きします。Bambuddyに複数のネットワークインターフェースがある場合に便利です。',
     },
     howItWorks: {
-      step5: '設定したアクセスコードで接続する',
-      step6: 'Bambuddyに「印刷」すると、3MFファイルがアーカイブされる',
-      proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
-      proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
-      proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
-      proxyStep4: 'プリンターのアクセスコードで接続',
-      proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
-      proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
       title: '仕組み',
-      titleProxy: '仕組み(プロキシモード)',
-      step1: 'プラットフォーム用のセットアップガイドを完了',
-      step2: '仮想プリンターを有効にしてアクセスコードを設定',
-      step3: 'Bambu StudioまたはOrcaSlicerで「プリンター追加」へ',
-      step4: '「Bambuddy」プリンターが検出リストに表示されます',
+      step1: '同じLAN上では、仮想プリンターはスライサー(Bambu Studio / OrcaSlicer)に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。',
+      step2: 'アーカイブ、レビュー、キューモードでは、スライサーの「送信」ボタンを使用して3MFファイルをBambuddyにアップロードします。スライサーは「印刷成功」と表示しますが、ファイルは保存され、印刷はされません。',
+      step3: 'プロキシモードでは、仮想プリンターはすべてのトラフィックを実際のプリンターに転送します。直接接続されているかのように印刷がすぐに開始されます。',
     },
     status: {
       mode: 'モード',
@@ -2860,10 +2850,39 @@ export default {
     toast: {
       accessCodeRequired: '先にアクセスコードを設定してください',
       targetPrinterRequired: '先にターゲットプリンターを選択してください',
+      bindIpRequired: '先にバインドIPを設定してください',
       accessCodeEmpty: 'アクセスコードは空にできません',
       accessCodeLength: 'アクセスコードは8文字である必要があります',
       updated: '仮想プリンター設定を更新しました',
       failedToUpdate: '設定の更新に失敗しました',
+      created: '仮想プリンターを作成しました',
+      failedToCreate: '仮想プリンターの作成に失敗しました',
+      deleted: '仮想プリンターを削除しました',
+      failedToDelete: '仮想プリンターの削除に失敗しました',
+    },
+    list: {
+      title: '仮想プリンター',
+      add: '追加',
+      addFirst: '仮想プリンターを追加',
+      empty: '仮想プリンターが設定されていません。追加して始めましょう。',
+    },
+    bindIp: {
+      title: 'バインドインターフェース',
+      placeholder: 'インターフェースを選択...',
+      hint: 'この仮想プリンターがバインドするネットワークインターフェース。プリンターごとに一意である必要があります。',
+    },
+    proxy: {
+      accessCodeHint: 'プロキシモードでは、スライサーにターゲットプリンターのアクセスコードを使用してください。接続は実際のプリンターに透過的に転送されます。',
+    },
+    addDialog: {
+      title: '仮想プリンターを追加',
+      name: '名前',
+      hint: 'アクセスコード、ターゲットプリンター、その他の設定は作成後に設定できます。',
+      create: '作成',
+    },
+    deleteConfirm: {
+      title: '仮想プリンターを削除',
+      message: '「{{name}}」を削除してもよろしいですか?このプリンターのすべてのサービスが停止されます。',
     },
     title: '仮想プリンター',
     stopped: '停止',

+ 2 - 2
frontend/src/pages/SettingsPage.tsx

@@ -21,7 +21,7 @@ import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
 import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
-import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
+import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { APIBrowser } from '../components/APIBrowser';
@@ -3197,7 +3197,7 @@ export function SettingsPage() {
 
       {/* Virtual Printer Tab */}
       {activeTab === 'virtual-printer' && (
-        <VirtualPrinterSettings />
+        <VirtualPrinterList />
       )}
 
       {/* Filament Tab */}

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


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


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


+ 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-P5MHD-Cf.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-EqFdfChN.css">
+    <script type="module" crossorigin src="/assets/index-ADBQB8en.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DPf6CLKV.css">
   </head>
   <body>
     <div id="root"></div>

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