Просмотр исходного кода

- Add Virtual Printer feature - emulate Bambu printer on network
- Virtual printer appears in Bambu Studio/Orca Slicer via SSDP discovery
- Secure TLS/MQTT communication with auto-generated certificates
- Queue mode (pending uploads) or auto-start mode
- Configurable access code for authentication
- Docker support with network_mode: host and certificate persistence
- Fix backup/restore for virtual printer settings (auto-save no longer overwrites)

maziggy 4 месяцев назад
Родитель
Сommit
88e84ca45a
31 измененных файлов с 4648 добавлено и 19 удалено
  1. 3 0
      .gitignore
  2. 14 0
      CHANGELOG.md
  3. 7 0
      README.md
  4. 249 0
      backend/app/api/routes/pending_uploads.py
  5. 230 0
      backend/app/api/routes/settings.py
  6. 31 0
      backend/app/main.py
  7. 9 7
      backend/app/models/__init__.py
  8. 47 0
      backend/app/models/pending_upload.py
  9. 24 5
      backend/app/schemas/settings.py
  10. 5 0
      backend/app/services/virtual_printer/__init__.py
  11. 254 0
      backend/app/services/virtual_printer/certificate.py
  12. 511 0
      backend/app/services/virtual_printer/ftp_server.py
  13. 323 0
      backend/app/services/virtual_printer/manager.py
  14. 799 0
      backend/app/services/virtual_printer/mqtt_server.py
  15. 270 0
      backend/app/services/virtual_printer/ssdp_server.py
  16. 252 0
      backend/tests/integration/test_virtual_printer_api.py
  17. 390 0
      backend/tests/unit/services/test_virtual_printer.py
  18. 2 0
      docker-compose.yml
  19. 408 0
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  20. 74 0
      frontend/src/api/client.ts
  21. 9 1
      frontend/src/components/BackupModal.tsx
  22. 361 0
      frontend/src/components/PendingUploadsPanel.tsx
  23. 4 0
      frontend/src/components/RestoreModal.tsx
  24. 307 0
      frontend/src/components/VirtualPrinterSettings.tsx
  25. 4 0
      frontend/src/pages/ArchivesPage.tsx
  26. 55 4
      frontend/src/pages/SettingsPage.tsx
  27. 4 0
      requirements.txt
  28. 0 0
      static/assets/index-BOEy1ke3.css
  29. 0 0
      static/assets/index-CYUCozUo.js
  30. 0 0
      static/assets/index-DUGbV7GF.css
  31. 2 2
      static/index.html

+ 3 - 0
.gitignore

@@ -31,6 +31,9 @@ npm-debug.log*
 # Archive files (user data)
 # Archive files (user data)
 archive/
 archive/
 
 
+# Virtual printer (auto-generated certs and uploads)
+virtual_printer/
+
 # IDE
 # IDE
 .idea/
 .idea/
 .vscode/
 .vscode/

+ 14 - 0
CHANGELOG.md

@@ -2,6 +2,20 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.6b2] - 2025-12-29
+
+### Added
+- **Virtual Printer** - Bambuddy now emulates a Bambu Lab printer on your network! Send prints directly from Bambu Studio or Orca Slicer without needing a physical printer connection. Features include:
+  - SSDP discovery (printer appears automatically in slicer)
+  - Secure TLS/MQTT communication with auto-generated certificates
+  - Queue mode (prints go to pending uploads) or auto-start mode
+  - Configurable access code for authentication
+  - Works with Docker (requires `network_mode: host`)
+  - Persistent certificates across container rebuilds via volume mount
+
+### Fixed
+- **Backup/restore for virtual printer settings** - Virtual printer settings (enabled, access code, mode) now correctly persist after restore without being overwritten by auto-save
+
 ## [0.1.6b] - 2025-12-28
 ## [0.1.6b] - 2025-12-28
 
 
 ### Added
 ### Added

+ 7 - 0
README.md

@@ -92,6 +92,13 @@
 - External sidebar links
 - External sidebar links
 - Webhooks & API keys
 - Webhooks & API keys
 
 
+### 🖨️ Virtual Printer
+- Emulates a Bambu Lab printer on your network
+- Send prints directly from Bambu Studio/Orca Slicer
+- Queue mode or auto-start mode
+- SSDP discovery (appears in slicer automatically)
+- Secure TLS/MQTT communication
+
 ### 🛠️ Maintenance
 ### 🛠️ Maintenance
 - Maintenance scheduling & tracking
 - Maintenance scheduling & tracking
 - Interval reminders (hours/days)
 - Interval reminders (hours/days)

+ 249 - 0
backend/app/api/routes/pending_uploads.py

@@ -0,0 +1,249 @@
+"""API routes for pending uploads (virtual printer queue mode)."""
+
+from datetime import UTC, datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.pending_upload import PendingUpload
+from backend.app.services.archive import ArchiveService
+
+router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
+
+
+class ArchiveRequest(BaseModel):
+    """Request to archive a pending upload."""
+
+    tags: str | None = None
+    notes: str | None = None
+    project_id: int | None = None
+
+
+class PendingUploadResponse(BaseModel):
+    """Response model for pending upload."""
+
+    id: int
+    filename: str
+    file_size: int
+    source_ip: str | None
+    status: str
+    tags: str | None
+    notes: str | None
+    project_id: int | None
+    uploaded_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+@router.get("/", response_model=list[PendingUploadResponse])
+async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
+    """List all pending uploads."""
+    result = await db.execute(
+        select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
+    )
+
+    return result.scalars().all()
+
+
+@router.get("/count")
+async def get_pending_count(db: AsyncSession = Depends(get_db)):
+    """Get count of pending uploads."""
+    result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
+    count = len(result.scalars().all())
+
+    return {"count": count}
+
+
+# Note: Bulk operations must be defined BEFORE parameterized routes
+# to prevent FastAPI from matching /archive-all as /{upload_id}
+
+
+@router.post("/archive-all")
+async def archive_all_pending(db: AsyncSession = Depends(get_db)):
+    """Archive all pending uploads."""
+    result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
+    pending_uploads = result.scalars().all()
+
+    archived = 0
+    failed = 0
+
+    service = ArchiveService(db)
+
+    for pending in pending_uploads:
+        file_path = Path(pending.file_path)
+        if not file_path.exists():
+            pending.status = "discarded"
+            failed += 1
+            continue
+
+        try:
+            archive = await service.archive_print(
+                printer_id=None,
+                source_file=file_path,
+                print_data={
+                    "status": "archived",
+                    "source": "virtual_printer",
+                    "source_ip": pending.source_ip,
+                },
+            )
+
+            if archive:
+                pending.status = "archived"
+                pending.archived_id = archive.id
+                pending.archived_at = datetime.now(UTC)
+                archived += 1
+
+                # Clean up temp file
+                try:
+                    file_path.unlink()
+                except Exception:
+                    pass
+            else:
+                failed += 1
+        except Exception:
+            failed += 1
+
+    await db.commit()
+
+    return {
+        "archived": archived,
+        "failed": failed,
+    }
+
+
+@router.delete("/discard-all")
+async def discard_all_pending(db: AsyncSession = Depends(get_db)):
+    """Discard all pending uploads."""
+    result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
+    pending_uploads = result.scalars().all()
+
+    discarded = 0
+
+    for pending in pending_uploads:
+        # Delete file from disk
+        try:
+            file_path = Path(pending.file_path)
+            file_path.unlink(missing_ok=True)
+        except Exception:
+            pass
+
+        pending.status = "discarded"
+        discarded += 1
+
+    await db.commit()
+
+    return {"discarded": discarded}
+
+
+@router.get("/{upload_id}", response_model=PendingUploadResponse)
+async def get_pending_upload(
+    upload_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a specific pending upload."""
+    result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
+    pending = result.scalar_one_or_none()
+
+    if not pending:
+        raise HTTPException(status_code=404, detail="Upload not found")
+
+    return pending
+
+
+@router.post("/{upload_id}/archive")
+async def archive_pending_upload(
+    upload_id: int,
+    request: ArchiveRequest = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Archive a pending upload."""
+    result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
+    pending = result.scalar_one_or_none()
+
+    if not pending:
+        raise HTTPException(status_code=404, detail="Upload not found")
+    if pending.status != "pending":
+        raise HTTPException(status_code=400, detail="Upload already processed")
+
+    # Check file exists
+    file_path = Path(pending.file_path)
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="Upload file not found on disk")
+
+    # Archive the file
+    service = ArchiveService(db)
+    archive = await service.archive_print(
+        printer_id=None,
+        source_file=file_path,
+        print_data={
+            "status": "archived",
+            "source": "virtual_printer",
+            "source_ip": pending.source_ip,
+        },
+    )
+
+    if not archive:
+        raise HTTPException(status_code=500, detail="Failed to archive file")
+
+    # Apply tags/notes/project from request
+    if request:
+        if request.tags:
+            archive.tags = request.tags
+        if request.notes:
+            archive.notes = request.notes
+        if request.project_id:
+            archive.project_id = request.project_id
+
+    # Update pending record
+    pending.status = "archived"
+    pending.archived_id = archive.id
+    pending.archived_at = datetime.now(UTC)
+    if request:
+        pending.tags = request.tags
+        pending.notes = request.notes
+        pending.project_id = request.project_id
+
+    await db.commit()
+
+    # Clean up temp file
+    try:
+        file_path.unlink()
+    except Exception:
+        pass
+
+    return {
+        "id": archive.id,
+        "print_name": archive.print_name,
+        "filename": archive.filename,
+    }
+
+
+@router.delete("/{upload_id}")
+async def discard_pending_upload(
+    upload_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Discard a pending upload without archiving."""
+    result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
+    pending = result.scalar_one_or_none()
+
+    if not pending:
+        raise HTTPException(status_code=404, detail="Upload not found")
+
+    # Delete file from disk
+    file_path = Path(pending.file_path)
+    try:
+        file_path.unlink(missing_ok=True)
+    except Exception:
+        pass
+
+    # Update status
+    pending.status = "discarded"
+    await db.commit()
+
+    return {"success": True}

+ 230 - 0
backend/app/api/routes/settings.py

@@ -17,6 +17,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.maintenance import MaintenanceType
 from backend.app.models.maintenance import MaintenanceType
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.project_bom import ProjectBOMItem
@@ -70,6 +71,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "spoolman_enabled",
                 "spoolman_enabled",
                 "check_updates",
                 "check_updates",
                 "telemetry_enabled",
                 "telemetry_enabled",
+                "virtual_printer_enabled",
             ]:
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
@@ -181,6 +183,7 @@ async def export_backup(
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
     include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_projects: bool = Query(False, description="Include projects with BOM items"),
+    include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
 ):
 ):
     """Export selected data as JSON backup."""
     """Export selected data as JSON backup."""
@@ -510,6 +513,36 @@ async def export_backup(
             backup["projects"].append(project_data)
             backup["projects"].append(project_data)
         backup["included"].append("projects")
         backup["included"].append("projects")
 
 
+    # Pending uploads (virtual printer queue mode)
+    if include_pending_uploads:
+        result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
+        pending_uploads = result.scalars().all()
+        backup["pending_uploads"] = []
+
+        for p in pending_uploads:
+            upload_data = {
+                "filename": p.filename,
+                "file_size": p.file_size,
+                "source_ip": p.source_ip,
+                "status": p.status,
+                "tags": p.tags,
+                "notes": p.notes,
+                "project_id": p.project_id,
+                "uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None,
+            }
+
+            # Include the actual file if it exists
+            if p.file_path:
+                file_path = Path(p.file_path)
+                if file_path.exists():
+                    # Store relative path for ZIP
+                    rel_path = f"pending_uploads/{p.filename}"
+                    upload_data["file_path"] = rel_path
+                    backup_files.append((rel_path, file_path))
+
+            backup["pending_uploads"].append(upload_data)
+        backup["included"].append("pending_uploads")
+
     # If there are files to include (icons or archives), create ZIP file
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
     if backup_files:
         zip_buffer = io.BytesIO()
         zip_buffer = io.BytesIO()
@@ -599,6 +632,7 @@ async def import_backup(
         "filaments": 0,
         "filaments": 0,
         "maintenance_types": 0,
         "maintenance_types": 0,
         "projects": 0,
         "projects": 0,
+        "pending_uploads": 0,
     }
     }
     skipped = {
     skipped = {
         "settings": 0,
         "settings": 0,
@@ -611,6 +645,7 @@ async def import_backup(
         "maintenance_types": 0,
         "maintenance_types": 0,
         "archives": 0,
         "archives": 0,
         "projects": 0,
         "projects": 0,
+        "pending_uploads": 0,
     }
     }
     skipped_details = {
     skipped_details = {
         "notification_providers": [],
         "notification_providers": [],
@@ -621,6 +656,7 @@ async def import_backup(
         "maintenance_types": [],
         "maintenance_types": [],
         "archives": [],
         "archives": [],
         "projects": [],
         "projects": [],
+        "pending_uploads": [],
     }
     }
 
 
     # Restore settings (always overwrites)
     # Restore settings (always overwrites)
@@ -1077,6 +1113,82 @@ async def import_backup(
                     if archive:
                     if archive:
                         archive.project_id = project_name_to_id[project_name]
                         archive.project_id = project_name_to_id[project_name]
 
 
+    # Restore pending uploads (skip duplicates by filename)
+    if "pending_uploads" in backup:
+        # Ensure the pending uploads directory exists
+        pending_uploads_dir = base_dir / "virtual_printer" / "uploads"
+        pending_uploads_dir.mkdir(parents=True, exist_ok=True)
+
+        for upload_data in backup["pending_uploads"]:
+            # Check for existing by filename
+            result = await db.execute(
+                select(PendingUpload).where(
+                    PendingUpload.filename == upload_data["filename"],
+                    PendingUpload.status == "pending",
+                )
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    # Update existing
+                    existing.file_size = upload_data.get("file_size", 0)
+                    existing.source_ip = upload_data.get("source_ip")
+                    existing.tags = upload_data.get("tags")
+                    existing.notes = upload_data.get("notes")
+                    existing.project_id = upload_data.get("project_id")
+                    # Update file path if file was restored from ZIP
+                    if upload_data.get("file_path"):
+                        restored_file = base_dir / upload_data["file_path"]
+                        if restored_file.exists():
+                            # Move to proper location
+                            target_path = pending_uploads_dir / upload_data["filename"]
+                            if restored_file != target_path:
+                                import shutil
+
+                                shutil.move(str(restored_file), str(target_path))
+                            existing.file_path = str(target_path)
+                    restored["pending_uploads"] += 1
+                else:
+                    skipped["pending_uploads"] += 1
+                    skipped_details["pending_uploads"].append(upload_data["filename"])
+            else:
+                # Determine file path
+                file_path_str = None
+                if upload_data.get("file_path"):
+                    restored_file = base_dir / upload_data["file_path"]
+                    if restored_file.exists():
+                        # Move to proper location
+                        target_path = pending_uploads_dir / upload_data["filename"]
+                        if restored_file != target_path:
+                            import shutil
+
+                            shutil.move(str(restored_file), str(target_path))
+                        file_path_str = str(target_path)
+
+                # Parse uploaded_at
+                uploaded_at = None
+                if upload_data.get("uploaded_at"):
+                    try:
+                        uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00"))
+                    except (ValueError, AttributeError):
+                        uploaded_at = datetime.utcnow()
+                else:
+                    uploaded_at = datetime.utcnow()
+
+                pending = PendingUpload(
+                    filename=upload_data["filename"],
+                    file_path=file_path_str or "",
+                    file_size=upload_data.get("file_size", 0),
+                    source_ip=upload_data.get("source_ip"),
+                    status="pending",
+                    tags=upload_data.get("tags"),
+                    notes=upload_data.get("notes"),
+                    project_id=upload_data.get("project_id"),
+                    uploaded_at=uploaded_at,
+                )
+                db.add(pending)
+                restored["pending_uploads"] += 1
+
     await db.commit()
     await db.commit()
 
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1104,6 +1216,33 @@ async def import_backup(
             except Exception:
             except Exception:
                 pass  # Spoolman connection failed, but don't fail the restore
                 pass  # Spoolman connection failed, but don't fail the restore
 
 
+        # Reconfigure virtual printer if settings were restored
+        try:
+            from backend.app.services.virtual_printer import virtual_printer_manager
+
+            vp_enabled = await get_setting(db, "virtual_printer_enabled")
+            vp_access_code = await get_setting(db, "virtual_printer_access_code")
+            vp_mode = await get_setting(db, "virtual_printer_mode")
+
+            enabled = vp_enabled and vp_enabled.lower() == "true"
+            access_code = vp_access_code or ""
+            mode = vp_mode or "immediate"
+
+            if enabled and access_code:
+                await virtual_printer_manager.configure(
+                    enabled=True,
+                    access_code=access_code,
+                    mode=mode,
+                )
+            elif not enabled and virtual_printer_manager.is_enabled:
+                await virtual_printer_manager.configure(
+                    enabled=False,
+                    access_code=access_code,
+                    mode=mode,
+                )
+        except Exception:
+            pass  # Virtual printer config failed, but don't fail the restore
+
     # Build summary message
     # Build summary message
     restored_parts = []
     restored_parts = []
     for key, count in restored.items():
     for key, count in restored.items():
@@ -1134,3 +1273,94 @@ async def import_backup(
         "files_restored": files_restored,
         "files_restored": files_restored,
         "total_skipped": total_skipped,
         "total_skipped": total_skipped,
     }
     }
+
+
+# =============================================================================
+# Virtual Printer Settings
+# =============================================================================
+
+
+@router.get("/virtual-printer")
+async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
+    """Get virtual printer settings and status."""
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    enabled = await get_setting(db, "virtual_printer_enabled")
+    access_code = await get_setting(db, "virtual_printer_access_code")
+    mode = await get_setting(db, "virtual_printer_mode")
+
+    return {
+        "enabled": enabled == "true" if enabled else False,
+        "access_code_set": bool(access_code),
+        "mode": mode or "immediate",
+        "status": virtual_printer_manager.get_status(),
+    }
+
+
+@router.put("/virtual-printer")
+async def update_virtual_printer_settings(
+    enabled: bool = None,
+    access_code: str = None,
+    mode: str = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update virtual printer settings and restart services if needed."""
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    # Get current values
+    current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
+    current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
+    current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
+
+    # Apply updates
+    new_enabled = enabled if enabled is not None else current_enabled
+    new_access_code = access_code if access_code is not None else current_access_code
+    new_mode = mode if mode is not None else current_mode
+
+    # Validate mode
+    if new_mode not in ("immediate", "queue"):
+        return JSONResponse(
+            status_code=400,
+            content={"detail": "Mode must be 'immediate' or 'queue'"},
+        )
+
+    # Validate access code when enabling
+    if new_enabled and not new_access_code:
+        return JSONResponse(
+            status_code=400,
+            content={"detail": "Access code is required when enabling virtual printer"},
+        )
+
+    # Validate access code length (Bambu Studio requires exactly 8 characters)
+    if access_code is not None and len(access_code) != 8:
+        return JSONResponse(
+            status_code=400,
+            content={"detail": "Access code must be exactly 8 characters"},
+        )
+
+    # Save settings
+    await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
+    if access_code is not None:
+        await set_setting(db, "virtual_printer_access_code", access_code)
+    await set_setting(db, "virtual_printer_mode", new_mode)
+    await db.commit()
+
+    # Reconfigure virtual printer
+    try:
+        await virtual_printer_manager.configure(
+            enabled=new_enabled,
+            access_code=new_access_code,
+            mode=new_mode,
+        )
+    except ValueError as e:
+        return JSONResponse(
+            status_code=400,
+            content={"detail": str(e)},
+        )
+    except Exception as e:
+        return JSONResponse(
+            status_code=500,
+            content={"detail": f"Failed to configure virtual printer: {e}"},
+        )
+
+    return await get_virtual_printer_settings(db)

+ 31 - 0
backend/app/main.py

@@ -63,6 +63,7 @@ from backend.app.api.routes import (
     maintenance,
     maintenance,
     notification_templates,
     notification_templates,
     notifications,
     notifications,
+    pending_uploads,
     print_queue,
     print_queue,
     printers,
     printers,
     projects,
     projects,
@@ -1491,6 +1492,31 @@ async def lifespan(app: FastAPI):
     # Start anonymous telemetry (opt-out via settings)
     # Start anonymous telemetry (opt-out via settings)
     asyncio.create_task(start_telemetry_loop(async_session))
     asyncio.create_task(start_telemetry_loop(async_session))
 
 
+    # Initialize virtual printer manager
+    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"
+
+            if vp_access_code:
+                try:
+                    await virtual_printer_manager.configure(
+                        enabled=True,
+                        access_code=vp_access_code,
+                        mode=vp_mode,
+                    )
+                    logging.info("Virtual printer started")
+                except Exception as e:
+                    logging.warning(f"Failed to start virtual printer: {e}")
+
     yield
     yield
 
 
     # Shutdown
     # Shutdown
@@ -1501,6 +1527,10 @@ async def lifespan(app: FastAPI):
     printer_manager.disconnect_all()
     printer_manager.disconnect_all()
     await close_spoolman_client()
     await close_spoolman_client()
 
 
+    # Stop virtual printer if running
+    if virtual_printer_manager.is_enabled:
+        await virtual_printer_manager.configure(enabled=False)
+
 
 
 app = FastAPI(
 app = FastAPI(
     title=app_settings.app_name,
     title=app_settings.app_name,
@@ -1532,6 +1562,7 @@ app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(discovery.router, prefix=app_settings.api_prefix)
+app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 
 
 
 
 # Serve static files (React build)
 # Serve static files (React build)

+ 9 - 7
backend/app/models/__init__.py

@@ -1,15 +1,16 @@
-from backend.app.models.printer import Printer
+from backend.app.models.ams_history import AMSSensorHistory
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
-from backend.app.models.settings import Settings
-from backend.app.models.smart_plug import SmartPlug
-from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
-from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification import NotificationLog
+from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
-from backend.app.models.api_key import APIKey
-from backend.app.models.ams_history import AMSSensorHistory
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
 
 
 __all__ = [
 __all__ = [
     "Printer",
     "Printer",
@@ -26,4 +27,5 @@ __all__ = [
     "Project",
     "Project",
     "APIKey",
     "APIKey",
     "AMSSensorHistory",
     "AMSSensorHistory",
+    "PendingUpload",
 ]
 ]

+ 47 - 0
backend/app/models/pending_upload.py

@@ -0,0 +1,47 @@
+"""Pending upload model for virtual printer queue mode."""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PendingUpload(Base):
+    """Pending upload from virtual printer awaiting user review."""
+
+    __tablename__ = "pending_uploads"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+
+    # File info
+    filename: Mapped[str] = mapped_column(String(255))
+    file_path: Mapped[str] = mapped_column(String(500))  # Temp storage path
+    file_size: Mapped[int] = mapped_column(Integer)
+
+    # Source info
+    source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
+
+    # Status: pending, archived, discarded
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+
+    # User additions (before archiving)
+    tags: Mapped[str | None] = mapped_column(Text, nullable=True)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+
+    # After archiving - link to created archive
+    archived_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+
+    # Timestamps
+    uploaded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    # Relationships
+    project: Mapped["Project | None"] = relationship()
+    archive: Mapped["PrintArchive | None"] = relationship()
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.project import Project  # noqa: E402

+ 24 - 5
backend/app/schemas/settings.py

@@ -6,16 +6,23 @@ class AppSettings(BaseModel):
 
 
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
-    capture_finish_photo: bool = Field(default=True, description="Capture photo from printer camera when print completes")
+    capture_finish_photo: bool = Field(
+        default=True, description="Capture photo from printer camera when print completes"
+    )
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")
     currency: str = Field(default="USD", description="Currency for cost tracking")
     energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
     energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
-    energy_tracking_mode: str = Field(default="total", description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption")
+    energy_tracking_mode: str = Field(
+        default="total",
+        description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption",
+    )
 
 
     # Spoolman integration
     # Spoolman integration
     spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
     spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
     spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
     spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
-    spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
+    spoolman_sync_mode: str = Field(
+        default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press"
+    )
 
 
     # Updates
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -25,9 +32,13 @@ class AppSettings(BaseModel):
 
 
     # AMS threshold settings for humidity and temperature coloring
     # AMS threshold settings for humidity and temperature coloring
     ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
     ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
-    ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
+    ams_humidity_fair: int = Field(
+        default=60, description="Humidity threshold for fair (orange): <= this value, > is red"
+    )
     ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
     ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
-    ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
+    ams_temp_fair: float = Field(
+        default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
+    )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
 
 
     # Date/time display format
     # Date/time display format
@@ -40,6 +51,11 @@ class AppSettings(BaseModel):
     # Telemetry
     # Telemetry
     telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
     telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
 
 
+    # Virtual Printer
+    virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
+    virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
+    virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'")
+
 
 
 class AppSettingsUpdate(BaseModel):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -65,3 +81,6 @@ class AppSettingsUpdate(BaseModel):
     time_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
     default_printer_id: int | None = None
     telemetry_enabled: bool | None = None
     telemetry_enabled: bool | None = None
+    virtual_printer_enabled: bool | None = None
+    virtual_printer_access_code: str | None = None
+    virtual_printer_mode: str | None = None

+ 5 - 0
backend/app/services/virtual_printer/__init__.py

@@ -0,0 +1,5 @@
+"""Virtual printer services for slicer integration."""
+
+from backend.app.services.virtual_printer.manager import virtual_printer_manager
+
+__all__ = ["virtual_printer_manager"]

+ 254 - 0
backend/app/services/virtual_printer/certificate.py

@@ -0,0 +1,254 @@
+"""TLS certificate generation for virtual printer services.
+
+Generates certificates that mimic real Bambu printer certificate format:
+- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
+- Printer certificate has CN = serial number, signed by the CA
+"""
+
+import logging
+import socket
+from datetime import UTC, datetime, timedelta
+from ipaddress import IPv4Address
+from pathlib import Path
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
+
+logger = logging.getLogger(__name__)
+
+# Default serial number for virtual printer (matches SSDP/MQTT config)
+DEFAULT_SERIAL = "00M09A391800001"
+
+
+def _get_local_ip() -> str:
+    """Get the local IP address."""
+    try:
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(("8.8.8.8", 80))
+        ip = s.getsockname()[0]
+        s.close()
+        return ip
+    except Exception:
+        return "127.0.0.1"
+
+
+class CertificateService:
+    """Generate and manage TLS certificates for virtual printer.
+
+    Creates a certificate chain mimicking real Bambu printers:
+    - Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN"
+    - Printer cert with CN=serial_number, signed by the CA
+    """
+
+    def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL):
+        """Initialize the certificate service.
+
+        Args:
+            cert_dir: Directory to store certificates
+            serial: Serial number to use as CN in printer certificate
+        """
+        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"
+        self.cert_path = cert_dir / "virtual_printer.crt"
+        self.key_path = cert_dir / "virtual_printer.key"
+
+    def ensure_certificates(self) -> tuple[Path, Path]:
+        """Ensure certificates exist, generate if needed.
+
+        Returns:
+            Tuple of (cert_path, key_path)
+        """
+        if self.cert_path.exists() and self.key_path.exists():
+            logger.debug("Using existing virtual printer certificates")
+            return self.cert_path, self.key_path
+        return self.generate_certificates()
+
+    def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
+        """Generate a CA certificate for the virtual printer.
+
+        We use a generic name instead of mimicking BBL CA, since the slicer
+        may specifically reject certificates claiming to be from BBL but
+        with a different public key.
+
+        Returns:
+            Tuple of (ca_private_key, ca_certificate)
+        """
+        logger.info("Generating Virtual Printer CA certificate...")
+
+        # Generate CA private key
+        ca_key = rsa.generate_private_key(
+            public_exponent=65537,
+            key_size=2048,
+        )
+
+        # Use a generic CA name - NOT BBL to avoid being rejected as fake
+        ca_name = x509.Name(
+            [
+                x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"),
+            ]
+        )
+
+        now = datetime.now(UTC)
+
+        ca_cert = (
+            x509.CertificateBuilder()
+            .subject_name(ca_name)
+            .issuer_name(ca_name)
+            .public_key(ca_key.public_key())
+            .serial_number(x509.random_serial_number())
+            .not_valid_before(now)
+            .not_valid_after(now + timedelta(days=7300))  # 20 years
+            .add_extension(
+                x509.BasicConstraints(ca=True, path_length=0),
+                critical=True,
+            )
+            .add_extension(
+                x509.KeyUsage(
+                    digital_signature=True,
+                    content_commitment=False,
+                    key_encipherment=False,
+                    data_encipherment=False,
+                    key_agreement=False,
+                    key_cert_sign=True,
+                    crl_sign=True,
+                    encipher_only=False,
+                    decipher_only=False,
+                ),
+                critical=True,
+            )
+            .sign(ca_key, hashes.SHA256())
+        )
+
+        return ca_key, ca_cert
+
+    def generate_certificates(self) -> tuple[Path, Path]:
+        """Generate CA and printer certificates.
+
+        Creates a certificate chain mimicking real Bambu printers:
+        - BBL CA (self-signed root)
+        - Printer certificate (CN=serial, signed by BBL CA)
+
+        Returns:
+            Tuple of (cert_path, key_path)
+        """
+        logger.info(f"Generating certificates for virtual printer (serial: {self.serial})...")
+
+        # Ensure directory exists
+        self.cert_dir.mkdir(parents=True, exist_ok=True)
+
+        # Generate or load CA
+        ca_key, ca_cert = self._generate_ca_certificate()
+
+        # Save CA certificate and key
+        self.ca_key_path.write_bytes(
+            ca_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+        self.ca_key_path.chmod(0o600)
+        self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
+
+        # Generate printer private key
+        printer_key = rsa.generate_private_key(
+            public_exponent=65537,
+            key_size=2048,
+        )
+
+        # Printer certificate subject - CN is the serial number (like real Bambu printers)
+        printer_subject = x509.Name(
+            [
+                x509.NameAttribute(NameOID.COMMON_NAME, self.serial),
+            ]
+        )
+
+        # Issuer is the CA
+        issuer = ca_cert.subject
+
+        now = datetime.now(UTC)
+        local_ip = _get_local_ip()
+        logger.info(f"Generating printer certificate with CN={self.serial}, local IP: {local_ip}")
+
+        # Build printer certificate signed by CA
+        printer_cert = (
+            x509.CertificateBuilder()
+            .subject_name(printer_subject)
+            .issuer_name(issuer)
+            .public_key(printer_key.public_key())
+            .serial_number(x509.random_serial_number())
+            .not_valid_before(now)
+            .not_valid_after(now + timedelta(days=3650))  # 10 years
+            .add_extension(
+                x509.BasicConstraints(ca=False, path_length=None),
+                critical=True,
+            )
+            .add_extension(
+                x509.SubjectAlternativeName(
+                    [
+                        x509.DNSName("localhost"),
+                        x509.DNSName("bambuddy"),
+                        x509.DNSName(self.serial),
+                        x509.IPAddress(IPv4Address(local_ip)),
+                        x509.IPAddress(IPv4Address("127.0.0.1")),
+                    ]
+                ),
+                critical=False,
+            )
+            .add_extension(
+                x509.ExtendedKeyUsage(
+                    [
+                        ExtendedKeyUsageOID.SERVER_AUTH,
+                        ExtendedKeyUsageOID.CLIENT_AUTH,
+                    ]
+                ),
+                critical=False,
+            )
+            .add_extension(
+                x509.KeyUsage(
+                    digital_signature=True,
+                    content_commitment=False,
+                    key_encipherment=True,
+                    data_encipherment=False,
+                    key_agreement=False,
+                    key_cert_sign=False,
+                    crl_sign=False,
+                    encipher_only=False,
+                    decipher_only=False,
+                ),
+                critical=True,
+            )
+            .sign(ca_key, hashes.SHA256())  # Signed by CA, not self-signed
+        )
+
+        # Write printer private key
+        self.key_path.write_bytes(
+            printer_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+        self.key_path.chmod(0o600)
+
+        # Write printer certificate (include CA cert in chain for full chain)
+        cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes(
+            serialization.Encoding.PEM
+        )
+        self.cert_path.write_bytes(cert_chain)
+
+        logger.info(f"Generated certificate chain at {self.cert_dir}")
+        logger.info("  CA: CN=Virtual Printer CA")
+        logger.info(f"  Printer: CN={self.serial}")
+        return self.cert_path, self.key_path
+
+    def delete_certificates(self) -> None:
+        """Delete existing certificates."""
+        for path in [self.cert_path, self.key_path, self.ca_cert_path, self.ca_key_path]:
+            if path.exists():
+                path.unlink()
+        logger.info("Deleted virtual printer certificates")

+ 511 - 0
backend/app/services/virtual_printer/ftp_server.py

@@ -0,0 +1,511 @@
+"""Implicit FTPS server for receiving 3MF uploads from slicers.
+
+Implements an implicit FTPS server (TLS from byte 0) that accepts file uploads
+from Bambu Studio and OrcaSlicer, matching the real Bambu printer behavior.
+
+Unlike explicit FTPS (AUTH TLS), implicit FTPS wraps the connection in TLS
+immediately upon connection, before any FTP commands are exchanged.
+"""
+
+import asyncio
+import logging
+import random
+import ssl
+from collections.abc import Callable
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Default FTP port for Bambu printers (implicit FTPS)
+FTP_PORT = 9990
+
+
+class FTPSession:
+    """Handles a single FTP client session."""
+
+    def __init__(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        upload_dir: Path,
+        access_code: str,
+        ssl_context: ssl.SSLContext,
+        on_file_received: Callable[[Path, str], None] | None,
+    ):
+        self.reader = reader
+        self.writer = writer
+        self.upload_dir = upload_dir
+        self.access_code = access_code
+        self.ssl_context = ssl_context
+        self.on_file_received = on_file_received
+
+        self.authenticated = False
+        self.username: str | None = None
+        self.current_dir = upload_dir
+        self.transfer_type = "A"  # ASCII by default
+        self.data_server: asyncio.Server | None = None
+        self.data_port: int | None = None
+
+        # For data transfer coordination
+        self._data_reader: asyncio.StreamReader | None = None
+        self._data_writer: asyncio.StreamWriter | None = None
+        self._data_connected = asyncio.Event()
+
+        peername = writer.get_extra_info("peername")
+        self.remote_ip = peername[0] if peername else "unknown"
+
+    async def send(self, code: int, message: str) -> None:
+        """Send an FTP response."""
+        response = f"{code} {message}\r\n"
+        logger.info(f"FTP -> {self.remote_ip}: {response.strip()}")
+        self.writer.write(response.encode("utf-8"))
+        await self.writer.drain()
+
+    async def handle(self) -> None:
+        """Handle the FTP session."""
+        try:
+            # Send welcome banner
+            await self.send(220, "Bambuddy Virtual Printer FTP ready")
+
+            while True:
+                try:
+                    line = await asyncio.wait_for(
+                        self.reader.readline(),
+                        timeout=300,  # 5 minute timeout
+                    )
+                except TimeoutError:
+                    logger.debug(f"FTP session timeout from {self.remote_ip}")
+                    break
+
+                if not line:
+                    break
+
+                try:
+                    command_line = line.decode("utf-8").strip()
+                except UnicodeDecodeError:
+                    command_line = line.decode("latin-1").strip()
+
+                if not command_line:
+                    continue
+
+                logger.info(f"FTP <- {self.remote_ip}: {command_line}")
+
+                # Parse command and argument
+                parts = command_line.split(" ", 1)
+                cmd = parts[0].upper()
+                arg = parts[1] if len(parts) > 1 else ""
+
+                # Dispatch command
+                handler = getattr(self, f"cmd_{cmd}", None)
+                if handler:
+                    await handler(arg)
+                else:
+                    logger.warning(f"FTP command not implemented: {cmd}")
+                    await self.send(502, f"Command {cmd} not implemented")
+
+        except asyncio.CancelledError:
+            logger.info(f"FTP session cancelled from {self.remote_ip}")
+        except Exception as e:
+            logger.error(f"FTP session error from {self.remote_ip}: {e}")
+        finally:
+            logger.info(f"FTP session ended from {self.remote_ip}")
+            await self._cleanup()
+
+    async def _cleanup(self) -> None:
+        """Clean up session resources."""
+        if self.data_server:
+            self.data_server.close()
+            try:
+                await self.data_server.wait_closed()
+            except Exception:
+                pass
+            self.data_server = None
+
+        try:
+            self.writer.close()
+            await self.writer.wait_closed()
+        except Exception:
+            pass
+
+    # FTP Commands
+
+    async def cmd_USER(self, arg: str) -> None:
+        """Handle USER command."""
+        self.username = arg
+        if arg.lower() == "bblp":
+            await self.send(331, "Password required")
+        else:
+            await self.send(530, "Invalid user")
+
+    async def cmd_PASS(self, arg: str) -> None:
+        """Handle PASS command."""
+        if self.username and self.username.lower() == "bblp":
+            if arg == self.access_code:
+                self.authenticated = True
+                await self.send(230, "Login successful")
+                logger.info(f"FTP login from {self.remote_ip}")
+            else:
+                await self.send(530, "Login incorrect")
+                logger.warning(f"FTP failed login from {self.remote_ip}")
+        else:
+            await self.send(503, "Login with USER first")
+
+    async def cmd_SYST(self, arg: str) -> None:
+        """Handle SYST command."""
+        await self.send(215, "UNIX Type: L8")
+
+    async def cmd_FEAT(self, arg: str) -> None:
+        """Handle FEAT command."""
+        features = [
+            "211-Features:",
+            " PASV",
+            " UTF8",
+            " SIZE",
+            "211 End",
+        ]
+        for line in features[:-1]:
+            self.writer.write(f"{line}\r\n".encode())
+        await self.writer.drain()
+        self.writer.write(f"{features[-1]}\r\n".encode())
+        await self.writer.drain()
+
+    async def cmd_PWD(self, arg: str) -> None:
+        """Handle PWD command."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+        await self.send(257, '"/" is current directory')
+
+    async def cmd_CWD(self, arg: str) -> None:
+        """Handle CWD command."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+        # Accept any directory change (we use a flat structure)
+        await self.send(250, "Directory changed")
+
+    async def cmd_TYPE(self, arg: str) -> None:
+        """Handle TYPE command."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+        if arg.upper() in ("A", "I"):
+            self.transfer_type = arg.upper()
+            type_name = "ASCII" if arg.upper() == "A" else "Binary"
+            await self.send(200, f"Type set to {type_name}")
+        else:
+            await self.send(504, "Type not supported")
+
+    async def cmd_PASV(self, arg: str) -> None:
+        """Handle PASV command - set up passive data connection."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+
+        # Close any existing data connection/server
+        await self._close_data_connection()
+
+        # Reset connection state
+        self._data_connected.clear()
+        self._data_reader = None
+        self._data_writer = None
+
+        # Find a free port for passive data connection
+        self.data_port = random.randint(50000, 60000)
+
+        try:
+            # Create data server with TLS
+            self.data_server = await asyncio.start_server(
+                self._handle_data_connection,
+                "0.0.0.0",
+                self.data_port,
+                ssl=self.ssl_context,
+            )
+
+            # Get server's IP for response
+            # Use the IP the client connected to
+            sockname = self.writer.get_extra_info("sockname")
+            ip = sockname[0] if sockname else "127.0.0.1"
+
+            # Format IP and port for PASV response
+            ip_parts = ip.split(".")
+            port_hi = self.data_port // 256
+            port_lo = self.data_port % 256
+
+            await self.send(
+                227,
+                f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
+            )
+            logger.info(f"FTP PASV listening on port {self.data_port}")
+
+        except Exception as e:
+            logger.error(f"Failed to create passive data connection: {e}")
+            await self.send(425, "Cannot open data connection")
+
+    async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
+        """Handle incoming data connection (used by PASV)."""
+        logger.info(f"FTP data connection established from {self.remote_ip}")
+        self._data_reader = reader
+        self._data_writer = writer
+        self._data_connected.set()
+        # Don't close - let the transfer command handle it
+
+    async def _close_data_connection(self) -> None:
+        """Close the data connection and server."""
+        if self._data_writer:
+            try:
+                self._data_writer.close()
+                await self._data_writer.wait_closed()
+            except Exception:
+                pass
+            self._data_writer = None
+            self._data_reader = None
+
+        if self.data_server:
+            try:
+                self.data_server.close()
+                await self.data_server.wait_closed()
+            except Exception:
+                pass
+            self.data_server = None
+
+    async def cmd_STOR(self, arg: str) -> None:
+        """Handle STOR command - receive file upload."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+
+        if not self.data_server:
+            await self.send(425, "Use PASV first")
+            return
+
+        filename = Path(arg).name  # Sanitize filename
+        file_path = self.upload_dir / filename
+
+        logger.info(f"FTP receiving file: {filename} from {self.remote_ip}")
+
+        await self.send(150, f"Opening data connection for {filename}")
+
+        # Wait for data connection to be established (client connects after 150)
+        try:
+            await asyncio.wait_for(self._data_connected.wait(), timeout=30)
+        except TimeoutError:
+            logger.error("FTP data connection timeout - client didn't connect")
+            await self.send(425, "Data connection timeout")
+            await self._close_data_connection()
+            return
+
+        if not self._data_reader:
+            await self.send(425, "Data connection failed")
+            await self._close_data_connection()
+            return
+
+        # Receive data
+        data_content: list[bytes] = []
+        try:
+            while True:
+                chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
+                if not chunk:
+                    break
+                data_content.append(chunk)
+                logger.debug(f"FTP received chunk: {len(chunk)} bytes")
+        except TimeoutError:
+            logger.error("FTP data transfer timeout")
+            await self.send(426, "Transfer timeout")
+            await self._close_data_connection()
+            return
+        except Exception as e:
+            logger.error(f"FTP data transfer error: {e}")
+            await self.send(426, f"Transfer failed: {e}")
+            await self._close_data_connection()
+            return
+
+        # Close data connection
+        await self._close_data_connection()
+
+        # Write file
+        try:
+            total_size = sum(len(c) for c in data_content)
+            file_path.write_bytes(b"".join(data_content))
+            logger.info(f"FTP saved file: {file_path} ({total_size} bytes)")
+            await self.send(226, "Transfer complete")
+
+            # Notify callback
+            if self.on_file_received:
+                try:
+                    result = self.on_file_received(file_path, self.remote_ip)
+                    if asyncio.iscoroutine(result):
+                        await result
+                except Exception as e:
+                    logger.error(f"File received callback error: {e}")
+
+        except Exception as e:
+            logger.error(f"Failed to save file {file_path}: {e}")
+            await self.send(550, "Failed to save file")
+
+    async def cmd_SIZE(self, arg: str) -> None:
+        """Handle SIZE command."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+        # We don't store files for SIZE queries
+        await self.send(550, "File not found")
+
+    async def cmd_QUIT(self, arg: str) -> None:
+        """Handle QUIT command."""
+        await self.send(221, "Goodbye")
+        raise asyncio.CancelledError()
+
+    async def cmd_NOOP(self, arg: str) -> None:
+        """Handle NOOP command."""
+        await self.send(200, "OK")
+
+    async def cmd_OPTS(self, arg: str) -> None:
+        """Handle OPTS command."""
+        if arg.upper().startswith("UTF8"):
+            await self.send(200, "UTF8 mode enabled")
+        else:
+            await self.send(501, "Option not supported")
+
+    async def cmd_PBSZ(self, arg: str) -> None:
+        """Handle PBSZ (Protection Buffer Size) command.
+
+        Required for FTP security extensions. With TLS, buffer size is 0.
+        """
+        await self.send(200, "PBSZ=0")
+
+    async def cmd_PROT(self, arg: str) -> None:
+        """Handle PROT (Data Channel Protection Level) command.
+
+        P = Private (encrypted), which we always use with implicit FTPS.
+        """
+        if arg.upper() == "P":
+            await self.send(200, "Protection level set to Private")
+        elif arg.upper() == "C":
+            # Clear (unprotected) - we don't support this
+            await self.send(536, "Protection level C not supported")
+        else:
+            await self.send(504, f"Protection level {arg} not supported")
+
+    async def cmd_MKD(self, arg: str) -> None:
+        """Handle MKD (Make Directory) command."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+        # We don't really create directories, just pretend it works
+        await self.send(257, f'"{arg}" directory created')
+
+    async def cmd_LIST(self, arg: str) -> None:
+        """Handle LIST command - list directory contents."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+        # We don't support listing, return empty
+        await self.send(150, "Opening data connection")
+        await self.send(226, "Transfer complete")
+
+
+class VirtualPrinterFTPServer:
+    """Implicit FTPS server that accepts uploads from slicers."""
+
+    def __init__(
+        self,
+        upload_dir: Path,
+        access_code: str,
+        cert_path: Path,
+        key_path: Path,
+        port: int = FTP_PORT,
+        on_file_received: Callable[[Path, str], None] | None = None,
+    ):
+        """Initialize the FTPS server.
+
+        Args:
+            upload_dir: Directory to store uploaded files
+            access_code: Password for authentication (bblp user)
+            cert_path: Path to TLS certificate file
+            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)
+        """
+        self.upload_dir = upload_dir
+        self.access_code = access_code
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self.port = port
+        self.on_file_received = on_file_received
+        self._server: asyncio.Server | None = None
+        self._running = False
+        self._ssl_context: ssl.SSLContext | None = None
+
+    async def start(self) -> None:
+        """Start the implicit FTPS server."""
+        if self._running:
+            return
+
+        logger.info(f"Starting virtual printer implicit FTPS on port {self.port}")
+
+        # Ensure upload directory exists
+        self.upload_dir.mkdir(parents=True, exist_ok=True)
+        cache_dir = self.upload_dir / "cache"
+        cache_dir.mkdir(exist_ok=True)
+
+        # Create SSL context for implicit FTPS (TLS from byte 0)
+        self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
+        self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+
+        try:
+            # Create server with SSL - TLS handshake happens before any FTP data
+            self._server = await asyncio.start_server(
+                self._handle_client,
+                "0.0.0.0",
+                self.port,
+                ssl=self._ssl_context,  # This makes it implicit FTPS!
+            )
+            self._running = True
+
+            logger.info(f"Implicit FTPS server started on port {self.port}")
+
+            async with self._server:
+                await self._server.serve_forever()
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.error(f"FTP port {self.port} is already in use")
+            else:
+                logger.error(f"FTP server error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("FTP server task cancelled")
+        except Exception as e:
+            logger.error(f"FTP server error: {e}")
+        finally:
+            await self.stop()
+
+    async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
+        """Handle a new FTP client connection."""
+        peername = writer.get_extra_info("peername")
+        logger.info(f"FTP connection from {peername}")
+
+        session = FTPSession(
+            reader=reader,
+            writer=writer,
+            upload_dir=self.upload_dir,
+            access_code=self.access_code,
+            ssl_context=self._ssl_context,
+            on_file_received=self.on_file_received,
+        )
+
+        await session.handle()
+
+    async def stop(self) -> None:
+        """Stop the FTPS server."""
+        logger.info("Stopping FTP server")
+        self._running = False
+
+        if self._server:
+            try:
+                self._server.close()
+                await self._server.wait_closed()
+            except Exception as e:
+                logger.debug(f"Error closing FTP server: {e}")
+            self._server = None

+ 323 - 0
backend/app/services/virtual_printer/manager.py

@@ -0,0 +1,323 @@
+"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services."""
+
+import asyncio
+import logging
+from collections.abc import Callable
+from datetime import UTC, datetime
+from pathlib import Path
+
+from backend.app.core.config import settings as app_settings
+from backend.app.services.virtual_printer.certificate import CertificateService
+from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
+from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
+from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+logger = logging.getLogger(__name__)
+
+
+class VirtualPrinterManager:
+    """Manages the virtual printer lifecycle and coordinates all services."""
+
+    # Fixed configuration
+    PRINTER_NAME = "Bambuddy"
+    PRINTER_SERIAL = "00M09A391800001"  # X1C serial format
+    PRINTER_MODEL = "3DPrinter-X1-Carbon"  # Full model name for slicer compatibility
+
+    def __init__(self):
+        """Initialize the virtual printer manager."""
+        self._session_factory: Callable | None = None
+        self._enabled = False
+        self._access_code = ""
+        self._mode = "immediate"
+
+        # Service instances
+        self._ssdp: VirtualPrinterSSDPServer | None = None
+        self._ftp: VirtualPrinterFTPServer | None = None
+        self._mqtt: SimpleMQTTServer | None = None
+
+        # Background tasks
+        self._tasks: list[asyncio.Task] = []
+
+        # Directories
+        self._base_dir = app_settings.base_dir / "virtual_printer"
+        self._upload_dir = self._base_dir / "uploads"
+        self._cert_dir = self._base_dir / "certs"
+
+        # Certificate service - pass serial to match CN in certificate
+        self._cert_service = CertificateService(self._cert_dir, serial=self.PRINTER_SERIAL)
+
+        # Track pending uploads for MQTT correlation
+        self._pending_files: dict[str, Path] = {}
+
+    def set_session_factory(self, session_factory: Callable) -> None:
+        """Set the database session factory.
+
+        Args:
+            session_factory: Async context manager for database sessions
+        """
+        self._session_factory = session_factory
+
+    @property
+    def is_enabled(self) -> bool:
+        """Check if virtual printer is enabled."""
+        return self._enabled
+
+    @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",
+    ) -> 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' or 'queue'
+        """
+        if enabled and not access_code:
+            raise ValueError("Access code is required when enabling virtual printer")
+
+        self._access_code = access_code
+        self._mode = mode
+
+        if enabled and not self._enabled:
+            await self._start()
+        elif not enabled and self._enabled:
+            await self._stop()
+
+        self._enabled = enabled
+
+    async def _start(self) -> None:
+        """Start all virtual printer services."""
+        logger.info("Starting virtual printer services...")
+
+        # Ensure certificates exist
+        cert_path, key_path = self._cert_service.ensure_certificates()
+
+        # 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.PRINTER_MODEL,
+        )
+
+        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,
+        )
+
+        # 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(f"Virtual printer {name} failed: {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"),
+        ]
+
+        logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.PRINTER_SERIAL})")
+
+    async def _stop(self) -> None:
+        """Stop all virtual printer services."""
+        logger.info("Stopping virtual printer services...")
+
+        # Cancel all tasks
+        for task in self._tasks:
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+        self._tasks = []
+
+        # Stop services
+        if self._ssdp:
+            await self._ssdp.stop()
+            self._ssdp = None
+
+        if self._ftp:
+            await self._ftp.stop()
+            self._ftp = None
+
+        if self._mqtt:
+            await self._mqtt.stop()
+            self._mqtt = None
+
+        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(f"Virtual printer received file: {file_path.name} from {source_ip}")
+
+        # Store file reference for MQTT correlation
+        self._pending_files[file_path.name] = file_path
+
+        # In immediate mode, archive right away
+        # In queue mode, create pending upload record
+        if self._mode == "immediate":
+            await self._archive_file(file_path, source_ip)
+        else:
+            await self._queue_file(file_path, source_ip)
+
+    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(f"Virtual printer received print command for: {filename}")
+        logger.debug(f"Print command data: {data}")
+
+        # The file should already be archived from FTP upload
+        # This command just confirms the slicer's intent to "print"
+
+    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
+        """
+        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(f"Skipping non-3MF file: {file_path.name}")
+            # Remove from pending and clean up
+            self._pending_files.pop(file_path.name, None)
+            try:
+                file_path.unlink()
+            except Exception:
+                pass
+            return
+
+        try:
+            from backend.app.services.archive import ArchiveService
+
+            async with self._session_factory() as db:
+                service = ArchiveService(db)
+
+                # Archive the print
+                archive = await service.archive_print(
+                    printer_id=None,  # No physical printer
+                    source_file=file_path,
+                    print_data={
+                        "status": "archived",
+                        "source": "virtual_printer",
+                        "source_ip": source_ip,
+                    },
+                )
+
+                if archive:
+                    logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
+
+                    # Clean up uploaded file (it's now copied to archive)
+                    try:
+                        file_path.unlink()
+                    except Exception:
+                        pass
+
+                    # Remove from pending
+                    self._pending_files.pop(file_path.name, None)
+                else:
+                    logger.error(f"Failed to archive file: {file_path.name}")
+
+        except Exception as e:
+            logger.error(f"Error archiving file: {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
+        """
+        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.warning(f"Skipping non-3MF file: {file_path.name}")
+            return
+
+        try:
+            from backend.app.models.pending_upload import PendingUpload
+
+            async with self._session_factory() as db:
+                pending = PendingUpload(
+                    filename=file_path.name,
+                    file_path=str(file_path),
+                    file_size=file_path.stat().st_size,
+                    source_ip=source_ip,
+                    status="pending",
+                    uploaded_at=datetime.now(UTC),
+                )
+                db.add(pending)
+                await db.commit()
+
+                logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
+
+                # Remove from pending files dict
+                self._pending_files.pop(file_path.name, None)
+
+        except Exception as e:
+            logger.error(f"Error queueing file: {e}")
+
+    def get_status(self) -> dict:
+        """Get virtual printer status.
+
+        Returns:
+            Status dictionary with enabled, running, mode, etc.
+        """
+        return {
+            "enabled": self._enabled,
+            "running": self.is_running,
+            "mode": self._mode,
+            "name": self.PRINTER_NAME,
+            "serial": self.PRINTER_SERIAL,
+            "model": self.PRINTER_MODEL,
+            "pending_files": len(self._pending_files),
+        }
+
+
+# Global instance
+virtual_printer_manager = VirtualPrinterManager()

+ 799 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -0,0 +1,799 @@
+"""MQTT broker for virtual printer.
+
+Implements an MQTT broker that accepts connections from slicers,
+authenticates with the configured access code, and logs print commands.
+"""
+
+import asyncio
+import json
+import logging
+import ssl
+from collections.abc import Callable
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Default MQTT port for Bambu printers (MQTT over TLS)
+MQTT_PORT = 8883
+
+
+class VirtualPrinterMQTTServer:
+    """MQTT broker that accepts connections from slicers.
+
+    This is a minimal MQTT broker implementation that:
+    - Accepts TLS connections on port 8883
+    - Authenticates with username 'bblp' and the configured access code
+    - Receives print commands on device/{serial}/request
+    - Can publish status on device/{serial}/report
+    """
+
+    def __init__(
+        self,
+        serial: str,
+        access_code: str,
+        cert_path: Path,
+        key_path: Path,
+        port: int = MQTT_PORT,
+        on_print_command: Callable[[str, dict], None] | None = None,
+    ):
+        """Initialize the MQTT server.
+
+        Args:
+            serial: Virtual printer serial number
+            access_code: Password for authentication
+            cert_path: Path to TLS certificate
+            key_path: Path to TLS private key
+            port: Port to listen on (default 8883)
+            on_print_command: Callback when print command received (filename, data)
+        """
+        self.serial = serial
+        self.access_code = access_code
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self.port = port
+        self.on_print_command = on_print_command
+        self._running = False
+        self._broker = None
+        self._broker_task = None
+
+    async def start(self) -> None:
+        """Start the MQTT broker."""
+        if self._running:
+            return
+
+        # Try to import amqtt
+        try:
+            from amqtt.broker import Broker
+        except ImportError:
+            logger.error("amqtt not installed. Run: pip install amqtt")
+            return
+
+        logger.info(f"Starting virtual printer MQTT broker on port {self.port}")
+
+        # Build broker configuration
+        config = {
+            "listeners": {
+                "default": {
+                    "type": "tcp",
+                    "bind": f"0.0.0.0:{self.port}",
+                    "ssl": "on",
+                    "certfile": str(self.cert_path),
+                    "keyfile": str(self.key_path),
+                },
+            },
+            "auth": {
+                "allow-anonymous": False,
+                "plugins": ["auth_custom"],
+            },
+            "topic-check": {
+                "enabled": False,  # Allow any topic
+            },
+        }
+
+        try:
+            self._running = True
+
+            # Create and start broker
+            self._broker = Broker(config)
+
+            # Register custom auth plugin
+            self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate
+
+            # Start the broker
+            await self._broker.start()
+            logger.info(f"MQTT broker started on port {self.port}")
+
+            # Keep running
+            while self._running:
+                await asyncio.sleep(1)
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.error(f"MQTT port {self.port} is already in use")
+            else:
+                logger.error(f"MQTT broker error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("MQTT broker task cancelled")
+        except Exception as e:
+            logger.error(f"MQTT broker error: {e}")
+        finally:
+            await self.stop()
+
+    async def _authenticate(self, session) -> bool:
+        """Authenticate MQTT connection.
+
+        Args:
+            session: MQTT session with username/password
+
+        Returns:
+            True if authentication successful
+        """
+        username = getattr(session, "username", None)
+        password = getattr(session, "password", None)
+
+        # Bambu slicers use 'bblp' as username and access code as password
+        if username == "bblp" and password == self.access_code:
+            logger.debug(f"MQTT client authenticated from {session.remote_address}")
+            return True
+
+        logger.warning(f"MQTT auth failed for user '{username}' from {session.remote_address}")
+        return False
+
+    async def stop(self) -> None:
+        """Stop the MQTT broker."""
+        logger.info("Stopping MQTT broker")
+        self._running = False
+
+        if self._broker:
+            try:
+                await self._broker.shutdown()
+            except Exception as e:
+                logger.debug(f"Error shutting down MQTT broker: {e}")
+            self._broker = None
+
+
+class SimpleMQTTServer:
+    """Simplified MQTT server using raw sockets.
+
+    This is a fallback implementation that handles basic MQTT protocol
+    without requiring the amqtt library. It's less feature-complete but
+    more lightweight.
+    """
+
+    def __init__(
+        self,
+        serial: str,
+        access_code: str,
+        cert_path: Path,
+        key_path: Path,
+        port: int = MQTT_PORT,
+        on_print_command: Callable[[str, dict], None] | None = None,
+    ):
+        self.serial = serial
+        self.access_code = access_code
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self.port = port
+        self.on_print_command = on_print_command
+        self._running = False
+        self._server = None
+        self._clients: dict[str, asyncio.StreamWriter] = {}
+        self._status_push_task: asyncio.Task | None = None
+        self._sequence_id = 0
+
+    async def start(self) -> None:
+        """Start the MQTT server."""
+        if self._running:
+            return
+
+        logger.info(f"Starting simple MQTT server on port {self.port}")
+
+        # Create SSL context with Bambu-compatible settings
+        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
+        # Match Bambu printer behavior - accept any client
+        ssl_context.verify_mode = ssl.CERT_NONE
+        # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
+        ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Disable hostname checking
+        ssl_context.check_hostname = False
+
+        # Log certificate info
+        import subprocess
+
+        try:
+            result = subprocess.run(
+                ["openssl", "x509", "-in", str(self.cert_path), "-noout", "-subject", "-issuer"],
+                capture_output=True,
+                text=True,
+                timeout=5,
+            )
+            logger.info(f"MQTT SSL cert info: {result.stdout.strip()}")
+        except Exception:
+            pass
+
+        logger.info(f"MQTT SSL context: TLS 1.2+, cert={self.cert_path}")
+
+        try:
+            self._running = True
+
+            # Wrapper to log ALL connection attempts including SSL errors
+            async def connection_handler(reader, writer):
+                try:
+                    addr = writer.get_extra_info("peername")
+                    ssl_obj = writer.get_extra_info("ssl_object")
+                    if ssl_obj:
+                        logger.info(
+                            f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
+                        )
+                    else:
+                        logger.info(f"MQTT connection from {addr} (no TLS?)")
+                    await self._handle_client(reader, writer)
+                except ssl.SSLError as e:
+                    logger.error(f"MQTT SSL error: {e}")
+                except Exception as e:
+                    logger.error(f"MQTT connection handler error: {e}")
+
+            # Custom protocol factory to log raw connection attempts
+            logger.info("Setting up MQTT server with SSL error handling...")
+
+            # Add SSL handshake error callback
+            def handle_ssl_error(loop, context):
+                exception = context.get("exception")
+                message = context.get("message", "")
+                if "ssl" in str(exception).lower() or "ssl" in message.lower():
+                    logger.error(f"SSL error: {message} - {exception}")
+                else:
+                    logger.debug(f"Asyncio error: {message}")
+
+            asyncio.get_event_loop().set_exception_handler(handle_ssl_error)
+
+            self._server = await asyncio.start_server(
+                connection_handler,
+                "0.0.0.0",
+                self.port,
+                ssl=ssl_context,
+            )
+
+            logger.info(f"Simple MQTT server listening on port {self.port}")
+
+            # Start periodic status push task
+            self._status_push_task = asyncio.create_task(self._periodic_status_push())
+
+            async with self._server:
+                await self._server.serve_forever()
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.error(f"MQTT port {self.port} is already in use")
+            else:
+                logger.error(f"MQTT server error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("MQTT server task cancelled")
+        except Exception as e:
+            logger.error(f"MQTT server error: {e}")
+        finally:
+            await self.stop()
+
+    async def stop(self) -> None:
+        """Stop the MQTT server."""
+        logger.info("Stopping simple MQTT server")
+        self._running = False
+
+        # Stop periodic status push
+        if self._status_push_task:
+            self._status_push_task.cancel()
+            try:
+                await self._status_push_task
+            except asyncio.CancelledError:
+                pass
+            self._status_push_task = None
+
+        # Close all client connections
+        for _client_id, writer in self._clients.items():
+            try:
+                writer.close()
+                await writer.wait_closed()
+            except Exception:
+                pass
+        self._clients.clear()
+
+        if self._server:
+            try:
+                self._server.close()
+                await self._server.wait_closed()
+            except Exception:
+                pass
+            self._server = None
+
+    async def _periodic_status_push(self) -> None:
+        """Send periodic status updates to all connected clients."""
+        logger.info("Starting periodic status push task")
+        while self._running:
+            try:
+                await asyncio.sleep(1)  # Push every 1 second like real printers
+
+                # Send status to all connected clients
+                disconnected = []
+                for client_id, writer in list(self._clients.items()):
+                    try:
+                        if writer.is_closing():
+                            disconnected.append(client_id)
+                            continue
+                        await self._send_status_report(writer)
+                    except Exception as e:
+                        logger.debug(f"Failed to push status to {client_id}: {e}")
+                        disconnected.append(client_id)
+
+                # Remove disconnected clients
+                for client_id in disconnected:
+                    self._clients.pop(client_id, None)
+
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.error(f"Periodic status push error: {e}")
+
+        logger.info("Periodic status push task stopped")
+
+    async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
+        """Handle an MQTT client connection."""
+        addr = writer.get_extra_info("peername")
+        client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
+        logger.info(f"MQTT client connected: {client_id}")
+
+        authenticated = False
+
+        try:
+            while self._running:
+                # Read MQTT fixed header
+                try:
+                    header = await asyncio.wait_for(reader.read(1), timeout=60)
+                except TimeoutError:
+                    break
+
+                if not header:
+                    break
+
+                packet_type = (header[0] & 0xF0) >> 4
+
+                # Read remaining length
+                remaining_length = await self._read_remaining_length(reader)
+                if remaining_length is None:
+                    break
+
+                # Read payload
+                payload = await reader.read(remaining_length) if remaining_length > 0 else b""
+
+                # Handle packet types
+                if packet_type == 1:  # CONNECT
+                    authenticated = await self._handle_connect(payload, writer)
+                    if not authenticated:
+                        break
+                    # Register client for periodic status pushes
+                    self._clients[client_id] = writer
+                elif packet_type == 3:  # PUBLISH
+                    if authenticated:
+                        await self._handle_publish(header[0], payload, writer)
+                elif packet_type == 8:  # SUBSCRIBE
+                    if authenticated:
+                        await self._handle_subscribe(payload, writer)
+                elif packet_type == 12:  # PINGREQ
+                    # Send PINGRESP
+                    writer.write(bytes([0xD0, 0x00]))
+                    await writer.drain()
+                elif packet_type == 14:  # DISCONNECT
+                    break
+
+        except asyncio.CancelledError:
+            pass
+        except Exception as e:
+            logger.debug(f"MQTT client error: {e}")
+        finally:
+            logger.debug(f"MQTT client disconnected: {client_id}")
+            if client_id in self._clients:
+                del self._clients[client_id]
+            try:
+                writer.close()
+                await writer.wait_closed()
+            except Exception:
+                pass
+
+    async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:
+        """Read MQTT remaining length (variable byte integer)."""
+        multiplier = 1
+        value = 0
+
+        for _ in range(4):
+            try:
+                byte = await reader.read(1)
+                if not byte:
+                    return None
+                encoded = byte[0]
+                value += (encoded & 127) * multiplier
+                if (encoded & 128) == 0:
+                    return value
+                multiplier *= 128
+            except Exception:
+                return None
+
+        return None
+
+    async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> bool:
+        """Handle MQTT CONNECT packet.
+
+        Returns True if authentication successful.
+        """
+        try:
+            # Parse CONNECT packet
+            # Skip protocol name length and name
+            idx = 0
+            proto_len = (payload[idx] << 8) | payload[idx + 1]
+            idx += 2 + proto_len
+
+            # Skip protocol level and connect flags
+            # connect_flags = payload[idx + 1]
+            idx += 2
+
+            # Skip keepalive
+            idx += 2
+
+            # Read client ID
+            client_id_len = (payload[idx] << 8) | payload[idx + 1]
+            idx += 2
+            # client_id = payload[idx : idx + client_id_len].decode("utf-8")
+            idx += client_id_len
+
+            # Read username
+            username_len = (payload[idx] << 8) | payload[idx + 1]
+            idx += 2
+            username = payload[idx : idx + username_len].decode("utf-8")
+            idx += username_len
+
+            # Read password
+            password_len = (payload[idx] << 8) | payload[idx + 1]
+            idx += 2
+            password = payload[idx : idx + password_len].decode("utf-8")
+
+            # Authenticate
+            if username == "bblp" and password == self.access_code:
+                # Send CONNACK with success
+                writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
+                await writer.drain()
+                logger.info("MQTT client authenticated successfully")
+
+                # Send immediate status report after auth - slicer expects this
+                await self._send_status_report(writer)
+                return True
+            else:
+                # Send CONNACK with auth failure
+                writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
+                await writer.drain()
+                logger.warning(f"MQTT auth failed for user '{username}'")
+                return False
+
+        except Exception as e:
+            logger.debug(f"MQTT CONNECT parse error: {e}")
+            # Send CONNACK with error
+            writer.write(bytes([0x20, 0x02, 0x00, 0x02]))  # Protocol error
+            await writer.drain()
+            return False
+
+    async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter) -> None:
+        """Handle MQTT SUBSCRIBE packet."""
+        try:
+            # Parse packet ID
+            packet_id = (payload[0] << 8) | payload[1]
+
+            # Parse topic filters (just acknowledge them)
+            idx = 2
+            granted_qos = []
+            while idx < len(payload):
+                topic_len = (payload[idx] << 8) | payload[idx + 1]
+                idx += 2
+                topic = payload[idx : idx + topic_len].decode("utf-8")
+                idx += topic_len
+                requested_qos = payload[idx]
+                idx += 1
+
+                logger.info(f"MQTT subscribe: {topic} QoS={requested_qos}")
+                granted_qos.append(min(requested_qos, 1))  # Grant up to QoS 1
+
+            # Send SUBACK
+            suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF])
+            suback += bytes(granted_qos)
+            writer.write(suback)
+            await writer.drain()
+
+            # Send initial status report after subscribe
+            await self._send_status_report(writer)
+
+        except Exception as e:
+            logger.debug(f"MQTT SUBSCRIBE error: {e}")
+
+    async def _send_status_report(self, writer: asyncio.StreamWriter) -> None:
+        """Send a status report to the slicer after connection."""
+        try:
+            # Build status message matching Bambu printer format
+            self._sequence_id += 1
+            status = {
+                "print": {
+                    "sequence_id": str(self._sequence_id),
+                    "command": "push_status",
+                    "msg": 0,
+                    "gcode_state": "IDLE",
+                    "gcode_file": "",
+                    "gcode_file_prepare_percent": "0",
+                    "subtask_name": "",
+                    "mc_print_stage": "",
+                    "mc_percent": 0,
+                    "mc_remaining_time": 0,
+                    "wifi_signal": "-44dBm",
+                    "print_error": 0,
+                    "print_type": "",
+                    "bed_temper": 25.0,
+                    "bed_target_temper": 0.0,
+                    "nozzle_temper": 25.0,
+                    "nozzle_target_temper": 0.0,
+                    "chamber_temper": 25.0,
+                    "cooling_fan_speed": "0",
+                    "big_fan1_speed": "0",
+                    "big_fan2_speed": "0",
+                    "heatbreak_fan_speed": "0",
+                    "spd_lvl": 1,
+                    "spd_mag": 100,
+                    "stg": [],
+                    "stg_cur": 0,
+                    "layer_num": 0,
+                    "total_layer_num": 0,
+                    "home_flag": 256,  # Bit 8 = SD card present (HAS_SDCARD_NORMAL)
+                    "hw_switch_state": 0,
+                    "online": {"ahb": False, "rfid": False, "version": 7},
+                    "ams_status": 0,
+                    "sdcard": True,
+                    "storage": {"free": 1000000000, "total": 32000000000},
+                    "upgrade_state": {
+                        "sequence_id": 0,
+                        "progress": "",
+                        "status": "",
+                        "consistency_request": False,
+                        "dis_state": 0,
+                        "err_code": 0,
+                        "force_upgrade": False,
+                        "message": "",
+                        "module": "",
+                        "new_version_state": 2,
+                        "new_ver_list": [],
+                        "ota_new_version_number": "",
+                        "ahb_new_version_number": "",
+                    },
+                    "ipcam": {
+                        "ipcam_dev": "1",
+                        "ipcam_record": "enable",
+                        "timelapse": "disable",
+                        "resolution": "1080p",
+                        "mode_bits": 0,
+                    },
+                    "xcam": {
+                        "allow_skip_parts": False,
+                        "buildplate_marker_detector": True,
+                        "first_layer_inspector": True,
+                        "halt_print_sensitivity": "medium",
+                        "print_halt": True,
+                        "printing_monitor": True,
+                        "spaghetti_detector": True,
+                    },
+                    "lights_report": [{"node": "chamber_light", "mode": "on"}],
+                    "nozzle_diameter": "0.4",
+                    "nozzle_type": "hardened_steel",
+                }
+            }
+
+            topic = f"device/{self.serial}/report"
+            message = json.dumps(status)
+
+            # Build MQTT PUBLISH packet
+            topic_bytes = topic.encode("utf-8")
+            message_bytes = message.encode("utf-8")
+
+            # Calculate remaining length
+            remaining = 2 + len(topic_bytes) + len(message_bytes)
+
+            # Build packet
+            packet = bytes([0x30])  # PUBLISH, QoS 0
+
+            # Encode remaining length
+            while remaining > 0:
+                byte = remaining % 128
+                remaining //= 128
+                if remaining > 0:
+                    byte |= 0x80
+                packet += bytes([byte])
+
+            # Topic length and topic
+            packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
+            packet += topic_bytes
+
+            # Message payload
+            packet += message_bytes
+
+            writer.write(packet)
+            await writer.drain()
+
+            logger.info(f"Sent initial status report on {topic}")
+
+        except Exception as e:
+            logger.error(f"Failed to send status report: {e}")
+
+    async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
+        """Send version info response to the slicer."""
+        try:
+            # Build version response matching OrcaSlicer expectations
+            # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
+            version_info = {
+                "info": {
+                    "command": "get_version",
+                    "sequence_id": sequence_id,
+                    "module": [
+                        {
+                            "name": "ota",
+                            "product_name": "X1 Carbon",
+                            "sw_ver": "01.07.00.00",
+                            "sw_new_ver": "",
+                            "hw_ver": "OTA",
+                            "sn": self.serial,
+                            "flag": 0,
+                        },
+                        {
+                            "name": "esp32",
+                            "product_name": "X1 Carbon",
+                            "sw_ver": "01.07.22.25",
+                            "sw_new_ver": "",
+                            "hw_ver": "AP05",
+                            "sn": self.serial,
+                            "flag": 0,
+                        },
+                        {
+                            "name": "rv1126",
+                            "product_name": "X1 Carbon",
+                            "sw_ver": "00.00.27.38",
+                            "sw_new_ver": "",
+                            "hw_ver": "AP05",
+                            "sn": self.serial,
+                            "flag": 0,
+                        },
+                        {
+                            "name": "th",
+                            "product_name": "X1 Carbon",
+                            "sw_ver": "00.00.04.00",
+                            "sw_new_ver": "",
+                            "hw_ver": "TH07",
+                            "sn": self.serial,
+                            "flag": 0,
+                        },
+                        {
+                            "name": "mc",
+                            "product_name": "X1 Carbon",
+                            "sw_ver": "00.00.10.00",
+                            "sw_new_ver": "",
+                            "hw_ver": "MC07",
+                            "sn": self.serial,
+                            "flag": 0,
+                        },
+                    ],
+                }
+            }
+
+            topic = f"device/{self.serial}/report"
+            message = json.dumps(version_info)
+
+            # Build MQTT PUBLISH packet
+            topic_bytes = topic.encode("utf-8")
+            message_bytes = message.encode("utf-8")
+
+            # Calculate remaining length
+            remaining = 2 + len(topic_bytes) + len(message_bytes)
+
+            # Build packet
+            packet = bytes([0x30])  # PUBLISH, QoS 0
+
+            # Encode remaining length
+            while remaining > 0:
+                byte = remaining % 128
+                remaining //= 128
+                if remaining > 0:
+                    byte |= 0x80
+                packet += bytes([byte])
+
+            # Topic length and topic
+            packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
+            packet += topic_bytes
+
+            # Message payload
+            packet += message_bytes
+
+            writer.write(packet)
+            await writer.drain()
+
+            logger.info(f"Sent version response on {topic}")
+
+        except Exception as e:
+            logger.error(f"Failed to send version response: {e}")
+
+    async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
+        """Handle MQTT PUBLISH packet."""
+        try:
+            # Parse topic
+            idx = 0
+            topic_len = (payload[idx] << 8) | payload[idx + 1]
+            idx += 2
+            topic = payload[idx : idx + topic_len].decode("utf-8")
+            idx += topic_len
+
+            # Check for packet ID (QoS > 0)
+            qos = (header & 0x06) >> 1
+            if qos > 0:
+                # packet_id = (payload[idx] << 8) | payload[idx + 1]
+                idx += 2
+
+            # Parse message
+            message = payload[idx:].decode("utf-8")
+
+            logger.info(f"MQTT publish to {topic}: {message[:100]}...")
+
+            # Handle commands on device request topic
+            if f"device/{self.serial}/request" in topic:
+                try:
+                    data = json.loads(message)
+
+                    # Handle pushing command (status request)
+                    if "pushing" in data:
+                        pushing_data = data["pushing"]
+                        command = pushing_data.get("command", "")
+                        logger.info(f"MQTT pushing command: {command}")
+
+                        if command == "pushall":
+                            # Slicer is requesting full status - send response
+                            logger.info("Sending status report in response to pushall")
+                            await self._send_status_report(writer)
+                        elif command == "start":
+                            # Slicer wants periodic status updates - send one now
+                            logger.info("Starting status push stream")
+                            await self._send_status_report(writer)
+
+                    # Handle info commands (get_version, etc.)
+                    if "info" in data:
+                        info_data = data["info"]
+                        command = info_data.get("command", "")
+                        sequence_id = info_data.get("sequence_id", "0")
+                        logger.info(f"MQTT info command: {command}")
+
+                        if command == "get_version":
+                            await self._send_version_response(writer, sequence_id)
+
+                    # Handle print commands
+                    if "print" in data:
+                        print_data = data["print"]
+                        command = print_data.get("command", "")
+                        filename = print_data.get("subtask_name", "")
+
+                        logger.info(f"MQTT print command: {command} for {filename}")
+
+                        if self.on_print_command and command == "project_file":
+                            await self._notify_print_command(filename, print_data)
+
+                except json.JSONDecodeError:
+                    pass
+
+        except Exception as e:
+            logger.debug(f"MQTT PUBLISH error: {e}")
+
+    async def _notify_print_command(self, filename: str, data: dict) -> None:
+        """Notify callback of print command."""
+        if self.on_print_command:
+            try:
+                result = self.on_print_command(filename, data)
+                if asyncio.iscoroutine(result):
+                    await result
+            except Exception as e:
+                logger.error(f"Print command callback error: {e}")

+ 270 - 0
backend/app/services/virtual_printer/ssdp_server.py

@@ -0,0 +1,270 @@
+"""SSDP discovery responder for virtual printer.
+
+Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
+announcements so the virtual printer appears as a discoverable Bambu printer.
+"""
+
+import asyncio
+import logging
+import socket
+import struct
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+# SSDP multicast address - Bambu uses port 2021
+SSDP_ADDR = "239.255.255.250"
+SSDP_PORT = 2021
+
+# Bambu service target
+BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
+
+
+class VirtualPrinterSSDPServer:
+    """SSDP server that responds to discovery requests as a virtual Bambu printer."""
+
+    def __init__(
+        self,
+        name: str = "Bambuddy",
+        serial: str = "00M09A391800001",  # X1C serial format for compatibility
+        model: str = "BL-P001",  # X1C model code for best compatibility
+    ):
+        """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)
+        """
+        self.name = name
+        self.serial = serial
+        self.model = model
+        self._running = False
+        self._socket: socket.socket | None = None
+        self._local_ip: str | None = None
+
+    def _get_local_ip(self) -> str:
+        """Get the local IP address to advertise."""
+        if self._local_ip:
+            return self._local_ip
+
+        # Try to determine local IP by connecting to a public address
+        try:
+            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+            s.connect(("8.8.8.8", 80))
+            ip = s.getsockname()[0]
+            s.close()
+            self._local_ip = ip
+            return ip
+        except Exception:
+            return "127.0.0.1"
+
+    def _build_notify_message(self) -> bytes:
+        """Build SSDP NOTIFY message for periodic announcements."""
+        ip = self._get_local_ip()
+        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
+        message = (
+            "NOTIFY * HTTP/1.1\r\n"
+            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
+            "Cache-Control: max-age=1800\r\n"
+            f"Location: {ip}\r\n"
+            f"NT: {BAMBU_SEARCH_TARGET}\r\n"
+            "NTS: ssdp:alive\r\n"
+            "EXT:\r\n"
+            f"USN: {self.serial}\r\n"
+            f"DevModel.bambu.com: {self.model}\r\n"
+            f"DevName.bambu.com: {self.name}\r\n"
+            "DevSignal.bambu.com: -44\r\n"
+            "DevConnect.bambu.com: lan\r\n"
+            "DevBind.bambu.com: free\r\n"
+            "Devseclink.bambu.com: secure\r\n"
+            "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "\r\n"
+        )
+        return message.encode()
+
+    def _build_response_message(self) -> bytes:
+        """Build SSDP response message for M-SEARCH requests."""
+        ip = self._get_local_ip()
+        # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+        # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
+        # Added: Devseclink, DevVersion, DevCap for better compatibility
+        message = (
+            "HTTP/1.1 200 OK\r\n"
+            "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
+            f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
+            f"Location: {ip}\r\n"
+            f"ST: {BAMBU_SEARCH_TARGET}\r\n"
+            "EXT:\r\n"
+            f"USN: {self.serial}\r\n"
+            "Cache-Control: max-age=1800\r\n"
+            f"DevModel.bambu.com: {self.model}\r\n"
+            f"DevName.bambu.com: {self.name}\r\n"
+            "DevSignal.bambu.com: -44\r\n"
+            "DevConnect.bambu.com: lan\r\n"
+            "DevBind.bambu.com: free\r\n"
+            "Devseclink.bambu.com: secure\r\n"
+            "DevVersion.bambu.com: 01.07.00.00\r\n"
+            "\r\n"
+        )
+        return message.encode()
+
+    async def start(self) -> None:
+        """Start the SSDP server."""
+        if self._running:
+            return
+
+        logger.info(f"Starting virtual printer SSDP server: {self.name} ({self.serial})")
+        self._running = True
+
+        try:
+            # Create UDP socket
+            self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+            # Try to set SO_REUSEPORT if available
+            try:
+                self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+
+            # Set non-blocking mode
+            self._socket.setblocking(False)
+
+            # Bind to SSDP port
+            self._socket.bind(("", SSDP_PORT))
+
+            # Join multicast group
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+
+            # Enable broadcast
+            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            # Set multicast TTL
+            self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
+
+            local_ip = self._get_local_ip()
+            logger.info(f"SSDP server listening on port {SSDP_PORT}, advertising IP: {local_ip}")
+            logger.info(f"Virtual printer: {self.name} ({self.serial}) model={self.model}")
+
+            # Send initial NOTIFY
+            await self._send_notify()
+            logger.info("Sent initial SSDP NOTIFY announcement")
+
+            # Run receive and announce loops
+            last_notify = asyncio.get_event_loop().time()
+            notify_interval = 30.0  # Send NOTIFY every 30 seconds
+
+            while self._running:
+                # Try to receive M-SEARCH requests
+                try:
+                    data, addr = self._socket.recvfrom(4096)
+                    message = data.decode("utf-8", errors="ignore")
+                    await self._handle_message(message, addr)
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    if self._running:
+                        logger.debug(f"SSDP receive error: {e}")
+
+                # Send periodic NOTIFY
+                now = asyncio.get_event_loop().time()
+                if now - last_notify >= notify_interval:
+                    await self._send_notify()
+                    last_notify = now
+
+                await asyncio.sleep(0.1)
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.warning(f"SSDP port {SSDP_PORT} in use - real printers may be running")
+            else:
+                logger.error(f"SSDP server error: {e}")
+        except asyncio.CancelledError:
+            logger.debug("SSDP server cancelled")
+        except Exception as e:
+            logger.error(f"SSDP server error: {e}")
+        finally:
+            await self._cleanup()
+
+    async def stop(self) -> None:
+        """Stop the SSDP server."""
+        logger.info("Stopping SSDP server")
+        self._running = False
+        await self._cleanup()
+
+    async def _cleanup(self) -> None:
+        """Clean up resources."""
+        if self._socket:
+            try:
+                # Send byebye message
+                await self._send_byebye()
+            except Exception:
+                pass
+
+            try:
+                self._socket.close()
+            except Exception:
+                pass
+            self._socket = None
+
+    async def _send_notify(self) -> None:
+        """Send SSDP NOTIFY message."""
+        if not self._socket:
+            return
+
+        try:
+            msg = self._build_notify_message()
+            self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
+            logger.debug(f"Sent SSDP NOTIFY for {self.name}")
+        except Exception as e:
+            logger.debug(f"Failed to send NOTIFY: {e}")
+
+    async def _send_byebye(self) -> None:
+        """Send SSDP byebye message when shutting down."""
+        if not self._socket:
+            return
+
+        message = (
+            "NOTIFY * HTTP/1.1\r\n"
+            f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+            f"NT: {BAMBU_SEARCH_TARGET}\r\n"
+            "NTS: ssdp:byebye\r\n"
+            f"USN: {self.serial}\r\n"
+            "\r\n"
+        )
+
+        try:
+            self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
+            logger.debug("Sent SSDP byebye")
+        except Exception:
+            pass
+
+    async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
+        """Handle incoming SSDP message.
+
+        Args:
+            message: The SSDP message content
+            addr: Tuple of (ip_address, port) of sender
+        """
+        # Check if this is an M-SEARCH request for Bambu printers
+        if "M-SEARCH" not in message:
+            return
+
+        # Check search target
+        if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
+            return
+
+        logger.debug(f"Received M-SEARCH from {addr[0]}")
+
+        # Send response
+        if self._socket:
+            try:
+                response = self._build_response_message()
+                self._socket.sendto(response, addr)
+                logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
+            except Exception as e:
+                logger.debug(f"Failed to send SSDP response: {e}")

+ 252 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -0,0 +1,252 @@
+"""Integration tests for Virtual Printer API endpoints.
+
+Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestVirtualPrinterSettingsAPI:
+    """Integration tests for /api/v1/settings/virtual-printer endpoints."""
+
+    # ========================================================================
+    # Get settings
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_virtual_printer_settings(self, async_client: AsyncClient):
+        """Verify virtual printer settings can be retrieved."""
+        response = await async_client.get("/api/v1/settings/virtual-printer")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "enabled" in result
+        assert "access_code_set" in result
+        assert "mode" in result
+        assert "status" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_settings_has_status(self, async_client: AsyncClient):
+        """Verify settings include status details."""
+        response = await async_client.get("/api/v1/settings/virtual-printer")
+
+        assert response.status_code == 200
+        result = response.json()
+        status = result["status"]
+        assert "enabled" in status
+        assert "running" in status
+        assert "mode" in status
+        assert "name" in status
+        assert "serial" in status
+        assert "pending_files" in status
+
+    # ========================================================================
+    # Update settings
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mode(self, async_client: AsyncClient):
+        """Verify mode can be updated."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mode"] == "queue"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mode_to_immediate(self, async_client: AsyncClient):
+        """Verify mode can be set to immediate."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mode"] == "immediate"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_access_code(self, async_client: AsyncClient):
+        """Verify access code can be set."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["access_code_set"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_access_code_wrong_length(self, async_client: AsyncClient):
+        """Verify access code validation for length."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123")
+
+        # Should fail validation
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_without_access_code(self, async_client: AsyncClient):
+        """Verify enabling fails without access code set."""
+        # First ensure no access code is set by checking current state
+        # Then try to enable
+        response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
+
+        # If access code wasn't set, this should fail
+        # If it was already set, it will succeed
+        # Both are valid test outcomes
+        assert response.status_code in [200, 400]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_with_access_code(self, async_client: AsyncClient):
+        """Verify enabling succeeds when access code is set."""
+        # First set access code
+        await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
+
+        # Then enable (this will start the servers which may fail in test env)
+        # We mock the manager to avoid actually starting servers
+        with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
+            mock_manager.configure = AsyncMock()
+            mock_manager.get_status = MagicMock(
+                return_value={
+                    "enabled": True,
+                    "running": True,
+                    "mode": "immediate",
+                    "name": "Bambuddy",
+                    "serial": "00M09A391800001",
+                    "pending_files": 0,
+                }
+            )
+
+            response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
+
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_virtual_printer(self, async_client: AsyncClient):
+        """Verify virtual printer can be disabled."""
+        with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
+            mock_manager.configure = AsyncMock()
+            mock_manager.get_status = MagicMock(
+                return_value={
+                    "enabled": False,
+                    "running": False,
+                    "mode": "immediate",
+                    "name": "Bambuddy",
+                    "serial": "00M09A391800001",
+                    "pending_files": 0,
+                }
+            )
+
+            response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["enabled"] is False
+
+
+class TestPendingUploadsAPI:
+    """Integration tests for /api/v1/pending-uploads/ endpoints."""
+
+    @pytest.fixture
+    def mock_pending_uploads(self, db_session):
+        """Create mock pending uploads in database."""
+
+        async def _create_pending(filename: str = "test.3mf"):
+            from datetime import datetime
+
+            from backend.app.models.pending_upload import PendingUpload
+
+            upload = PendingUpload(
+                filename=filename,
+                file_path=f"/tmp/{filename}",
+                file_size=1024,
+                source_ip="192.168.1.100",
+                status="pending",
+            )
+            db_session.add(upload)
+            await db_session.commit()
+            await db_session.refresh(upload)
+            return upload
+
+        return _create_pending
+
+    # ========================================================================
+    # List pending uploads
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
+        """Verify empty list is returned when no pending uploads."""
+        response = await async_client.get("/api/v1/pending-uploads/")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert isinstance(result, list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_pending_uploads_count(self, async_client: AsyncClient):
+        """Verify count endpoint returns correct count."""
+        response = await async_client.get("/api/v1/pending-uploads/count")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "count" in result
+        assert isinstance(result["count"], int)
+
+    # ========================================================================
+    # Archive pending upload
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
+        """Verify archiving non-existent upload returns 404."""
+        response = await async_client.post("/api/v1/pending-uploads/99999/archive")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Discard pending upload
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
+        """Verify discarding non-existent upload returns 404."""
+        response = await async_client.delete("/api/v1/pending-uploads/99999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Bulk operations
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_all_empty(self, async_client: AsyncClient):
+        """Verify archive all with no pending uploads."""
+        response = await async_client.post("/api/v1/pending-uploads/archive-all")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "archived" in result
+        assert "failed" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_discard_all_empty(self, async_client: AsyncClient):
+        """Verify discard all with no pending uploads."""
+        response = await async_client.delete("/api/v1/pending-uploads/discard-all")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "discarded" in result

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

@@ -0,0 +1,390 @@
+"""Unit tests for Virtual Printer services.
+
+Tests the virtual printer manager, FTP server, and SSDP server components.
+"""
+
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestVirtualPrinterManager:
+    """Tests for VirtualPrinterManager class."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a VirtualPrinterManager instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        return VirtualPrinterManager()
+
+    # ========================================================================
+    # Tests for configuration
+    # ========================================================================
+
+    @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",
+            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)
+
+    # ========================================================================
+    # Tests for status
+    # ========================================================================
+
+    def test_get_status_returns_correct_format(self, manager):
+        """Verify get_status returns expected fields."""
+        manager._enabled = True
+        manager._mode = "immediate"
+        manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
+        # Simulate running tasks
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+
+        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"] == "00M09A391800001"
+        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
+        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
+
+    # ========================================================================
+    # 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
+
+        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")
+
+            assert "test.3mf" in manager._pending_files
+            mock_queue.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_file_received_archives_immediately(self, manager):
+        """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")
+
+            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):
+        """Verify non-3MF files are skipped and cleaned up."""
+        manager._session_factory = MagicMock()
+        manager._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")
+
+            # Should be removed from pending
+            assert "verify_job" not in manager._pending_files
+
+
+class TestFTPSession:
+    """Tests for FTP session handling."""
+
+    @pytest.fixture
+    def mock_reader(self):
+        """Create a mock StreamReader."""
+        reader = AsyncMock()
+        return reader
+
+    @pytest.fixture
+    def mock_writer(self):
+        """Create a mock StreamWriter."""
+        writer = MagicMock()
+        writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
+        writer.write = MagicMock()
+        writer.drain = AsyncMock()
+        writer.close = MagicMock()
+        writer.wait_closed = AsyncMock()
+        writer.is_closing = MagicMock(return_value=False)
+        return writer
+
+    @pytest.fixture
+    def ssl_context(self):
+        """Create a mock SSL context."""
+        return MagicMock()
+
+    @pytest.fixture
+    def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
+        """Create an FTPSession instance."""
+        from backend.app.services.virtual_printer.ftp_server import FTPSession
+
+        return FTPSession(
+            reader=mock_reader,
+            writer=mock_writer,
+            upload_dir=tmp_path,
+            access_code="12345678",
+            ssl_context=ssl_context,
+            on_file_received=None,
+        )
+
+    # ========================================================================
+    # Tests for authentication
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_user_command_accepts_bblp(self, session):
+        """Verify USER command accepts bblp user."""
+        await session.cmd_USER("bblp")
+
+        assert session.username == "bblp"
+
+    @pytest.mark.asyncio
+    async def test_pass_command_authenticates(self, session):
+        """Verify PASS command authenticates with correct code."""
+        session.username = "bblp"
+
+        await session.cmd_PASS("12345678")
+
+        assert session.authenticated is True
+
+    @pytest.mark.asyncio
+    async def test_pass_command_rejects_wrong_code(self, session):
+        """Verify PASS command rejects wrong access code."""
+        session.username = "bblp"
+
+        await session.cmd_PASS("wrongcode")
+
+        assert session.authenticated is False
+
+    # ========================================================================
+    # Tests for FTP commands
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_syst_command(self, session):
+        """Verify SYST returns UNIX type."""
+        await session.cmd_SYST("")
+
+        session.writer.write.assert_called()
+        call_args = session.writer.write.call_args[0][0].decode()
+        assert "215" in call_args
+        assert "UNIX" in call_args
+
+    @pytest.mark.asyncio
+    async def test_pwd_command_requires_auth(self, session):
+        """Verify PWD requires authentication."""
+        session.authenticated = False
+
+        await session.cmd_PWD("")
+
+        call_args = session.writer.write.call_args[0][0].decode()
+        assert "530" in call_args
+
+    @pytest.mark.asyncio
+    async def test_pwd_command_when_authenticated(self, session):
+        """Verify PWD returns root directory when authenticated."""
+        session.authenticated = True
+
+        await session.cmd_PWD("")
+
+        call_args = session.writer.write.call_args[0][0].decode()
+        assert "257" in call_args
+
+    @pytest.mark.asyncio
+    async def test_type_command_sets_binary(self, session):
+        """Verify TYPE I sets binary mode."""
+        session.authenticated = True
+
+        await session.cmd_TYPE("I")
+
+        assert session.transfer_type == "I"
+
+    @pytest.mark.asyncio
+    async def test_pbsz_command(self, session):
+        """Verify PBSZ returns success."""
+        await session.cmd_PBSZ("0")
+
+        call_args = session.writer.write.call_args[0][0].decode()
+        assert "200" in call_args
+
+    @pytest.mark.asyncio
+    async def test_prot_command_accepts_p(self, session):
+        """Verify PROT P is accepted."""
+        await session.cmd_PROT("P")
+
+        call_args = session.writer.write.call_args[0][0].decode()
+        assert "200" in call_args
+
+    @pytest.mark.asyncio
+    async def test_quit_command(self, session):
+        """Verify QUIT sends goodbye and raises CancelledError."""
+        with pytest.raises(asyncio.CancelledError):
+            await session.cmd_QUIT("")
+
+
+class TestSSDPServer:
+    """Tests for Virtual Printer SSDP server."""
+
+    @pytest.fixture
+    def ssdp_server(self):
+        """Create a VirtualPrinterSSDPServer instance."""
+        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+
+        return VirtualPrinterSSDPServer(
+            serial="TEST123",
+            name="TestPrinter",
+            model="BL-P001",
+        )
+
+    # ========================================================================
+    # Tests for SSDP response
+    # ========================================================================
+
+    def test_build_notify_message(self, ssdp_server):
+        """Verify NOTIFY packet contains required headers."""
+        # Set a known IP for testing
+        ssdp_server._local_ip = "192.168.1.100"
+
+        message = ssdp_server._build_notify_message()
+
+        assert b"NOTIFY" in message
+        assert b"DevName.bambu.com: TestPrinter" in message
+        assert b"USN: TEST123" in message
+
+    def test_build_response_message(self, ssdp_server):
+        """Verify response packet contains required headers."""
+        # Set a known IP for testing
+        ssdp_server._local_ip = "192.168.1.100"
+
+        message = ssdp_server._build_response_message()
+
+        assert b"HTTP/1.1 200 OK" in message
+        assert b"DevName.bambu.com: TestPrinter" in message
+        assert b"USN: TEST123" in message
+
+    def test_ssdp_server_uses_correct_model(self, ssdp_server):
+        """Verify SSDP server uses the provided model."""
+        ssdp_server._local_ip = "192.168.1.100"
+
+        message = ssdp_server._build_notify_message()
+
+        assert b"DevModel.bambu.com: BL-P001" in message
+
+
+class TestCertificateService:
+    """Tests for TLS certificate generation."""
+
+    @pytest.fixture
+    def cert_service(self, tmp_path):
+        """Create a CertificateService instance."""
+        from backend.app.services.virtual_printer.certificate import CertificateService
+
+        return CertificateService(cert_dir=tmp_path, serial="TEST123")
+
+    def test_generate_certificates(self, cert_service, tmp_path):
+        """Verify certificates are generated correctly."""
+        cert_path, key_path = cert_service.generate_certificates()
+
+        assert cert_path.exists()
+        assert key_path.exists()
+
+        # Verify certificate content
+        cert_content = cert_path.read_text()
+        assert "BEGIN CERTIFICATE" in cert_content
+
+        key_content = key_path.read_text()
+        assert "BEGIN" in key_content and "KEY" in key_content
+
+    def test_certificates_reused_if_exist(self, cert_service):
+        """Verify existing certificates are reused."""
+        # First generation
+        cert_path1, key_path1 = cert_service.generate_certificates()
+        mtime1 = cert_path1.stat().st_mtime
+
+        # Second call should reuse (via ensure_certificates)
+        cert_path2, key_path2 = cert_service.ensure_certificates()
+        mtime2 = cert_path2.stat().st_mtime
+
+        assert mtime1 == mtime2  # File wasn't regenerated
+
+    def test_delete_certificates(self, cert_service):
+        """Verify certificates can be deleted."""
+        cert_service.generate_certificates()
+
+        assert cert_service.cert_path.exists()
+        assert cert_service.key_path.exists()
+
+        cert_service.delete_certificates()
+
+        assert not cert_service.cert_path.exists()
+        assert not cert_service.key_path.exists()
+
+    def test_ensure_creates_if_not_exist(self, cert_service):
+        """Verify ensure_certificates generates if not existing."""
+        assert not cert_service.cert_path.exists()
+
+        cert_path, key_path = cert_service.ensure_certificates()
+
+        assert cert_path.exists()
+        assert key_path.exists()

+ 2 - 0
docker-compose.yml

@@ -14,6 +14,7 @@ services:
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs
+      - bambuddy_vprinter:/app/virtual_printer
     environment:
     environment:
       - TZ=Europe/Berlin
       - TZ=Europe/Berlin
     restart: unless-stopped
     restart: unless-stopped
@@ -21,3 +22,4 @@ services:
 volumes:
 volumes:
   bambuddy_data:
   bambuddy_data:
   bambuddy_logs:
   bambuddy_logs:
+  bambuddy_vprinter:

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

@@ -0,0 +1,408 @@
+/**
+ * Tests for the VirtualPrinterSettings component.
+ *
+ * Tests the virtual printer configuration UI including:
+ * - Enable/disable toggle
+ * - Access code management
+ * - Archive mode selection
+ * - Status display
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  virtualPrinterApi: {
+    getSettings: vi.fn(),
+    updateSettings: vi.fn(),
+  },
+}));
+
+// Import mocked module
+import { virtualPrinterApi } from '../../api/client';
+
+// Mock data factory
+const createMockSettings = (overrides = {}) => ({
+  enabled: false,
+  access_code_set: false,
+  mode: 'immediate' as const,
+  status: {
+    enabled: false,
+    running: false,
+    mode: 'immediate',
+    name: 'Bambuddy',
+    serial: '00M09A391800001',
+    pending_files: 0,
+  },
+  ...overrides,
+});
+
+describe('VirtualPrinterSettings', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    // Default mock implementation
+    vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings());
+    vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings());
+  });
+
+  describe('rendering', () => {
+    it('renders loading state initially', () => {
+      // Delay the API response to catch loading state
+      vi.mocked(virtualPrinterApi.getSettings).mockImplementation(
+        () => new Promise(() => {}) // Never resolves
+      );
+      render(<VirtualPrinterSettings />);
+
+      // Should show loading spinner
+      expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+    });
+
+    it('renders component title', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Virtual Printer')).toBeInTheDocument();
+      });
+    });
+
+    it('renders enable toggle', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
+      });
+    });
+
+    it('renders access code section', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Access Code')).toBeInTheDocument();
+      });
+    });
+
+    it('renders archive mode section', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Archive Mode')).toBeInTheDocument();
+      });
+    });
+
+    it('renders how it works info', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('How it works:')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('status indicator', () => {
+    it('shows Stopped when not running', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ status: { ...createMockSettings().status, running: false } })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Stopped')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Running when active', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({
+          enabled: true,
+          status: { ...createMockSettings().status, enabled: true, running: true },
+        })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Running')).toBeInTheDocument();
+      });
+    });
+
+    it('shows status details when running', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({
+          enabled: true,
+          status: {
+            enabled: true,
+            running: true,
+            mode: 'immediate',
+            name: 'Bambuddy',
+            serial: '00M09A391800001',
+            pending_files: 0,
+          },
+        })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Status Details')).toBeInTheDocument();
+        expect(screen.getByText('Bambuddy')).toBeInTheDocument();
+        expect(screen.getByText('00M09A391800001')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('access code', () => {
+    it('shows warning when access code not set', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ access_code_set: false })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('No access code set - required to enable')).toBeInTheDocument();
+      });
+    });
+
+    it('shows success when access code is set', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ access_code_set: true })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Access code is set')).toBeInTheDocument();
+      });
+    });
+
+    it('shows character count while typing', async () => {
+      const user = userEvent.setup();
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Access Code')).toBeInTheDocument();
+      });
+
+      const input = screen.getByPlaceholderText('Enter 8-char code');
+      await user.type(input, '1234');
+
+      expect(screen.getByText('(4/8)')).toBeInTheDocument();
+    });
+
+    it('saves access code on button click', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+        createMockSettings({ access_code_set: true })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Access Code')).toBeInTheDocument();
+      });
+
+      const input = screen.getByPlaceholderText('Enter 8-char code');
+      await user.type(input, '12345678');
+
+      const saveButton = screen.getByRole('button', { name: 'Save' });
+      await user.click(saveButton);
+
+      await waitFor(() => {
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({
+          access_code: '12345678',
+        });
+      });
+    });
+
+    it('toggles password visibility', async () => {
+      const user = userEvent.setup();
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Access Code')).toBeInTheDocument();
+      });
+
+      const input = screen.getByPlaceholderText('Enter 8-char code');
+      expect(input).toHaveAttribute('type', 'password');
+
+      // Find and click the visibility toggle (eye icon button)
+      const toggleButtons = screen.getAllByRole('button');
+      const visibilityToggle = toggleButtons.find(
+        (btn) => btn.querySelector('svg') && btn.className.includes('absolute')
+      );
+
+      if (visibilityToggle) {
+        await user.click(visibilityToggle);
+        expect(input).toHaveAttribute('type', 'text');
+      }
+    });
+  });
+
+  describe('archive mode', () => {
+    it('renders immediate mode option', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Immediate')).toBeInTheDocument();
+        expect(
+          screen.getByText('Archive files as soon as they are uploaded')
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('renders queue mode option', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue for Review')).toBeInTheDocument();
+        expect(screen.getByText('Review and tag files before archiving')).toBeInTheDocument();
+      });
+    });
+
+    it('highlights current mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ mode: 'queue' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        const queueButton = screen.getByText('Queue for Review').closest('button');
+        expect(queueButton?.className).toContain('border-bambu-green');
+      });
+    });
+
+    it('changes mode on click', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+        createMockSettings({ mode: 'queue' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue for Review')).toBeInTheDocument();
+      });
+
+      const queueButton = screen.getByText('Queue for Review').closest('button');
+      if (queueButton) {
+        await user.click(queueButton);
+      }
+
+      await waitFor(() => {
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
+      });
+    });
+  });
+
+  describe('enable/disable toggle', () => {
+    it('cannot enable without access code', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: false, access_code_set: false })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
+      });
+
+      // Find the toggle switch (it's a button with relative class containing the slider)
+      const allButtons = screen.getAllByRole('button');
+      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
+
+      if (toggle) {
+        await user.click(toggle);
+      }
+
+      // Should not call update API (no access code set)
+      expect(virtualPrinterApi.updateSettings).not.toHaveBeenCalled();
+    });
+
+    it('can enable when access code is set', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: false, access_code_set: true })
+      );
+      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, access_code_set: true })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
+      });
+
+      // Find the toggle switch (it's a button with rounded-full and w-12 classes)
+      const allButtons = screen.getAllByRole('button');
+      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
+
+      expect(toggle).toBeDefined();
+      if (toggle) {
+        await user.click(toggle);
+      }
+
+      await waitFor(() => {
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
+          expect.objectContaining({ enabled: true })
+        );
+      });
+    });
+
+    it('can disable when enabled', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ enabled: true, access_code_set: true })
+      );
+      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+        createMockSettings({ enabled: false, access_code_set: true })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
+      });
+
+      // Find the toggle switch
+      const allButtons = screen.getAllByRole('button');
+      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
+
+      expect(toggle).toBeDefined();
+      if (toggle) {
+        await user.click(toggle);
+      }
+
+      await waitFor(() => {
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
+          expect.objectContaining({ enabled: false })
+        );
+      });
+    });
+  });
+
+  describe('info section', () => {
+    it('shows required ports warning', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Required ports: 2021.*8883.*990/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows iptables instructions', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/iptables -t nat -A PREROUTING/)).toBeInTheDocument();
+      });
+    });
+  });
+});

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

@@ -2342,3 +2342,77 @@ export const discoveryApi = {
   stopSubnetScan: () =>
   stopSubnetScan: () =>
     request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),
     request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),
 };
 };
+
+// Virtual Printer types
+export interface VirtualPrinterStatus {
+  enabled: boolean;
+  running: boolean;
+  mode: 'immediate' | 'queue';
+  name: string;
+  serial: string;
+  model: string;
+  pending_files: number;
+}
+
+export interface VirtualPrinterSettings {
+  enabled: boolean;
+  access_code_set: boolean;
+  mode: 'immediate' | 'queue';
+  status: VirtualPrinterStatus;
+}
+
+export interface PendingUpload {
+  id: number;
+  filename: string;
+  file_size: number;
+  source_ip: string | null;
+  status: string;
+  tags: string | null;
+  notes: string | null;
+  project_id: number | null;
+  uploaded_at: string;
+}
+
+// Virtual Printer API
+export const virtualPrinterApi = {
+  getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
+
+  updateSettings: (data: {
+    enabled?: boolean;
+    access_code?: string;
+    mode?: 'immediate' | 'queue';
+  }) => {
+    const params = new URLSearchParams();
+    if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
+    if (data.access_code !== undefined) params.set('access_code', data.access_code);
+    if (data.mode !== undefined) params.set('mode', data.mode);
+
+    return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
+      method: 'PUT',
+    });
+  },
+};
+
+// Pending Uploads API
+export const pendingUploadsApi = {
+  list: () => request<PendingUpload[]>('/pending-uploads/'),
+
+  getCount: () => request<{ count: number }>('/pending-uploads/count'),
+
+  get: (id: number) => request<PendingUpload>(`/pending-uploads/${id}`),
+
+  archive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) =>
+    request<{ id: number; print_name: string; filename: string }>(`/pending-uploads/${id}/archive`, {
+      method: 'POST',
+      body: JSON.stringify(data || {}),
+    }),
+
+  discard: (id: number) =>
+    request<{ success: boolean }>(`/pending-uploads/${id}`, { method: 'DELETE' }),
+
+  archiveAll: () =>
+    request<{ archived: number; failed: number }>('/pending-uploads/archive-all', { method: 'POST' }),
+
+  discardAll: () =>
+    request<{ discarded: number }>('/pending-uploads/discard-all', { method: 'DELETE' }),
+};

+ 9 - 1
frontend/src/components/BackupModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
-import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban } from 'lucide-react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban, Upload } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -95,6 +95,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     default: false,
     description: 'Projects, BOM items, and attachments',
     description: 'Projects, BOM items, and attachments',
   },
   },
+  {
+    id: 'pending_uploads',
+    labelKey: 'backup.categories.pendingUploads',
+    defaultLabel: 'Pending Uploads',
+    icon: <Upload className="w-4 h-4" />,
+    default: false,
+    description: 'Virtual printer uploads awaiting review',
+  },
 ];
 ];
 
 
 interface BackupModalProps {
 interface BackupModalProps {

+ 361 - 0
frontend/src/components/PendingUploadsPanel.tsx

@@ -0,0 +1,361 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
+import { pendingUploadsApi } from '../api/client';
+import type { PendingUpload, ProjectListItem } from '../api/client';
+import { api } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { ConfirmModal } from './ConfirmModal';
+
+function formatFileSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function formatTimeAgo(dateStr: string): string {
+  const date = new Date(dateStr);
+  const now = new Date();
+  const diffMs = now.getTime() - date.getTime();
+  const diffMins = Math.floor(diffMs / 60000);
+
+  if (diffMins < 1) return 'Just now';
+  if (diffMins < 60) return `${diffMins}m ago`;
+  const diffHours = Math.floor(diffMins / 60);
+  if (diffHours < 24) return `${diffHours}h ago`;
+  const diffDays = Math.floor(diffHours / 24);
+  return `${diffDays}d ago`;
+}
+
+interface PendingUploadItemProps {
+  upload: PendingUpload;
+  projects: ProjectListItem[];
+  onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void;
+  onDiscard: (id: number) => void;
+  isArchiving: boolean;
+  isDiscarding: boolean;
+}
+
+function PendingUploadItem({
+  upload,
+  projects,
+  onArchive,
+  onDiscard,
+  isArchiving,
+  isDiscarding,
+}: PendingUploadItemProps) {
+  const [expanded, setExpanded] = useState(false);
+  const [tags, setTags] = useState(upload.tags || '');
+  const [notes, setNotes] = useState(upload.notes || '');
+  const [projectId, setProjectId] = useState<number | null>(upload.project_id);
+
+  return (
+    <Card>
+      <CardContent className="py-3">
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-3">
+            <FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
+            <div>
+              <p className="text-white font-medium">{upload.filename}</p>
+              <div className="flex items-center gap-2 text-xs text-bambu-gray">
+                <span>{formatFileSize(upload.file_size)}</span>
+                <span>·</span>
+                <span className="flex items-center gap-1">
+                  <Clock className="w-3 h-3" />
+                  {formatTimeAgo(upload.uploaded_at)}
+                </span>
+                {upload.source_ip && (
+                  <>
+                    <span>·</span>
+                    <span>from {upload.source_ip}</span>
+                  </>
+                )}
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={() => setExpanded(!expanded)}
+              className="p-1 text-bambu-gray hover:text-white transition-colors"
+            >
+              {expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
+            </button>
+            <Button
+              variant="primary"
+              size="sm"
+              onClick={() => onArchive(upload.id, { tags, notes, project_id: projectId || undefined })}
+              disabled={isArchiving}
+            >
+              {isArchiving ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <>
+                  <Archive className="w-4 h-4" />
+                  Archive
+                </>
+              )}
+            </Button>
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => onDiscard(upload.id)}
+              disabled={isDiscarding}
+            >
+              {isDiscarding ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Trash2 className="w-4 h-4 text-red-400" />
+              )}
+            </Button>
+          </div>
+        </div>
+
+        {/* Expanded details for adding tags/notes/project */}
+        {expanded && (
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Tags</label>
+              <input
+                type="text"
+                value={tags}
+                onChange={(e) => setTags(e.target.value)}
+                placeholder="e.g., functional, prototype, gift"
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Notes</label>
+              <textarea
+                value={notes}
+                onChange={(e) => setNotes(e.target.value)}
+                placeholder="Add notes about this print..."
+                rows={2}
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Project</label>
+              <select
+                value={projectId || ''}
+                onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm"
+              >
+                <option value="">No project</option>
+                {projects.map((project) => (
+                  <option key={project.id} value={project.id}>
+                    {project.name}
+                  </option>
+                ))}
+              </select>
+            </div>
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+export function PendingUploadsPanel() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);
+  const [showDiscardAllConfirm, setShowDiscardAllConfirm] = useState(false);
+  const [archivingIds, setArchivingIds] = useState<Set<number>>(new Set());
+  const [discardingIds, setDiscardingIds] = useState<Set<number>>(new Set());
+
+  // Fetch pending uploads
+  const { data: uploads, isLoading: uploadsLoading } = useQuery({
+    queryKey: ['pending-uploads'],
+    queryFn: pendingUploadsApi.list,
+    refetchInterval: 10000, // Refresh every 10 seconds
+  });
+
+  // Fetch projects for dropdown
+  const { data: projects } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => api.getProjects(),
+  });
+
+  // Archive mutation
+  const archiveMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>
+      pendingUploadsApi.archive(id, data),
+    onMutate: ({ id }) => {
+      setArchivingIds((prev) => new Set(prev).add(id));
+    },
+    onSettled: (_, __, { id }) => {
+      setArchivingIds((prev) => {
+        const next = new Set(prev);
+        next.delete(id);
+        return next;
+      });
+    },
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Archived: ${data.print_name}`);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to archive', 'error');
+    },
+  });
+
+  // Discard mutation
+  const discardMutation = useMutation({
+    mutationFn: (id: number) => pendingUploadsApi.discard(id),
+    onMutate: (id) => {
+      setDiscardingIds((prev) => new Set(prev).add(id));
+    },
+    onSettled: (_, __, id) => {
+      setDiscardingIds((prev) => {
+        const next = new Set(prev);
+        next.delete(id);
+        return next;
+      });
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
+      showToast('Upload discarded');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to discard', 'error');
+    },
+  });
+
+  // Archive all mutation
+  const archiveAllMutation = useMutation({
+    mutationFn: pendingUploadsApi.archiveAll,
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to archive all', 'error');
+    },
+  });
+
+  // Discard all mutation
+  const discardAllMutation = useMutation({
+    mutationFn: pendingUploadsApi.discardAll,
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
+      showToast(`Discarded ${data.discarded} files`);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to discard all', 'error');
+    },
+  });
+
+  if (uploadsLoading) {
+    return (
+      <Card>
+        <CardContent className="py-8 flex justify-center">
+          <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  if (!uploads || uploads.length === 0) {
+    return null; // Don't render if no pending uploads
+  }
+
+  return (
+    <div className="mb-6">
+      <Card className="border-l-4 border-l-yellow-500">
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <Upload className="w-5 h-5 text-yellow-500" />
+              <h2 className="text-lg font-semibold text-white">
+                Pending Uploads ({uploads.length})
+              </h2>
+            </div>
+            <div className="flex items-center gap-2">
+              <Button
+                variant="primary"
+                size="sm"
+                onClick={() => setShowArchiveAllConfirm(true)}
+                disabled={archiveAllMutation.isPending}
+              >
+                {archiveAllMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <>
+                    <Archive className="w-4 h-4" />
+                    Archive All
+                  </>
+                )}
+              </Button>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => setShowDiscardAllConfirm(true)}
+                disabled={discardAllMutation.isPending}
+              >
+                {discardAllMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <>
+                    <Trash2 className="w-4 h-4" />
+                    Discard All
+                  </>
+                )}
+              </Button>
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <p className="text-sm text-bambu-gray mb-4">
+            These files were uploaded via the virtual printer. Review and archive them to add to your collection.
+          </p>
+          <div className="space-y-3">
+            {uploads.map((upload) => (
+              <PendingUploadItem
+                key={upload.id}
+                upload={upload}
+                projects={projects || []}
+                onArchive={(id, data) => archiveMutation.mutate({ id, data })}
+                onDiscard={(id) => discardMutation.mutate(id)}
+                isArchiving={archivingIds.has(upload.id)}
+                isDiscarding={discardingIds.has(upload.id)}
+              />
+            ))}
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Archive All Confirmation */}
+      {showArchiveAllConfirm && (
+        <ConfirmModal
+          title="Archive All Uploads"
+          message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}
+          confirmText="Archive All"
+          onConfirm={() => {
+            archiveAllMutation.mutate();
+            setShowArchiveAllConfirm(false);
+          }}
+          onCancel={() => setShowArchiveAllConfirm(false)}
+        />
+      )}
+
+      {/* Discard All Confirmation */}
+      {showDiscardAllConfirm && (
+        <ConfirmModal
+          title="Discard All Uploads"
+          message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}
+          confirmText="Discard All"
+          variant="danger"
+          onConfirm={() => {
+            discardAllMutation.mutate();
+            setShowDiscardAllConfirm(false);
+          }}
+          onCancel={() => setShowDiscardAllConfirm(false)}
+        />
+      )}
+    </div>
+  );
+}

+ 4 - 0
frontend/src/components/RestoreModal.tsx

@@ -31,6 +31,9 @@ const CATEGORY_LABELS: Record<string, string> = {
   filaments: 'Filaments',
   filaments: 'Filaments',
   maintenance_types: 'Maintenance Types',
   maintenance_types: 'Maintenance Types',
   archives: 'Archives',
   archives: 'Archives',
+  projects: 'Projects',
+  pending_uploads: 'Pending Uploads',
+  external_links: 'External Links',
 };
 };
 
 
 export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
 export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
@@ -194,6 +197,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
                         <li><strong>Notification Providers</strong> - matched by name</li>
                         <li><strong>Notification Providers</strong> - matched by name</li>
                         <li><strong>Filaments</strong> - matched by name + type + brand</li>
                         <li><strong>Filaments</strong> - matched by name + type + brand</li>
                         <li><strong>Archives</strong> - matched by content hash (always skipped)</li>
                         <li><strong>Archives</strong> - matched by content hash (always skipped)</li>
+                        <li><strong>Pending Uploads</strong> - matched by filename</li>
                         <li><strong>Settings & Templates</strong> - always overwritten</li>
                         <li><strong>Settings & Templates</strong> - always overwritten</li>
                       </ul>
                       </ul>
                     </div>
                     </div>

+ 307 - 0
frontend/src/components/VirtualPrinterSettings.tsx

@@ -0,0 +1,307 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info } from 'lucide-react';
+import { virtualPrinterApi } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+export function VirtualPrinterSettings() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [localEnabled, setLocalEnabled] = useState(false);
+  const [localAccessCode, setLocalAccessCode] = useState('');
+  const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
+  const [showAccessCode, setShowAccessCode] = useState(false);
+
+  // Fetch current settings
+  const { data: settings, isLoading } = useQuery({
+    queryKey: ['virtual-printer-settings'],
+    queryFn: virtualPrinterApi.getSettings,
+    refetchInterval: 10000, // Refresh every 10 seconds for status updates
+  });
+
+  // Initialize local state from settings
+  useEffect(() => {
+    if (settings) {
+      setLocalEnabled(settings.enabled);
+      setLocalMode(settings.mode);
+    }
+  }, [settings]);
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue' }) =>
+      virtualPrinterApi.updateSettings(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
+      showToast('Virtual printer settings updated');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to update settings', 'error');
+      // Revert local state on error
+      if (settings) {
+        setLocalEnabled(settings.enabled);
+        setLocalMode(settings.mode);
+      }
+    },
+  });
+
+  const handleToggleEnabled = () => {
+    const newEnabled = !localEnabled;
+
+    // If enabling, must have access code
+    if (newEnabled && !localAccessCode && !settings?.access_code_set) {
+      showToast('Please set an access code first', 'error');
+      return;
+    }
+
+    setLocalEnabled(newEnabled);
+    updateMutation.mutate({
+      enabled: newEnabled,
+      access_code: localAccessCode || undefined,
+      mode: localMode,
+    });
+  };
+
+  const handleAccessCodeChange = () => {
+    if (!localAccessCode) {
+      showToast('Access code cannot be empty', 'error');
+      return;
+    }
+
+    if (localAccessCode.length !== 8) {
+      showToast('Access code must be exactly 8 characters', 'error');
+      return;
+    }
+
+    updateMutation.mutate({
+      access_code: localAccessCode,
+    });
+    setLocalAccessCode(''); // Clear after saving
+  };
+
+  const handleModeChange = (mode: 'immediate' | 'queue') => {
+    setLocalMode(mode);
+    updateMutation.mutate({ mode });
+  };
+
+  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 status = settings?.status;
+  const isRunning = status?.running || false;
+
+  return (
+    <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
+      {/* Left Column - Settings */}
+      <div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
+      <Card>
+        <CardHeader>
+          <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">Virtual Printer</h2>
+            </div>
+            {status && (
+              <div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>
+                <span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
+                {isRunning ? 'Running' : 'Stopped'}
+              </div>
+            )}
+          </div>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <p className="text-sm text-bambu-gray">
+            Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer
+            will be archived directly without printing.
+          </p>
+
+          {/* Enable/Disable Toggle */}
+          <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
+            <div>
+              <div className="text-white font-medium">Enable Virtual Printer</div>
+              <div className="text-sm text-bambu-gray">
+                {isRunning ? 'Visible as "Bambuddy" in slicer discovery' : 'Not visible to slicers'}
+              </div>
+            </div>
+            <button
+              onClick={handleToggleEnabled}
+              disabled={updateMutation.isPending}
+              className={`relative w-12 h-6 rounded-full transition-colors ${
+                localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              } ${updateMutation.isPending ? 'opacity-50' : ''}`}
+            >
+              <span
+                className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
+                  localEnabled ? 'translate-x-6' : ''
+                }`}
+              />
+            </button>
+          </div>
+
+          {/* Access Code */}
+          <div className="py-3 border-t border-bambu-dark-tertiary">
+            <div className="text-white font-medium mb-2">Access Code</div>
+            <div className="text-sm text-bambu-gray mb-3">
+              {settings?.access_code_set ? (
+                <span className="flex items-center gap-1 text-green-400">
+                  <Check className="w-4 h-4" />
+                  Access code is set
+                </span>
+              ) : (
+                <span className="flex items-center gap-1 text-yellow-400">
+                  <AlertTriangle className="w-4 h-4" />
+                  No access code set - required to enable
+                </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={settings?.access_code_set ? 'Enter new code to change' : 'Enter 8-char code'}
+                  maxLength={8}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white 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 || updateMutation.isPending}
+                variant="primary"
+              >
+                {updateMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
+              </Button>
+            </div>
+            <p className="text-xs text-bambu-gray mt-2">
+              Must be exactly 8 characters. Used by slicers to authenticate.
+              {localAccessCode && (
+                <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
+                  {' '}({localAccessCode.length}/8)
+                </span>
+              )}
+            </p>
+          </div>
+
+          {/* Archive Mode */}
+          <div className="py-3 border-t border-bambu-dark-tertiary">
+            <div className="text-white font-medium mb-2">Archive Mode</div>
+            <div className="grid grid-cols-2 gap-3">
+              <button
+                onClick={() => handleModeChange('immediate')}
+                disabled={updateMutation.isPending}
+                className={`p-3 rounded-lg border text-left transition-colors ${
+                  localMode === 'immediate'
+                    ? 'border-bambu-green bg-bambu-green/10'
+                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                <div className="text-white font-medium">Immediate</div>
+                <div className="text-xs text-bambu-gray">Archive files as soon as they are uploaded</div>
+              </button>
+              <button
+                onClick={() => handleModeChange('queue')}
+                disabled={updateMutation.isPending}
+                className={`p-3 rounded-lg border text-left transition-colors ${
+                  localMode === 'queue'
+                    ? 'border-bambu-green bg-bambu-green/10'
+                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                <div className="text-white font-medium">Queue for Review</div>
+                <div className="text-xs text-bambu-gray">Review and tag files before archiving</div>
+              </button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+      </div>
+
+      {/* Right Column - Info & Status */}
+      <div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
+        {/* Info Card */}
+        <Card>
+          <CardContent className="py-4">
+            <div className="flex items-start gap-3">
+              <Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
+              <div className="text-sm text-bambu-gray">
+                <p className="mb-2">
+                  <strong className="text-white">How it works:</strong>
+                </p>
+                <ol className="list-decimal list-inside space-y-1">
+                  <li>Enable the virtual printer and set an access code</li>
+                  <li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
+                  <li>The "Bambuddy" printer should appear in the discovery list</li>
+                  <li>Connect using the access code you set</li>
+                  <li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
+                </ol>
+                <p className="mt-3 text-yellow-400/80">
+                  <AlertTriangle className="w-4 h-4 inline mr-1" />
+                  Required ports: 2021 (SSDP), 8883 (MQTT), 990 (FTP)
+                </p>
+                <div className="mt-2 text-xs text-bambu-gray space-y-1">
+                  <p>Port 990 requires root or iptables redirect:</p>
+                  <code className="block bg-bambu-dark-tertiary px-2 py-1 rounded text-[10px]">
+                    sudo iptables -t nat -A PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990
+                  </code>
+                  <code className="block bg-bambu-dark-tertiary px-2 py-1 rounded text-[10px]">
+                    sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990
+                  </code>
+                </div>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Status Details (when running) */}
+        {status && isRunning && (
+          <Card>
+            <CardHeader>
+              <h3 className="text-md font-semibold text-white">Status Details</h3>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-2 gap-4 text-sm">
+                <div>
+                  <div className="text-bambu-gray">Printer Name</div>
+                  <div className="text-white">{status.name}</div>
+                </div>
+                <div>
+                  <div className="text-bambu-gray">Model</div>
+                  <div className="text-white">{status.model?.replace('3DPrinter-', '').replace('-', ' ') || 'X1 Carbon'}</div>
+                </div>
+                <div>
+                  <div className="text-bambu-gray">Serial Number</div>
+                  <div className="text-white font-mono">{status.serial}</div>
+                </div>
+                <div>
+                  <div className="text-bambu-gray">Mode</div>
+                  <div className="text-white capitalize">{status.mode}</div>
+                </div>
+                <div>
+                  <div className="text-bambu-gray">Pending Files</div>
+                  <div className="text-white">{status.pending_files}</div>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        )}
+      </div>
+    </div>
+  );
+}

+ 4 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -60,6 +60,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { AddToQueueModal } from '../components/AddToQueueModal';
 import { AddToQueueModal } from '../components/AddToQueueModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
+import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
@@ -1722,6 +1723,9 @@ export function ArchivesPage() {
         </CardContent>
         </CardContent>
       </Card>
       </Card>
 
 
+      {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
+      <PendingUploadsPanel />
+
       {/* Archives */}
       {/* Archives */}
       {isLoading ? (
       {isLoading ? (
         <div className="text-center py-12 text-bambu-gray">Loading archives...</div>
         <div className="text-center py-12 text-bambu-gray">Loading archives...</div>

+ 55 - 4
frontend/src/pages/SettingsPage.tsx

@@ -1,8 +1,8 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
+import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -16,6 +16,8 @@ import { BackupModal } from '../components/BackupModal';
 import { RestoreModal } from '../components/RestoreModal';
 import { RestoreModal } from '../components/RestoreModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
+import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
+import { virtualPrinterApi } from '../api/client';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -33,7 +35,7 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
-  const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys'>('general');
+  const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys' | 'virtual-printer'>('general');
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -165,6 +167,14 @@ export function SettingsPage() {
     queryFn: api.getNotificationTemplates,
     queryFn: api.getNotificationTemplates,
   });
   });
 
 
+  // Virtual printer status for tab indicator
+  const { data: virtualPrinterSettings } = useQuery({
+    queryKey: ['virtual-printer-settings'],
+    queryFn: virtualPrinterApi.getSettings,
+    refetchInterval: 10000,
+  });
+  const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;
+
   const { data: ffmpegStatus } = useQuery({
   const { data: ffmpegStatus } = useQuery({
     queryKey: ['ffmpeg-status'],
     queryKey: ['ffmpeg-status'],
     queryFn: api.checkFfmpeg,
     queryFn: api.checkFfmpeg,
@@ -332,7 +342,28 @@ export function SettingsPage() {
 
 
     // Set new debounced save (500ms delay)
     // Set new debounced save (500ms delay)
     saveTimeoutRef.current = setTimeout(() => {
     saveTimeoutRef.current = setTimeout(() => {
-      updateMutation.mutate(localSettings);
+      // Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately)
+      const settingsToSave: AppSettingsUpdate = {
+        auto_archive: localSettings.auto_archive,
+        save_thumbnails: localSettings.save_thumbnails,
+        capture_finish_photo: localSettings.capture_finish_photo,
+        default_filament_cost: localSettings.default_filament_cost,
+        currency: localSettings.currency,
+        energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
+        energy_tracking_mode: localSettings.energy_tracking_mode,
+        check_updates: localSettings.check_updates,
+        notification_language: localSettings.notification_language,
+        telemetry_enabled: localSettings.telemetry_enabled,
+        ams_humidity_good: localSettings.ams_humidity_good,
+        ams_humidity_fair: localSettings.ams_humidity_fair,
+        ams_temp_good: localSettings.ams_temp_good,
+        ams_temp_fair: localSettings.ams_temp_fair,
+        ams_history_retention_days: localSettings.ams_history_retention_days,
+        date_format: localSettings.date_format,
+        time_format: localSettings.time_format,
+        default_printer_id: localSettings.default_printer_id,
+      };
+      updateMutation.mutate(settingsToSave);
     }, 500);
     }, 500);
 
 
     // Cleanup on unmount or when localSettings changes again
     // Cleanup on unmount or when localSettings changes again
@@ -422,6 +453,18 @@ export function SettingsPage() {
             </span>
             </span>
           )}
           )}
         </button>
         </button>
+        <button
+          onClick={() => setActiveTab('virtual-printer')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+            activeTab === 'virtual-printer'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-white border-transparent'
+          }`}
+        >
+          <Printer className="w-4 h-4" />
+          Virtual Printer
+          <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
+        </button>
       </div>
       </div>
 
 
       {/* General Tab */}
       {/* General Tab */}
@@ -1725,6 +1768,11 @@ export function SettingsPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {/* Virtual Printer Tab */}
+      {activeTab === 'virtual-printer' && (
+        <VirtualPrinterSettings />
+      )}
+
       {/* Delete API Key Confirmation */}
       {/* Delete API Key Confirmation */}
       {showDeleteAPIKeyConfirm !== null && (
       {showDeleteAPIKeyConfirm !== null && (
         <ConfirmModal
         <ConfirmModal
@@ -1878,6 +1926,9 @@ export function SettingsPage() {
             return await api.importBackup(file, overwrite);
             return await api.importBackup(file, overwrite);
           }}
           }}
           onSuccess={() => {
           onSuccess={() => {
+            // Reset local settings to force re-sync from restored data
+            setLocalSettings(null);
+            isInitialLoadRef.current = true;
             queryClient.invalidateQueries();
             queryClient.invalidateQueries();
           }}
           }}
         />
         />

+ 4 - 0
requirements.txt

@@ -15,6 +15,10 @@ pydantic-settings>=2.0.0
 paho-mqtt>=2.0.0
 paho-mqtt>=2.0.0
 aioftp>=0.22.0
 aioftp>=0.22.0
 
 
+# Virtual Printer (emulates Bambu printer for slicer uploads)
+pyftpdlib>=2.0.0
+cryptography>=41.0.0
+
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 
 
 # Excel Export
 # Excel Export

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BOEy1ke3.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CYUCozUo.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DUGbV7GF.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bv0AILUD.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BOEy1ke3.css">
+    <script type="module" crossorigin src="/assets/index-CYUCozUo.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DUGbV7GF.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов