- 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)
This commit is contained in:
maziggy
2025-12-29 15:52:48 +01:00
parent c3c36719a5
commit 88e84ca45a
31 changed files with 5068 additions and 439 deletions
+3
View File
@@ -31,6 +31,9 @@ npm-debug.log*
# Archive files (user data)
archive/
# Virtual printer (auto-generated certs and uploads)
virtual_printer/
# IDE
.idea/
.vscode/
+14
View File
@@ -2,6 +2,20 @@
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
### Added
+7
View File
@@ -92,6 +92,13 @@
- External sidebar links
- 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 scheduling & tracking
- Interval reminders (hours/days)
+249
View File
@@ -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
View File
@@ -17,6 +17,7 @@ from backend.app.models.filament import Filament
from backend.app.models.maintenance import MaintenanceType
from backend.app.models.notification import NotificationProvider
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_bom import ProjectBOMItem
@@ -70,6 +71,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
"spoolman_enabled",
"check_updates",
"telemetry_enabled",
"virtual_printer_enabled",
]:
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"]:
@@ -181,6 +183,7 @@ async def export_backup(
include_maintenance: bool = Query(False, description="Include maintenance types and records"),
include_archives: bool = Query(False, description="Include print archive metadata"),
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!)"),
):
"""Export selected data as JSON backup."""
@@ -510,6 +513,36 @@ async def export_backup(
backup["projects"].append(project_data)
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 backup_files:
zip_buffer = io.BytesIO()
@@ -599,6 +632,7 @@ async def import_backup(
"filaments": 0,
"maintenance_types": 0,
"projects": 0,
"pending_uploads": 0,
}
skipped = {
"settings": 0,
@@ -611,6 +645,7 @@ async def import_backup(
"maintenance_types": 0,
"archives": 0,
"projects": 0,
"pending_uploads": 0,
}
skipped_details = {
"notification_providers": [],
@@ -621,6 +656,7 @@ async def import_backup(
"maintenance_types": [],
"archives": [],
"projects": [],
"pending_uploads": [],
}
# Restore settings (always overwrites)
@@ -1077,6 +1113,82 @@ async def import_backup(
if archive:
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()
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1104,6 +1216,33 @@ async def import_backup(
except Exception:
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
restored_parts = []
for key, count in restored.items():
@@ -1134,3 +1273,94 @@ async def import_backup(
"files_restored": files_restored,
"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
View File
@@ -63,6 +63,7 @@ from backend.app.api.routes import (
maintenance,
notification_templates,
notifications,
pending_uploads,
print_queue,
printers,
projects,
@@ -1491,6 +1492,31 @@ async def lifespan(app: FastAPI):
# Start anonymous telemetry (opt-out via settings)
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
# Shutdown
@@ -1501,6 +1527,10 @@ async def lifespan(app: FastAPI):
printer_manager.disconnect_all()
await close_spoolman_client()
# Stop virtual printer if running
if virtual_printer_manager.is_enabled:
await virtual_printer_manager.configure(enabled=False)
app = FastAPI(
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(websocket.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)
+10 -8
View File
@@ -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.filament import Filament
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
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.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.notification_template import NotificationTemplate
from backend.app.models.notification import NotificationLog
from backend.app.models.project import Project
from backend.app.models.api_key import APIKey
from backend.app.models.ams_history import AMSSensorHistory
__all__ = [
"Printer",
@@ -26,4 +27,5 @@ __all__ = [
"Project",
"APIKey",
"AMSSensorHistory",
"PendingUpload",
]
+47
View File
@@ -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
View File
@@ -6,16 +6,23 @@ class AppSettings(BaseModel):
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")
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")
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_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_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_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
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_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_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")
# Date/time display format
@@ -40,6 +51,11 @@ class AppSettings(BaseModel):
# Telemetry
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):
"""Schema for updating settings (all fields optional)."""
@@ -65,3 +81,6 @@ class AppSettingsUpdate(BaseModel):
time_format: str | None = None
default_printer_id: int | 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
@@ -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"]
@@ -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")
@@ -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
@@ -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()
@@ -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}")
@@ -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}")
@@ -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
@@ -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
View File
@@ -14,6 +14,7 @@ services:
volumes:
- bambuddy_data:/app/data
- bambuddy_logs:/app/logs
- bambuddy_vprinter:/app/virtual_printer
environment:
- TZ=Europe/Berlin
restart: unless-stopped
@@ -21,3 +22,4 @@ services:
volumes:
bambuddy_data:
bambuddy_logs:
bambuddy_vprinter:
@@ -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
View File
@@ -2342,3 +2342,77 @@ export const discoveryApi = {
stopSubnetScan: () =>
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
View File
@@ -1,5 +1,5 @@
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 { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -95,6 +95,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
default: false,
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 {
@@ -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
View File
@@ -31,6 +31,9 @@ const CATEGORY_LABELS: Record<string, string> = {
filaments: 'Filaments',
maintenance_types: 'Maintenance Types',
archives: 'Archives',
projects: 'Projects',
pending_uploads: 'Pending Uploads',
external_links: 'External Links',
};
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>Filaments</strong> - matched by name + type + brand</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>
</ul>
</div>
@@ -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
View File
@@ -60,6 +60,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
import { TimelapseViewer } from '../components/TimelapseViewer';
import { AddToQueueModal } from '../components/AddToQueueModal';
import { CompareArchivesModal } from '../components/CompareArchivesModal';
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
import { useToast } from '../contexts/ToastContext';
function formatFileSize(bytes: number): string {
@@ -1722,6 +1723,9 @@ export function ArchivesPage() {
</CardContent>
</Card>
{/* Pending Uploads Panel (visible when in queue mode with pending files) */}
<PendingUploadsPanel />
{/* Archives */}
{isLoading ? (
<div className="text-center py-12 text-bambu-gray">Loading archives...</div>
+55 -4
View File
@@ -1,8 +1,8 @@
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 { 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 { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -16,6 +16,8 @@ import { BackupModal } from '../components/BackupModal';
import { RestoreModal } from '../components/RestoreModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
import { virtualPrinterApi } from '../api/client';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { availableLanguages } from '../i18n';
import { useToast } from '../contexts/ToastContext';
@@ -33,7 +35,7 @@ export function SettingsPage() {
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
const [showLogViewer, setShowLogViewer] = useState(false);
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 [newAPIKeyName, setNewAPIKeyName] = useState('');
const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -165,6 +167,14 @@ export function SettingsPage() {
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({
queryKey: ['ffmpeg-status'],
queryFn: api.checkFfmpeg,
@@ -332,7 +342,28 @@ export function SettingsPage() {
// Set new debounced save (500ms delay)
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);
// Cleanup on unmount or when localSettings changes again
@@ -422,6 +453,18 @@ export function SettingsPage() {
</span>
)}
</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>
{/* General Tab */}
@@ -1725,6 +1768,11 @@ export function SettingsPage() {
</div>
)}
{/* Virtual Printer Tab */}
{activeTab === 'virtual-printer' && (
<VirtualPrinterSettings />
)}
{/* Delete API Key Confirmation */}
{showDeleteAPIKeyConfirm !== null && (
<ConfirmModal
@@ -1878,6 +1926,9 @@ export function SettingsPage() {
return await api.importBackup(file, overwrite);
}}
onSuccess={() => {
// Reset local settings to force re-sync from restored data
setLocalSettings(null);
isInitialLoadRef.current = true;
queryClient.invalidateQueries();
}}
/>
+4
View File
@@ -15,6 +15,10 @@ pydantic-settings>=2.0.0
paho-mqtt>=2.0.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)
# Excel Export
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-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>
<body>
<div id="root"></div>