diff --git a/.gitignore b/.gitignore index b14b85e7d..a176a26e9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ npm-debug.log* # Archive files (user data) archive/ +# Virtual printer (auto-generated certs and uploads) +virtual_printer/ + # IDE .idea/ .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7533d0b..3a01c3ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index caab89cb6..913b8496d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/app/api/routes/pending_uploads.py b/backend/app/api/routes/pending_uploads.py new file mode 100644 index 000000000..b56b4c09a --- /dev/null +++ b/backend/app/api/routes/pending_uploads.py @@ -0,0 +1,249 @@ +"""API routes for pending uploads (virtual printer queue mode).""" + +from datetime import UTC, datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.database import get_db +from backend.app.models.pending_upload import PendingUpload +from backend.app.services.archive import ArchiveService + +router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"]) + + +class ArchiveRequest(BaseModel): + """Request to archive a pending upload.""" + + tags: str | None = None + notes: str | None = None + project_id: int | None = None + + +class PendingUploadResponse(BaseModel): + """Response model for pending upload.""" + + id: int + filename: str + file_size: int + source_ip: str | None + status: str + tags: str | None + notes: str | None + project_id: int | None + uploaded_at: datetime + + class Config: + from_attributes = True + + +@router.get("/", response_model=list[PendingUploadResponse]) +async def list_pending_uploads(db: AsyncSession = Depends(get_db)): + """List all pending uploads.""" + result = await db.execute( + select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc()) + ) + + return result.scalars().all() + + +@router.get("/count") +async def get_pending_count(db: AsyncSession = Depends(get_db)): + """Get count of pending uploads.""" + result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending")) + count = len(result.scalars().all()) + + return {"count": count} + + +# Note: Bulk operations must be defined BEFORE parameterized routes +# to prevent FastAPI from matching /archive-all as /{upload_id} + + +@router.post("/archive-all") +async def archive_all_pending(db: AsyncSession = Depends(get_db)): + """Archive all pending uploads.""" + result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending")) + pending_uploads = result.scalars().all() + + archived = 0 + failed = 0 + + service = ArchiveService(db) + + for pending in pending_uploads: + file_path = Path(pending.file_path) + if not file_path.exists(): + pending.status = "discarded" + failed += 1 + continue + + try: + archive = await service.archive_print( + printer_id=None, + source_file=file_path, + print_data={ + "status": "archived", + "source": "virtual_printer", + "source_ip": pending.source_ip, + }, + ) + + if archive: + pending.status = "archived" + pending.archived_id = archive.id + pending.archived_at = datetime.now(UTC) + archived += 1 + + # Clean up temp file + try: + file_path.unlink() + except Exception: + pass + else: + failed += 1 + except Exception: + failed += 1 + + await db.commit() + + return { + "archived": archived, + "failed": failed, + } + + +@router.delete("/discard-all") +async def discard_all_pending(db: AsyncSession = Depends(get_db)): + """Discard all pending uploads.""" + result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending")) + pending_uploads = result.scalars().all() + + discarded = 0 + + for pending in pending_uploads: + # Delete file from disk + try: + file_path = Path(pending.file_path) + file_path.unlink(missing_ok=True) + except Exception: + pass + + pending.status = "discarded" + discarded += 1 + + await db.commit() + + return {"discarded": discarded} + + +@router.get("/{upload_id}", response_model=PendingUploadResponse) +async def get_pending_upload( + upload_id: int, + db: AsyncSession = Depends(get_db), +): + """Get a specific pending upload.""" + result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id)) + pending = result.scalar_one_or_none() + + if not pending: + raise HTTPException(status_code=404, detail="Upload not found") + + return pending + + +@router.post("/{upload_id}/archive") +async def archive_pending_upload( + upload_id: int, + request: ArchiveRequest = None, + db: AsyncSession = Depends(get_db), +): + """Archive a pending upload.""" + result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id)) + pending = result.scalar_one_or_none() + + if not pending: + raise HTTPException(status_code=404, detail="Upload not found") + if pending.status != "pending": + raise HTTPException(status_code=400, detail="Upload already processed") + + # Check file exists + file_path = Path(pending.file_path) + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Upload file not found on disk") + + # Archive the file + service = ArchiveService(db) + archive = await service.archive_print( + printer_id=None, + source_file=file_path, + print_data={ + "status": "archived", + "source": "virtual_printer", + "source_ip": pending.source_ip, + }, + ) + + if not archive: + raise HTTPException(status_code=500, detail="Failed to archive file") + + # Apply tags/notes/project from request + if request: + if request.tags: + archive.tags = request.tags + if request.notes: + archive.notes = request.notes + if request.project_id: + archive.project_id = request.project_id + + # Update pending record + pending.status = "archived" + pending.archived_id = archive.id + pending.archived_at = datetime.now(UTC) + if request: + pending.tags = request.tags + pending.notes = request.notes + pending.project_id = request.project_id + + await db.commit() + + # Clean up temp file + try: + file_path.unlink() + except Exception: + pass + + return { + "id": archive.id, + "print_name": archive.print_name, + "filename": archive.filename, + } + + +@router.delete("/{upload_id}") +async def discard_pending_upload( + upload_id: int, + db: AsyncSession = Depends(get_db), +): + """Discard a pending upload without archiving.""" + result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id)) + pending = result.scalar_one_or_none() + + if not pending: + raise HTTPException(status_code=404, detail="Upload not found") + + # Delete file from disk + file_path = Path(pending.file_path) + try: + file_path.unlink(missing_ok=True) + except Exception: + pass + + # Update status + pending.status = "discarded" + await db.commit() + + return {"success": True} diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 4c62fced9..50ebae51c 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -17,6 +17,7 @@ from backend.app.models.filament import Filament from backend.app.models.maintenance import MaintenanceType from backend.app.models.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) diff --git a/backend/app/main.py b/backend/app/main.py index 4ff2d3db6..249bcb77b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5889627f2..a6d7bda5c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,15 +1,16 @@ -from backend.app.models.printer import Printer +from backend.app.models.ams_history import AMSSensorHistory +from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.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", ] diff --git a/backend/app/models/pending_upload.py b/backend/app/models/pending_upload.py new file mode 100644 index 000000000..937a454e8 --- /dev/null +++ b/backend/app/models/pending_upload.py @@ -0,0 +1,47 @@ +"""Pending upload model for virtual printer queue mode.""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class PendingUpload(Base): + """Pending upload from virtual printer awaiting user review.""" + + __tablename__ = "pending_uploads" + + id: Mapped[int] = mapped_column(primary_key=True) + + # File info + filename: Mapped[str] = mapped_column(String(255)) + file_path: Mapped[str] = mapped_column(String(500)) # Temp storage path + file_size: Mapped[int] = mapped_column(Integer) + + # Source info + source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) + + # Status: pending, archived, discarded + status: Mapped[str] = mapped_column(String(20), default="pending") + + # User additions (before archiving) + tags: Mapped[str | None] = mapped_column(Text, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) + + # After archiving - link to created archive + archived_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True) + + # Timestamps + uploaded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Relationships + project: Mapped["Project | None"] = relationship() + archive: Mapped["PrintArchive | None"] = relationship() + + +from backend.app.models.archive import PrintArchive # noqa: E402 +from backend.app.models.project import Project # noqa: E402 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 7a2ab3414..a1e167162 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -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 diff --git a/backend/app/services/virtual_printer/__init__.py b/backend/app/services/virtual_printer/__init__.py new file mode 100644 index 000000000..cbd79de43 --- /dev/null +++ b/backend/app/services/virtual_printer/__init__.py @@ -0,0 +1,5 @@ +"""Virtual printer services for slicer integration.""" + +from backend.app.services.virtual_printer.manager import virtual_printer_manager + +__all__ = ["virtual_printer_manager"] diff --git a/backend/app/services/virtual_printer/certificate.py b/backend/app/services/virtual_printer/certificate.py new file mode 100644 index 000000000..9d5c0bdb5 --- /dev/null +++ b/backend/app/services/virtual_printer/certificate.py @@ -0,0 +1,254 @@ +"""TLS certificate generation for virtual printer services. + +Generates certificates that mimic real Bambu printer certificate format: +- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd" +- Printer certificate has CN = serial number, signed by the CA +""" + +import logging +import socket +from datetime import UTC, datetime, timedelta +from ipaddress import IPv4Address +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +logger = logging.getLogger(__name__) + +# Default serial number for virtual printer (matches SSDP/MQTT config) +DEFAULT_SERIAL = "00M09A391800001" + + +def _get_local_ip() -> str: + """Get the local IP address.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + +class CertificateService: + """Generate and manage TLS certificates for virtual printer. + + Creates a certificate chain mimicking real Bambu printers: + - Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN" + - Printer cert with CN=serial_number, signed by the CA + """ + + def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL): + """Initialize the certificate service. + + Args: + cert_dir: Directory to store certificates + serial: Serial number to use as CN in printer certificate + """ + self.cert_dir = cert_dir + self.serial = serial + self.ca_cert_path = cert_dir / "bbl_ca.crt" + self.ca_key_path = cert_dir / "bbl_ca.key" + self.cert_path = cert_dir / "virtual_printer.crt" + self.key_path = cert_dir / "virtual_printer.key" + + def ensure_certificates(self) -> tuple[Path, Path]: + """Ensure certificates exist, generate if needed. + + Returns: + Tuple of (cert_path, key_path) + """ + if self.cert_path.exists() and self.key_path.exists(): + logger.debug("Using existing virtual printer certificates") + return self.cert_path, self.key_path + return self.generate_certificates() + + def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]: + """Generate a CA certificate for the virtual printer. + + We use a generic name instead of mimicking BBL CA, since the slicer + may specifically reject certificates claiming to be from BBL but + with a different public key. + + Returns: + Tuple of (ca_private_key, ca_certificate) + """ + logger.info("Generating Virtual Printer CA certificate...") + + # Generate CA private key + ca_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Use a generic CA name - NOT BBL to avoid being rejected as fake + ca_name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"), + ] + ) + + now = datetime.now(UTC) + + ca_cert = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + timedelta(days=7300)) # 20 years + .add_extension( + x509.BasicConstraints(ca=True, path_length=0), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(ca_key, hashes.SHA256()) + ) + + return ca_key, ca_cert + + def generate_certificates(self) -> tuple[Path, Path]: + """Generate CA and printer certificates. + + Creates a certificate chain mimicking real Bambu printers: + - BBL CA (self-signed root) + - Printer certificate (CN=serial, signed by BBL CA) + + Returns: + Tuple of (cert_path, key_path) + """ + logger.info(f"Generating certificates for virtual printer (serial: {self.serial})...") + + # Ensure directory exists + self.cert_dir.mkdir(parents=True, exist_ok=True) + + # Generate or load CA + ca_key, ca_cert = self._generate_ca_certificate() + + # Save CA certificate and key + self.ca_key_path.write_bytes( + ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + self.ca_key_path.chmod(0o600) + self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM)) + + # Generate printer private key + printer_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Printer certificate subject - CN is the serial number (like real Bambu printers) + printer_subject = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, self.serial), + ] + ) + + # Issuer is the CA + issuer = ca_cert.subject + + now = datetime.now(UTC) + local_ip = _get_local_ip() + logger.info(f"Generating printer certificate with CN={self.serial}, local IP: {local_ip}") + + # Build printer certificate signed by CA + printer_cert = ( + x509.CertificateBuilder() + .subject_name(printer_subject) + .issuer_name(issuer) + .public_key(printer_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + timedelta(days=3650)) # 10 years + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.DNSName("bambuddy"), + x509.DNSName(self.serial), + x509.IPAddress(IPv4Address(local_ip)), + x509.IPAddress(IPv4Address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension( + x509.ExtendedKeyUsage( + [ + ExtendedKeyUsageOID.SERVER_AUTH, + ExtendedKeyUsageOID.CLIENT_AUTH, + ] + ), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=True, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed + ) + + # Write printer private key + self.key_path.write_bytes( + printer_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + self.key_path.chmod(0o600) + + # Write printer certificate (include CA cert in chain for full chain) + cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes( + serialization.Encoding.PEM + ) + self.cert_path.write_bytes(cert_chain) + + logger.info(f"Generated certificate chain at {self.cert_dir}") + logger.info(" CA: CN=Virtual Printer CA") + logger.info(f" Printer: CN={self.serial}") + return self.cert_path, self.key_path + + def delete_certificates(self) -> None: + """Delete existing certificates.""" + for path in [self.cert_path, self.key_path, self.ca_cert_path, self.ca_key_path]: + if path.exists(): + path.unlink() + logger.info("Deleted virtual printer certificates") diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py new file mode 100644 index 000000000..ac8c5a90b --- /dev/null +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -0,0 +1,511 @@ +"""Implicit FTPS server for receiving 3MF uploads from slicers. + +Implements an implicit FTPS server (TLS from byte 0) that accepts file uploads +from Bambu Studio and OrcaSlicer, matching the real Bambu printer behavior. + +Unlike explicit FTPS (AUTH TLS), implicit FTPS wraps the connection in TLS +immediately upon connection, before any FTP commands are exchanged. +""" + +import asyncio +import logging +import random +import ssl +from collections.abc import Callable +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Default FTP port for Bambu printers (implicit FTPS) +FTP_PORT = 9990 + + +class FTPSession: + """Handles a single FTP client session.""" + + def __init__( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + upload_dir: Path, + access_code: str, + ssl_context: ssl.SSLContext, + on_file_received: Callable[[Path, str], None] | None, + ): + self.reader = reader + self.writer = writer + self.upload_dir = upload_dir + self.access_code = access_code + self.ssl_context = ssl_context + self.on_file_received = on_file_received + + self.authenticated = False + self.username: str | None = None + self.current_dir = upload_dir + self.transfer_type = "A" # ASCII by default + self.data_server: asyncio.Server | None = None + self.data_port: int | None = None + + # For data transfer coordination + self._data_reader: asyncio.StreamReader | None = None + self._data_writer: asyncio.StreamWriter | None = None + self._data_connected = asyncio.Event() + + peername = writer.get_extra_info("peername") + self.remote_ip = peername[0] if peername else "unknown" + + async def send(self, code: int, message: str) -> None: + """Send an FTP response.""" + response = f"{code} {message}\r\n" + logger.info(f"FTP -> {self.remote_ip}: {response.strip()}") + self.writer.write(response.encode("utf-8")) + await self.writer.drain() + + async def handle(self) -> None: + """Handle the FTP session.""" + try: + # Send welcome banner + await self.send(220, "Bambuddy Virtual Printer FTP ready") + + while True: + try: + line = await asyncio.wait_for( + self.reader.readline(), + timeout=300, # 5 minute timeout + ) + except TimeoutError: + logger.debug(f"FTP session timeout from {self.remote_ip}") + break + + if not line: + break + + try: + command_line = line.decode("utf-8").strip() + except UnicodeDecodeError: + command_line = line.decode("latin-1").strip() + + if not command_line: + continue + + logger.info(f"FTP <- {self.remote_ip}: {command_line}") + + # Parse command and argument + parts = command_line.split(" ", 1) + cmd = parts[0].upper() + arg = parts[1] if len(parts) > 1 else "" + + # Dispatch command + handler = getattr(self, f"cmd_{cmd}", None) + if handler: + await handler(arg) + else: + logger.warning(f"FTP command not implemented: {cmd}") + await self.send(502, f"Command {cmd} not implemented") + + except asyncio.CancelledError: + logger.info(f"FTP session cancelled from {self.remote_ip}") + except Exception as e: + logger.error(f"FTP session error from {self.remote_ip}: {e}") + finally: + logger.info(f"FTP session ended from {self.remote_ip}") + await self._cleanup() + + async def _cleanup(self) -> None: + """Clean up session resources.""" + if self.data_server: + self.data_server.close() + try: + await self.data_server.wait_closed() + except Exception: + pass + self.data_server = None + + try: + self.writer.close() + await self.writer.wait_closed() + except Exception: + pass + + # FTP Commands + + async def cmd_USER(self, arg: str) -> None: + """Handle USER command.""" + self.username = arg + if arg.lower() == "bblp": + await self.send(331, "Password required") + else: + await self.send(530, "Invalid user") + + async def cmd_PASS(self, arg: str) -> None: + """Handle PASS command.""" + if self.username and self.username.lower() == "bblp": + if arg == self.access_code: + self.authenticated = True + await self.send(230, "Login successful") + logger.info(f"FTP login from {self.remote_ip}") + else: + await self.send(530, "Login incorrect") + logger.warning(f"FTP failed login from {self.remote_ip}") + else: + await self.send(503, "Login with USER first") + + async def cmd_SYST(self, arg: str) -> None: + """Handle SYST command.""" + await self.send(215, "UNIX Type: L8") + + async def cmd_FEAT(self, arg: str) -> None: + """Handle FEAT command.""" + features = [ + "211-Features:", + " PASV", + " UTF8", + " SIZE", + "211 End", + ] + for line in features[:-1]: + self.writer.write(f"{line}\r\n".encode()) + await self.writer.drain() + self.writer.write(f"{features[-1]}\r\n".encode()) + await self.writer.drain() + + async def cmd_PWD(self, arg: str) -> None: + """Handle PWD command.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + await self.send(257, '"/" is current directory') + + async def cmd_CWD(self, arg: str) -> None: + """Handle CWD command.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + # Accept any directory change (we use a flat structure) + await self.send(250, "Directory changed") + + async def cmd_TYPE(self, arg: str) -> None: + """Handle TYPE command.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + if arg.upper() in ("A", "I"): + self.transfer_type = arg.upper() + type_name = "ASCII" if arg.upper() == "A" else "Binary" + await self.send(200, f"Type set to {type_name}") + else: + await self.send(504, "Type not supported") + + async def cmd_PASV(self, arg: str) -> None: + """Handle PASV command - set up passive data connection.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + + # Close any existing data connection/server + await self._close_data_connection() + + # Reset connection state + self._data_connected.clear() + self._data_reader = None + self._data_writer = None + + # Find a free port for passive data connection + self.data_port = random.randint(50000, 60000) + + try: + # Create data server with TLS + self.data_server = await asyncio.start_server( + self._handle_data_connection, + "0.0.0.0", + self.data_port, + ssl=self.ssl_context, + ) + + # Get server's IP for response + # Use the IP the client connected to + sockname = self.writer.get_extra_info("sockname") + ip = sockname[0] if sockname else "127.0.0.1" + + # Format IP and port for PASV response + ip_parts = ip.split(".") + port_hi = self.data_port // 256 + port_lo = self.data_port % 256 + + await self.send( + 227, + f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})", + ) + logger.info(f"FTP PASV listening on port {self.data_port}") + + except Exception as e: + logger.error(f"Failed to create passive data connection: {e}") + await self.send(425, "Cannot open data connection") + + async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Handle incoming data connection (used by PASV).""" + logger.info(f"FTP data connection established from {self.remote_ip}") + self._data_reader = reader + self._data_writer = writer + self._data_connected.set() + # Don't close - let the transfer command handle it + + async def _close_data_connection(self) -> None: + """Close the data connection and server.""" + if self._data_writer: + try: + self._data_writer.close() + await self._data_writer.wait_closed() + except Exception: + pass + self._data_writer = None + self._data_reader = None + + if self.data_server: + try: + self.data_server.close() + await self.data_server.wait_closed() + except Exception: + pass + self.data_server = None + + async def cmd_STOR(self, arg: str) -> None: + """Handle STOR command - receive file upload.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + + if not self.data_server: + await self.send(425, "Use PASV first") + return + + filename = Path(arg).name # Sanitize filename + file_path = self.upload_dir / filename + + logger.info(f"FTP receiving file: {filename} from {self.remote_ip}") + + await self.send(150, f"Opening data connection for {filename}") + + # Wait for data connection to be established (client connects after 150) + try: + await asyncio.wait_for(self._data_connected.wait(), timeout=30) + except TimeoutError: + logger.error("FTP data connection timeout - client didn't connect") + await self.send(425, "Data connection timeout") + await self._close_data_connection() + return + + if not self._data_reader: + await self.send(425, "Data connection failed") + await self._close_data_connection() + return + + # Receive data + data_content: list[bytes] = [] + try: + while True: + chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60) + if not chunk: + break + data_content.append(chunk) + logger.debug(f"FTP received chunk: {len(chunk)} bytes") + except TimeoutError: + logger.error("FTP data transfer timeout") + await self.send(426, "Transfer timeout") + await self._close_data_connection() + return + except Exception as e: + logger.error(f"FTP data transfer error: {e}") + await self.send(426, f"Transfer failed: {e}") + await self._close_data_connection() + return + + # Close data connection + await self._close_data_connection() + + # Write file + try: + total_size = sum(len(c) for c in data_content) + file_path.write_bytes(b"".join(data_content)) + logger.info(f"FTP saved file: {file_path} ({total_size} bytes)") + await self.send(226, "Transfer complete") + + # Notify callback + if self.on_file_received: + try: + result = self.on_file_received(file_path, self.remote_ip) + if asyncio.iscoroutine(result): + await result + except Exception as e: + logger.error(f"File received callback error: {e}") + + except Exception as e: + logger.error(f"Failed to save file {file_path}: {e}") + await self.send(550, "Failed to save file") + + async def cmd_SIZE(self, arg: str) -> None: + """Handle SIZE command.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + # We don't store files for SIZE queries + await self.send(550, "File not found") + + async def cmd_QUIT(self, arg: str) -> None: + """Handle QUIT command.""" + await self.send(221, "Goodbye") + raise asyncio.CancelledError() + + async def cmd_NOOP(self, arg: str) -> None: + """Handle NOOP command.""" + await self.send(200, "OK") + + async def cmd_OPTS(self, arg: str) -> None: + """Handle OPTS command.""" + if arg.upper().startswith("UTF8"): + await self.send(200, "UTF8 mode enabled") + else: + await self.send(501, "Option not supported") + + async def cmd_PBSZ(self, arg: str) -> None: + """Handle PBSZ (Protection Buffer Size) command. + + Required for FTP security extensions. With TLS, buffer size is 0. + """ + await self.send(200, "PBSZ=0") + + async def cmd_PROT(self, arg: str) -> None: + """Handle PROT (Data Channel Protection Level) command. + + P = Private (encrypted), which we always use with implicit FTPS. + """ + if arg.upper() == "P": + await self.send(200, "Protection level set to Private") + elif arg.upper() == "C": + # Clear (unprotected) - we don't support this + await self.send(536, "Protection level C not supported") + else: + await self.send(504, f"Protection level {arg} not supported") + + async def cmd_MKD(self, arg: str) -> None: + """Handle MKD (Make Directory) command.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + # We don't really create directories, just pretend it works + await self.send(257, f'"{arg}" directory created') + + async def cmd_LIST(self, arg: str) -> None: + """Handle LIST command - list directory contents.""" + if not self.authenticated: + await self.send(530, "Not logged in") + return + # We don't support listing, return empty + await self.send(150, "Opening data connection") + await self.send(226, "Transfer complete") + + +class VirtualPrinterFTPServer: + """Implicit FTPS server that accepts uploads from slicers.""" + + def __init__( + self, + upload_dir: Path, + access_code: str, + cert_path: Path, + key_path: Path, + port: int = FTP_PORT, + on_file_received: Callable[[Path, str], None] | None = None, + ): + """Initialize the FTPS server. + + Args: + upload_dir: Directory to store uploaded files + access_code: Password for authentication (bblp user) + cert_path: Path to TLS certificate file + key_path: Path to TLS private key file + port: Port to listen on (default 990) + on_file_received: Callback when file upload completes (path, source_ip) + """ + self.upload_dir = upload_dir + self.access_code = access_code + self.cert_path = cert_path + self.key_path = key_path + self.port = port + self.on_file_received = on_file_received + self._server: asyncio.Server | None = None + self._running = False + self._ssl_context: ssl.SSLContext | None = None + + async def start(self) -> None: + """Start the implicit FTPS server.""" + if self._running: + return + + logger.info(f"Starting virtual printer implicit FTPS on port {self.port}") + + # Ensure upload directory exists + self.upload_dir.mkdir(parents=True, exist_ok=True) + cache_dir = self.upload_dir / "cache" + cache_dir.mkdir(exist_ok=True) + + # Create SSL context for implicit FTPS (TLS from byte 0) + self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path)) + self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 + + try: + # Create server with SSL - TLS handshake happens before any FTP data + self._server = await asyncio.start_server( + self._handle_client, + "0.0.0.0", + self.port, + ssl=self._ssl_context, # This makes it implicit FTPS! + ) + self._running = True + + logger.info(f"Implicit FTPS server started on port {self.port}") + + async with self._server: + await self._server.serve_forever() + + except OSError as e: + if e.errno == 98: # Address already in use + logger.error(f"FTP port {self.port} is already in use") + else: + logger.error(f"FTP server error: {e}") + except asyncio.CancelledError: + logger.debug("FTP server task cancelled") + except Exception as e: + logger.error(f"FTP server error: {e}") + finally: + await self.stop() + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Handle a new FTP client connection.""" + peername = writer.get_extra_info("peername") + logger.info(f"FTP connection from {peername}") + + session = FTPSession( + reader=reader, + writer=writer, + upload_dir=self.upload_dir, + access_code=self.access_code, + ssl_context=self._ssl_context, + on_file_received=self.on_file_received, + ) + + await session.handle() + + async def stop(self) -> None: + """Stop the FTPS server.""" + logger.info("Stopping FTP server") + self._running = False + + if self._server: + try: + self._server.close() + await self._server.wait_closed() + except Exception as e: + logger.debug(f"Error closing FTP server: {e}") + self._server = None diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py new file mode 100644 index 000000000..2760b53f8 --- /dev/null +++ b/backend/app/services/virtual_printer/manager.py @@ -0,0 +1,323 @@ +"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.""" + +import asyncio +import logging +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path + +from backend.app.core.config import settings as app_settings +from backend.app.services.virtual_printer.certificate import CertificateService +from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer +from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer +from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer + +logger = logging.getLogger(__name__) + + +class VirtualPrinterManager: + """Manages the virtual printer lifecycle and coordinates all services.""" + + # Fixed configuration + PRINTER_NAME = "Bambuddy" + PRINTER_SERIAL = "00M09A391800001" # X1C serial format + PRINTER_MODEL = "3DPrinter-X1-Carbon" # Full model name for slicer compatibility + + def __init__(self): + """Initialize the virtual printer manager.""" + self._session_factory: Callable | None = None + self._enabled = False + self._access_code = "" + self._mode = "immediate" + + # Service instances + self._ssdp: VirtualPrinterSSDPServer | None = None + self._ftp: VirtualPrinterFTPServer | None = None + self._mqtt: SimpleMQTTServer | None = None + + # Background tasks + self._tasks: list[asyncio.Task] = [] + + # Directories + self._base_dir = app_settings.base_dir / "virtual_printer" + self._upload_dir = self._base_dir / "uploads" + self._cert_dir = self._base_dir / "certs" + + # Certificate service - pass serial to match CN in certificate + self._cert_service = CertificateService(self._cert_dir, serial=self.PRINTER_SERIAL) + + # Track pending uploads for MQTT correlation + self._pending_files: dict[str, Path] = {} + + def set_session_factory(self, session_factory: Callable) -> None: + """Set the database session factory. + + Args: + session_factory: Async context manager for database sessions + """ + self._session_factory = session_factory + + @property + def is_enabled(self) -> bool: + """Check if virtual printer is enabled.""" + return self._enabled + + @property + def is_running(self) -> bool: + """Check if virtual printer services are running.""" + return len(self._tasks) > 0 and all(not t.done() for t in self._tasks) + + async def configure( + self, + enabled: bool, + access_code: str = "", + mode: str = "immediate", + ) -> None: + """Configure and start/stop virtual printer. + + Args: + enabled: Whether to enable the virtual printer + access_code: Authentication password for slicer connections + mode: Archive mode - 'immediate' or 'queue' + """ + if enabled and not access_code: + raise ValueError("Access code is required when enabling virtual printer") + + self._access_code = access_code + self._mode = mode + + if enabled and not self._enabled: + await self._start() + elif not enabled and self._enabled: + await self._stop() + + self._enabled = enabled + + async def _start(self) -> None: + """Start all virtual printer services.""" + logger.info("Starting virtual printer services...") + + # Ensure certificates exist + cert_path, key_path = self._cert_service.ensure_certificates() + + # Create directories + self._upload_dir.mkdir(parents=True, exist_ok=True) + (self._upload_dir / "cache").mkdir(exist_ok=True) + + # Initialize services + self._ssdp = VirtualPrinterSSDPServer( + name=self.PRINTER_NAME, + serial=self.PRINTER_SERIAL, + model=self.PRINTER_MODEL, + ) + + self._ftp = VirtualPrinterFTPServer( + upload_dir=self._upload_dir, + access_code=self._access_code, + cert_path=cert_path, + key_path=key_path, + on_file_received=self._on_file_received, + ) + + self._mqtt = SimpleMQTTServer( + serial=self.PRINTER_SERIAL, + access_code=self._access_code, + cert_path=cert_path, + key_path=key_path, + on_print_command=self._on_print_command, + ) + + # Start services as background tasks + # Wrap each in error handler so one failure doesn't stop others + async def run_with_logging(coro, name): + try: + await coro + except Exception as e: + logger.error(f"Virtual printer {name} failed: {e}") + + self._tasks = [ + asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"), + asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"), + asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"), + ] + + logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.PRINTER_SERIAL})") + + async def _stop(self) -> None: + """Stop all virtual printer services.""" + logger.info("Stopping virtual printer services...") + + # Cancel all tasks + for task in self._tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + self._tasks = [] + + # Stop services + if self._ssdp: + await self._ssdp.stop() + self._ssdp = None + + if self._ftp: + await self._ftp.stop() + self._ftp = None + + if self._mqtt: + await self._mqtt.stop() + self._mqtt = None + + logger.info("Virtual printer stopped") + + async def _on_file_received(self, file_path: Path, source_ip: str) -> None: + """Handle file upload completion from FTP. + + Args: + file_path: Path to uploaded file + source_ip: IP address of the uploading slicer + """ + logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}") + + # Store file reference for MQTT correlation + self._pending_files[file_path.name] = file_path + + # In immediate mode, archive right away + # In queue mode, create pending upload record + if self._mode == "immediate": + await self._archive_file(file_path, source_ip) + else: + await self._queue_file(file_path, source_ip) + + async def _on_print_command(self, filename: str, data: dict) -> None: + """Handle print command from MQTT. + + In a real printer, this would start the print. For virtual printer, + we just log it since archiving is handled by file upload. + + Args: + filename: Name of the file to print + data: Print command data (contains settings like timelapse, bed_leveling, etc.) + """ + logger.info(f"Virtual printer received print command for: {filename}") + logger.debug(f"Print command data: {data}") + + # The file should already be archived from FTP upload + # This command just confirms the slicer's intent to "print" + + async def _archive_file(self, file_path: Path, source_ip: str) -> None: + """Archive file immediately. + + Args: + file_path: Path to the 3MF file + source_ip: IP address of uploader + """ + if not self._session_factory: + logger.error("Cannot archive: no database session factory configured") + return + + # Only archive 3MF files + if file_path.suffix.lower() != ".3mf": + logger.debug(f"Skipping non-3MF file: {file_path.name}") + # Remove from pending and clean up + self._pending_files.pop(file_path.name, None) + try: + file_path.unlink() + except Exception: + pass + return + + try: + from backend.app.services.archive import ArchiveService + + async with self._session_factory() as db: + service = ArchiveService(db) + + # Archive the print + archive = await service.archive_print( + printer_id=None, # No physical printer + source_file=file_path, + print_data={ + "status": "archived", + "source": "virtual_printer", + "source_ip": source_ip, + }, + ) + + if archive: + logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}") + + # Clean up uploaded file (it's now copied to archive) + try: + file_path.unlink() + except Exception: + pass + + # Remove from pending + self._pending_files.pop(file_path.name, None) + else: + logger.error(f"Failed to archive file: {file_path.name}") + + except Exception as e: + logger.error(f"Error archiving file: {e}") + + async def _queue_file(self, file_path: Path, source_ip: str) -> None: + """Queue file for user review. + + Args: + file_path: Path to the 3MF file + source_ip: IP address of uploader + """ + if not self._session_factory: + logger.error("Cannot queue: no database session factory configured") + return + + # Only queue 3MF files + if file_path.suffix.lower() != ".3mf": + logger.warning(f"Skipping non-3MF file: {file_path.name}") + return + + try: + from backend.app.models.pending_upload import PendingUpload + + async with self._session_factory() as db: + pending = PendingUpload( + filename=file_path.name, + file_path=str(file_path), + file_size=file_path.stat().st_size, + source_ip=source_ip, + status="pending", + uploaded_at=datetime.now(UTC), + ) + db.add(pending) + await db.commit() + + logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}") + + # Remove from pending files dict + self._pending_files.pop(file_path.name, None) + + except Exception as e: + logger.error(f"Error queueing file: {e}") + + def get_status(self) -> dict: + """Get virtual printer status. + + Returns: + Status dictionary with enabled, running, mode, etc. + """ + return { + "enabled": self._enabled, + "running": self.is_running, + "mode": self._mode, + "name": self.PRINTER_NAME, + "serial": self.PRINTER_SERIAL, + "model": self.PRINTER_MODEL, + "pending_files": len(self._pending_files), + } + + +# Global instance +virtual_printer_manager = VirtualPrinterManager() diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py new file mode 100644 index 000000000..f963e8fd1 --- /dev/null +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -0,0 +1,799 @@ +"""MQTT broker for virtual printer. + +Implements an MQTT broker that accepts connections from slicers, +authenticates with the configured access code, and logs print commands. +""" + +import asyncio +import json +import logging +import ssl +from collections.abc import Callable +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Default MQTT port for Bambu printers (MQTT over TLS) +MQTT_PORT = 8883 + + +class VirtualPrinterMQTTServer: + """MQTT broker that accepts connections from slicers. + + This is a minimal MQTT broker implementation that: + - Accepts TLS connections on port 8883 + - Authenticates with username 'bblp' and the configured access code + - Receives print commands on device/{serial}/request + - Can publish status on device/{serial}/report + """ + + def __init__( + self, + serial: str, + access_code: str, + cert_path: Path, + key_path: Path, + port: int = MQTT_PORT, + on_print_command: Callable[[str, dict], None] | None = None, + ): + """Initialize the MQTT server. + + Args: + serial: Virtual printer serial number + access_code: Password for authentication + cert_path: Path to TLS certificate + key_path: Path to TLS private key + port: Port to listen on (default 8883) + on_print_command: Callback when print command received (filename, data) + """ + self.serial = serial + self.access_code = access_code + self.cert_path = cert_path + self.key_path = key_path + self.port = port + self.on_print_command = on_print_command + self._running = False + self._broker = None + self._broker_task = None + + async def start(self) -> None: + """Start the MQTT broker.""" + if self._running: + return + + # Try to import amqtt + try: + from amqtt.broker import Broker + except ImportError: + logger.error("amqtt not installed. Run: pip install amqtt") + return + + logger.info(f"Starting virtual printer MQTT broker on port {self.port}") + + # Build broker configuration + config = { + "listeners": { + "default": { + "type": "tcp", + "bind": f"0.0.0.0:{self.port}", + "ssl": "on", + "certfile": str(self.cert_path), + "keyfile": str(self.key_path), + }, + }, + "auth": { + "allow-anonymous": False, + "plugins": ["auth_custom"], + }, + "topic-check": { + "enabled": False, # Allow any topic + }, + } + + try: + self._running = True + + # Create and start broker + self._broker = Broker(config) + + # Register custom auth plugin + self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate + + # Start the broker + await self._broker.start() + logger.info(f"MQTT broker started on port {self.port}") + + # Keep running + while self._running: + await asyncio.sleep(1) + + except OSError as e: + if e.errno == 98: # Address already in use + logger.error(f"MQTT port {self.port} is already in use") + else: + logger.error(f"MQTT broker error: {e}") + except asyncio.CancelledError: + logger.debug("MQTT broker task cancelled") + except Exception as e: + logger.error(f"MQTT broker error: {e}") + finally: + await self.stop() + + async def _authenticate(self, session) -> bool: + """Authenticate MQTT connection. + + Args: + session: MQTT session with username/password + + Returns: + True if authentication successful + """ + username = getattr(session, "username", None) + password = getattr(session, "password", None) + + # Bambu slicers use 'bblp' as username and access code as password + if username == "bblp" and password == self.access_code: + logger.debug(f"MQTT client authenticated from {session.remote_address}") + return True + + logger.warning(f"MQTT auth failed for user '{username}' from {session.remote_address}") + return False + + async def stop(self) -> None: + """Stop the MQTT broker.""" + logger.info("Stopping MQTT broker") + self._running = False + + if self._broker: + try: + await self._broker.shutdown() + except Exception as e: + logger.debug(f"Error shutting down MQTT broker: {e}") + self._broker = None + + +class SimpleMQTTServer: + """Simplified MQTT server using raw sockets. + + This is a fallback implementation that handles basic MQTT protocol + without requiring the amqtt library. It's less feature-complete but + more lightweight. + """ + + def __init__( + self, + serial: str, + access_code: str, + cert_path: Path, + key_path: Path, + port: int = MQTT_PORT, + on_print_command: Callable[[str, dict], None] | None = None, + ): + self.serial = serial + self.access_code = access_code + self.cert_path = cert_path + self.key_path = key_path + self.port = port + self.on_print_command = on_print_command + self._running = False + self._server = None + self._clients: dict[str, asyncio.StreamWriter] = {} + self._status_push_task: asyncio.Task | None = None + self._sequence_id = 0 + + async def start(self) -> None: + """Start the MQTT server.""" + if self._running: + return + + logger.info(f"Starting simple MQTT server on port {self.port}") + + # Create SSL context with Bambu-compatible settings + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path)) + # Match Bambu printer behavior - accept any client + ssl_context.verify_mode = ssl.CERT_NONE + # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3) + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 + # Disable hostname checking + ssl_context.check_hostname = False + + # Log certificate info + import subprocess + + try: + result = subprocess.run( + ["openssl", "x509", "-in", str(self.cert_path), "-noout", "-subject", "-issuer"], + capture_output=True, + text=True, + timeout=5, + ) + logger.info(f"MQTT SSL cert info: {result.stdout.strip()}") + except Exception: + pass + + logger.info(f"MQTT SSL context: TLS 1.2+, cert={self.cert_path}") + + try: + self._running = True + + # Wrapper to log ALL connection attempts including SSL errors + async def connection_handler(reader, writer): + try: + addr = writer.get_extra_info("peername") + ssl_obj = writer.get_extra_info("ssl_object") + if ssl_obj: + logger.info( + f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}" + ) + else: + logger.info(f"MQTT connection from {addr} (no TLS?)") + await self._handle_client(reader, writer) + except ssl.SSLError as e: + logger.error(f"MQTT SSL error: {e}") + except Exception as e: + logger.error(f"MQTT connection handler error: {e}") + + # Custom protocol factory to log raw connection attempts + logger.info("Setting up MQTT server with SSL error handling...") + + # Add SSL handshake error callback + def handle_ssl_error(loop, context): + exception = context.get("exception") + message = context.get("message", "") + if "ssl" in str(exception).lower() or "ssl" in message.lower(): + logger.error(f"SSL error: {message} - {exception}") + else: + logger.debug(f"Asyncio error: {message}") + + asyncio.get_event_loop().set_exception_handler(handle_ssl_error) + + self._server = await asyncio.start_server( + connection_handler, + "0.0.0.0", + self.port, + ssl=ssl_context, + ) + + logger.info(f"Simple MQTT server listening on port {self.port}") + + # Start periodic status push task + self._status_push_task = asyncio.create_task(self._periodic_status_push()) + + async with self._server: + await self._server.serve_forever() + + except OSError as e: + if e.errno == 98: # Address already in use + logger.error(f"MQTT port {self.port} is already in use") + else: + logger.error(f"MQTT server error: {e}") + except asyncio.CancelledError: + logger.debug("MQTT server task cancelled") + except Exception as e: + logger.error(f"MQTT server error: {e}") + finally: + await self.stop() + + async def stop(self) -> None: + """Stop the MQTT server.""" + logger.info("Stopping simple MQTT server") + self._running = False + + # Stop periodic status push + if self._status_push_task: + self._status_push_task.cancel() + try: + await self._status_push_task + except asyncio.CancelledError: + pass + self._status_push_task = None + + # Close all client connections + for _client_id, writer in self._clients.items(): + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + self._clients.clear() + + if self._server: + try: + self._server.close() + await self._server.wait_closed() + except Exception: + pass + self._server = None + + async def _periodic_status_push(self) -> None: + """Send periodic status updates to all connected clients.""" + logger.info("Starting periodic status push task") + while self._running: + try: + await asyncio.sleep(1) # Push every 1 second like real printers + + # Send status to all connected clients + disconnected = [] + for client_id, writer in list(self._clients.items()): + try: + if writer.is_closing(): + disconnected.append(client_id) + continue + await self._send_status_report(writer) + except Exception as e: + logger.debug(f"Failed to push status to {client_id}: {e}") + disconnected.append(client_id) + + # Remove disconnected clients + for client_id in disconnected: + self._clients.pop(client_id, None) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Periodic status push error: {e}") + + logger.info("Periodic status push task stopped") + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Handle an MQTT client connection.""" + addr = writer.get_extra_info("peername") + client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown" + logger.info(f"MQTT client connected: {client_id}") + + authenticated = False + + try: + while self._running: + # Read MQTT fixed header + try: + header = await asyncio.wait_for(reader.read(1), timeout=60) + except TimeoutError: + break + + if not header: + break + + packet_type = (header[0] & 0xF0) >> 4 + + # Read remaining length + remaining_length = await self._read_remaining_length(reader) + if remaining_length is None: + break + + # Read payload + payload = await reader.read(remaining_length) if remaining_length > 0 else b"" + + # Handle packet types + if packet_type == 1: # CONNECT + authenticated = await self._handle_connect(payload, writer) + if not authenticated: + break + # Register client for periodic status pushes + self._clients[client_id] = writer + elif packet_type == 3: # PUBLISH + if authenticated: + await self._handle_publish(header[0], payload, writer) + elif packet_type == 8: # SUBSCRIBE + if authenticated: + await self._handle_subscribe(payload, writer) + elif packet_type == 12: # PINGREQ + # Send PINGRESP + writer.write(bytes([0xD0, 0x00])) + await writer.drain() + elif packet_type == 14: # DISCONNECT + break + + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug(f"MQTT client error: {e}") + finally: + logger.debug(f"MQTT client disconnected: {client_id}") + if client_id in self._clients: + del self._clients[client_id] + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None: + """Read MQTT remaining length (variable byte integer).""" + multiplier = 1 + value = 0 + + for _ in range(4): + try: + byte = await reader.read(1) + if not byte: + return None + encoded = byte[0] + value += (encoded & 127) * multiplier + if (encoded & 128) == 0: + return value + multiplier *= 128 + except Exception: + return None + + return None + + async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> bool: + """Handle MQTT CONNECT packet. + + Returns True if authentication successful. + """ + try: + # Parse CONNECT packet + # Skip protocol name length and name + idx = 0 + proto_len = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + proto_len + + # Skip protocol level and connect flags + # connect_flags = payload[idx + 1] + idx += 2 + + # Skip keepalive + idx += 2 + + # Read client ID + client_id_len = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + # client_id = payload[idx : idx + client_id_len].decode("utf-8") + idx += client_id_len + + # Read username + username_len = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + username = payload[idx : idx + username_len].decode("utf-8") + idx += username_len + + # Read password + password_len = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + password = payload[idx : idx + password_len].decode("utf-8") + + # Authenticate + if username == "bblp" and password == self.access_code: + # Send CONNACK with success + writer.write(bytes([0x20, 0x02, 0x00, 0x00])) + await writer.drain() + logger.info("MQTT client authenticated successfully") + + # Send immediate status report after auth - slicer expects this + await self._send_status_report(writer) + return True + else: + # Send CONNACK with auth failure + writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized + await writer.drain() + logger.warning(f"MQTT auth failed for user '{username}'") + return False + + except Exception as e: + logger.debug(f"MQTT CONNECT parse error: {e}") + # Send CONNACK with error + writer.write(bytes([0x20, 0x02, 0x00, 0x02])) # Protocol error + await writer.drain() + return False + + async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter) -> None: + """Handle MQTT SUBSCRIBE packet.""" + try: + # Parse packet ID + packet_id = (payload[0] << 8) | payload[1] + + # Parse topic filters (just acknowledge them) + idx = 2 + granted_qos = [] + while idx < len(payload): + topic_len = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + topic = payload[idx : idx + topic_len].decode("utf-8") + idx += topic_len + requested_qos = payload[idx] + idx += 1 + + logger.info(f"MQTT subscribe: {topic} QoS={requested_qos}") + granted_qos.append(min(requested_qos, 1)) # Grant up to QoS 1 + + # Send SUBACK + suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF]) + suback += bytes(granted_qos) + writer.write(suback) + await writer.drain() + + # Send initial status report after subscribe + await self._send_status_report(writer) + + except Exception as e: + logger.debug(f"MQTT SUBSCRIBE error: {e}") + + async def _send_status_report(self, writer: asyncio.StreamWriter) -> None: + """Send a status report to the slicer after connection.""" + try: + # Build status message matching Bambu printer format + self._sequence_id += 1 + status = { + "print": { + "sequence_id": str(self._sequence_id), + "command": "push_status", + "msg": 0, + "gcode_state": "IDLE", + "gcode_file": "", + "gcode_file_prepare_percent": "0", + "subtask_name": "", + "mc_print_stage": "", + "mc_percent": 0, + "mc_remaining_time": 0, + "wifi_signal": "-44dBm", + "print_error": 0, + "print_type": "", + "bed_temper": 25.0, + "bed_target_temper": 0.0, + "nozzle_temper": 25.0, + "nozzle_target_temper": 0.0, + "chamber_temper": 25.0, + "cooling_fan_speed": "0", + "big_fan1_speed": "0", + "big_fan2_speed": "0", + "heatbreak_fan_speed": "0", + "spd_lvl": 1, + "spd_mag": 100, + "stg": [], + "stg_cur": 0, + "layer_num": 0, + "total_layer_num": 0, + "home_flag": 256, # Bit 8 = SD card present (HAS_SDCARD_NORMAL) + "hw_switch_state": 0, + "online": {"ahb": False, "rfid": False, "version": 7}, + "ams_status": 0, + "sdcard": True, + "storage": {"free": 1000000000, "total": 32000000000}, + "upgrade_state": { + "sequence_id": 0, + "progress": "", + "status": "", + "consistency_request": False, + "dis_state": 0, + "err_code": 0, + "force_upgrade": False, + "message": "", + "module": "", + "new_version_state": 2, + "new_ver_list": [], + "ota_new_version_number": "", + "ahb_new_version_number": "", + }, + "ipcam": { + "ipcam_dev": "1", + "ipcam_record": "enable", + "timelapse": "disable", + "resolution": "1080p", + "mode_bits": 0, + }, + "xcam": { + "allow_skip_parts": False, + "buildplate_marker_detector": True, + "first_layer_inspector": True, + "halt_print_sensitivity": "medium", + "print_halt": True, + "printing_monitor": True, + "spaghetti_detector": True, + }, + "lights_report": [{"node": "chamber_light", "mode": "on"}], + "nozzle_diameter": "0.4", + "nozzle_type": "hardened_steel", + } + } + + topic = f"device/{self.serial}/report" + message = json.dumps(status) + + # Build MQTT PUBLISH packet + topic_bytes = topic.encode("utf-8") + message_bytes = message.encode("utf-8") + + # Calculate remaining length + remaining = 2 + len(topic_bytes) + len(message_bytes) + + # Build packet + packet = bytes([0x30]) # PUBLISH, QoS 0 + + # Encode remaining length + while remaining > 0: + byte = remaining % 128 + remaining //= 128 + if remaining > 0: + byte |= 0x80 + packet += bytes([byte]) + + # Topic length and topic + packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) + packet += topic_bytes + + # Message payload + packet += message_bytes + + writer.write(packet) + await writer.drain() + + logger.info(f"Sent initial status report on {topic}") + + except Exception as e: + logger.error(f"Failed to send status report: {e}") + + async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None: + """Send version info response to the slicer.""" + try: + # Build version response matching OrcaSlicer expectations + # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag + version_info = { + "info": { + "command": "get_version", + "sequence_id": sequence_id, + "module": [ + { + "name": "ota", + "product_name": "X1 Carbon", + "sw_ver": "01.07.00.00", + "sw_new_ver": "", + "hw_ver": "OTA", + "sn": self.serial, + "flag": 0, + }, + { + "name": "esp32", + "product_name": "X1 Carbon", + "sw_ver": "01.07.22.25", + "sw_new_ver": "", + "hw_ver": "AP05", + "sn": self.serial, + "flag": 0, + }, + { + "name": "rv1126", + "product_name": "X1 Carbon", + "sw_ver": "00.00.27.38", + "sw_new_ver": "", + "hw_ver": "AP05", + "sn": self.serial, + "flag": 0, + }, + { + "name": "th", + "product_name": "X1 Carbon", + "sw_ver": "00.00.04.00", + "sw_new_ver": "", + "hw_ver": "TH07", + "sn": self.serial, + "flag": 0, + }, + { + "name": "mc", + "product_name": "X1 Carbon", + "sw_ver": "00.00.10.00", + "sw_new_ver": "", + "hw_ver": "MC07", + "sn": self.serial, + "flag": 0, + }, + ], + } + } + + topic = f"device/{self.serial}/report" + message = json.dumps(version_info) + + # Build MQTT PUBLISH packet + topic_bytes = topic.encode("utf-8") + message_bytes = message.encode("utf-8") + + # Calculate remaining length + remaining = 2 + len(topic_bytes) + len(message_bytes) + + # Build packet + packet = bytes([0x30]) # PUBLISH, QoS 0 + + # Encode remaining length + while remaining > 0: + byte = remaining % 128 + remaining //= 128 + if remaining > 0: + byte |= 0x80 + packet += bytes([byte]) + + # Topic length and topic + packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) + packet += topic_bytes + + # Message payload + packet += message_bytes + + writer.write(packet) + await writer.drain() + + logger.info(f"Sent version response on {topic}") + + except Exception as e: + logger.error(f"Failed to send version response: {e}") + + async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None: + """Handle MQTT PUBLISH packet.""" + try: + # Parse topic + idx = 0 + topic_len = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + topic = payload[idx : idx + topic_len].decode("utf-8") + idx += topic_len + + # Check for packet ID (QoS > 0) + qos = (header & 0x06) >> 1 + if qos > 0: + # packet_id = (payload[idx] << 8) | payload[idx + 1] + idx += 2 + + # Parse message + message = payload[idx:].decode("utf-8") + + logger.info(f"MQTT publish to {topic}: {message[:100]}...") + + # Handle commands on device request topic + if f"device/{self.serial}/request" in topic: + try: + data = json.loads(message) + + # Handle pushing command (status request) + if "pushing" in data: + pushing_data = data["pushing"] + command = pushing_data.get("command", "") + logger.info(f"MQTT pushing command: {command}") + + if command == "pushall": + # Slicer is requesting full status - send response + logger.info("Sending status report in response to pushall") + await self._send_status_report(writer) + elif command == "start": + # Slicer wants periodic status updates - send one now + logger.info("Starting status push stream") + await self._send_status_report(writer) + + # Handle info commands (get_version, etc.) + if "info" in data: + info_data = data["info"] + command = info_data.get("command", "") + sequence_id = info_data.get("sequence_id", "0") + logger.info(f"MQTT info command: {command}") + + if command == "get_version": + await self._send_version_response(writer, sequence_id) + + # Handle print commands + if "print" in data: + print_data = data["print"] + command = print_data.get("command", "") + filename = print_data.get("subtask_name", "") + + logger.info(f"MQTT print command: {command} for {filename}") + + if self.on_print_command and command == "project_file": + await self._notify_print_command(filename, print_data) + + except json.JSONDecodeError: + pass + + except Exception as e: + logger.debug(f"MQTT PUBLISH error: {e}") + + async def _notify_print_command(self, filename: str, data: dict) -> None: + """Notify callback of print command.""" + if self.on_print_command: + try: + result = self.on_print_command(filename, data) + if asyncio.iscoroutine(result): + await result + except Exception as e: + logger.error(f"Print command callback error: {e}") diff --git a/backend/app/services/virtual_printer/ssdp_server.py b/backend/app/services/virtual_printer/ssdp_server.py new file mode 100644 index 000000000..2f033b688 --- /dev/null +++ b/backend/app/services/virtual_printer/ssdp_server.py @@ -0,0 +1,270 @@ +"""SSDP discovery responder for virtual printer. + +Responds to M-SEARCH requests from slicers and sends periodic NOTIFY +announcements so the virtual printer appears as a discoverable Bambu printer. +""" + +import asyncio +import logging +import socket +import struct +from datetime import datetime + +logger = logging.getLogger(__name__) + +# SSDP multicast address - Bambu uses port 2021 +SSDP_ADDR = "239.255.255.250" +SSDP_PORT = 2021 + +# Bambu service target +BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1" + + +class VirtualPrinterSSDPServer: + """SSDP server that responds to discovery requests as a virtual Bambu printer.""" + + def __init__( + self, + name: str = "Bambuddy", + serial: str = "00M09A391800001", # X1C serial format for compatibility + model: str = "BL-P001", # X1C model code for best compatibility + ): + """Initialize the SSDP server. + + Args: + name: Display name shown in slicer discovery + serial: Unique serial number for this virtual printer (must match cert CN) + model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D) + """ + self.name = name + self.serial = serial + self.model = model + self._running = False + self._socket: socket.socket | None = None + self._local_ip: str | None = None + + def _get_local_ip(self) -> str: + """Get the local IP address to advertise.""" + if self._local_ip: + return self._local_ip + + # Try to determine local IP by connecting to a public address + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + self._local_ip = ip + return ip + except Exception: + return "127.0.0.1" + + def _build_notify_message(self) -> bytes: + """Build SSDP NOTIFY message for periodic announcements.""" + ip = self._get_local_ip() + # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3 + # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound + message = ( + "NOTIFY * HTTP/1.1\r\n" + f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n" + "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n" + "Cache-Control: max-age=1800\r\n" + f"Location: {ip}\r\n" + f"NT: {BAMBU_SEARCH_TARGET}\r\n" + "NTS: ssdp:alive\r\n" + "EXT:\r\n" + f"USN: {self.serial}\r\n" + f"DevModel.bambu.com: {self.model}\r\n" + f"DevName.bambu.com: {self.name}\r\n" + "DevSignal.bambu.com: -44\r\n" + "DevConnect.bambu.com: lan\r\n" + "DevBind.bambu.com: free\r\n" + "Devseclink.bambu.com: secure\r\n" + "DevVersion.bambu.com: 01.07.00.00\r\n" + "\r\n" + ) + return message.encode() + + def _build_response_message(self) -> bytes: + """Build SSDP response message for M-SEARCH requests.""" + ip = self._get_local_ip() + # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3 + # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound + # Added: Devseclink, DevVersion, DevCap for better compatibility + message = ( + "HTTP/1.1 200 OK\r\n" + "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n" + f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n" + f"Location: {ip}\r\n" + f"ST: {BAMBU_SEARCH_TARGET}\r\n" + "EXT:\r\n" + f"USN: {self.serial}\r\n" + "Cache-Control: max-age=1800\r\n" + f"DevModel.bambu.com: {self.model}\r\n" + f"DevName.bambu.com: {self.name}\r\n" + "DevSignal.bambu.com: -44\r\n" + "DevConnect.bambu.com: lan\r\n" + "DevBind.bambu.com: free\r\n" + "Devseclink.bambu.com: secure\r\n" + "DevVersion.bambu.com: 01.07.00.00\r\n" + "\r\n" + ) + return message.encode() + + async def start(self) -> None: + """Start the SSDP server.""" + if self._running: + return + + logger.info(f"Starting virtual printer SSDP server: {self.name} ({self.serial})") + self._running = True + + try: + # Create UDP socket + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Try to set SO_REUSEPORT if available + try: + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except (AttributeError, OSError): + pass + + # Set non-blocking mode + self._socket.setblocking(False) + + # Bind to SSDP port + self._socket.bind(("", SSDP_PORT)) + + # Join multicast group + mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY) + self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + # Enable broadcast + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + + # Set multicast TTL + self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + + local_ip = self._get_local_ip() + logger.info(f"SSDP server listening on port {SSDP_PORT}, advertising IP: {local_ip}") + logger.info(f"Virtual printer: {self.name} ({self.serial}) model={self.model}") + + # Send initial NOTIFY + await self._send_notify() + logger.info("Sent initial SSDP NOTIFY announcement") + + # Run receive and announce loops + last_notify = asyncio.get_event_loop().time() + notify_interval = 30.0 # Send NOTIFY every 30 seconds + + while self._running: + # Try to receive M-SEARCH requests + try: + data, addr = self._socket.recvfrom(4096) + message = data.decode("utf-8", errors="ignore") + await self._handle_message(message, addr) + except BlockingIOError: + pass + except Exception as e: + if self._running: + logger.debug(f"SSDP receive error: {e}") + + # Send periodic NOTIFY + now = asyncio.get_event_loop().time() + if now - last_notify >= notify_interval: + await self._send_notify() + last_notify = now + + await asyncio.sleep(0.1) + + except OSError as e: + if e.errno == 98: # Address already in use + logger.warning(f"SSDP port {SSDP_PORT} in use - real printers may be running") + else: + logger.error(f"SSDP server error: {e}") + except asyncio.CancelledError: + logger.debug("SSDP server cancelled") + except Exception as e: + logger.error(f"SSDP server error: {e}") + finally: + await self._cleanup() + + async def stop(self) -> None: + """Stop the SSDP server.""" + logger.info("Stopping SSDP server") + self._running = False + await self._cleanup() + + async def _cleanup(self) -> None: + """Clean up resources.""" + if self._socket: + try: + # Send byebye message + await self._send_byebye() + except Exception: + pass + + try: + self._socket.close() + except Exception: + pass + self._socket = None + + async def _send_notify(self) -> None: + """Send SSDP NOTIFY message.""" + if not self._socket: + return + + try: + msg = self._build_notify_message() + self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT)) + logger.debug(f"Sent SSDP NOTIFY for {self.name}") + except Exception as e: + logger.debug(f"Failed to send NOTIFY: {e}") + + async def _send_byebye(self) -> None: + """Send SSDP byebye message when shutting down.""" + if not self._socket: + return + + message = ( + "NOTIFY * HTTP/1.1\r\n" + f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n" + f"NT: {BAMBU_SEARCH_TARGET}\r\n" + "NTS: ssdp:byebye\r\n" + f"USN: {self.serial}\r\n" + "\r\n" + ) + + try: + self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT)) + logger.debug("Sent SSDP byebye") + except Exception: + pass + + async def _handle_message(self, message: str, addr: tuple[str, int]) -> None: + """Handle incoming SSDP message. + + Args: + message: The SSDP message content + addr: Tuple of (ip_address, port) of sender + """ + # Check if this is an M-SEARCH request for Bambu printers + if "M-SEARCH" not in message: + return + + # Check search target + if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower(): + return + + logger.debug(f"Received M-SEARCH from {addr[0]}") + + # Send response + if self._socket: + try: + response = self._build_response_message() + self._socket.sendto(response, addr) + logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'") + except Exception as e: + logger.debug(f"Failed to send SSDP response: {e}") diff --git a/backend/tests/integration/test_virtual_printer_api.py b/backend/tests/integration/test_virtual_printer_api.py new file mode 100644 index 000000000..0815dda86 --- /dev/null +++ b/backend/tests/integration/test_virtual_printer_api.py @@ -0,0 +1,252 @@ +"""Integration tests for Virtual Printer API endpoints. + +Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient + + +class TestVirtualPrinterSettingsAPI: + """Integration tests for /api/v1/settings/virtual-printer endpoints.""" + + # ======================================================================== + # Get settings + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_virtual_printer_settings(self, async_client: AsyncClient): + """Verify virtual printer settings can be retrieved.""" + response = await async_client.get("/api/v1/settings/virtual-printer") + + assert response.status_code == 200 + result = response.json() + assert "enabled" in result + assert "access_code_set" in result + assert "mode" in result + assert "status" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_settings_has_status(self, async_client: AsyncClient): + """Verify settings include status details.""" + response = await async_client.get("/api/v1/settings/virtual-printer") + + assert response.status_code == 200 + result = response.json() + status = result["status"] + assert "enabled" in status + assert "running" in status + assert "mode" in status + assert "name" in status + assert "serial" in status + assert "pending_files" in status + + # ======================================================================== + # Update settings + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mode(self, async_client: AsyncClient): + """Verify mode can be updated.""" + response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue") + + assert response.status_code == 200 + result = response.json() + assert result["mode"] == "queue" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mode_to_immediate(self, async_client: AsyncClient): + """Verify mode can be set to immediate.""" + response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate") + + assert response.status_code == 200 + result = response.json() + assert result["mode"] == "immediate" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_access_code(self, async_client: AsyncClient): + """Verify access code can be set.""" + response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678") + + assert response.status_code == 200 + result = response.json() + assert result["access_code_set"] is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_access_code_wrong_length(self, async_client: AsyncClient): + """Verify access code validation for length.""" + response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123") + + # Should fail validation + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_enable_without_access_code(self, async_client: AsyncClient): + """Verify enabling fails without access code set.""" + # First ensure no access code is set by checking current state + # Then try to enable + response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true") + + # If access code wasn't set, this should fail + # If it was already set, it will succeed + # Both are valid test outcomes + assert response.status_code in [200, 400] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_enable_with_access_code(self, async_client: AsyncClient): + """Verify enabling succeeds when access code is set.""" + # First set access code + await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678") + + # Then enable (this will start the servers which may fail in test env) + # We mock the manager to avoid actually starting servers + with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager: + mock_manager.configure = AsyncMock() + mock_manager.get_status = MagicMock( + return_value={ + "enabled": True, + "running": True, + "mode": "immediate", + "name": "Bambuddy", + "serial": "00M09A391800001", + "pending_files": 0, + } + ) + + response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true") + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_disable_virtual_printer(self, async_client: AsyncClient): + """Verify virtual printer can be disabled.""" + with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager: + mock_manager.configure = AsyncMock() + mock_manager.get_status = MagicMock( + return_value={ + "enabled": False, + "running": False, + "mode": "immediate", + "name": "Bambuddy", + "serial": "00M09A391800001", + "pending_files": 0, + } + ) + + response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false") + + assert response.status_code == 200 + result = response.json() + assert result["enabled"] is False + + +class TestPendingUploadsAPI: + """Integration tests for /api/v1/pending-uploads/ endpoints.""" + + @pytest.fixture + def mock_pending_uploads(self, db_session): + """Create mock pending uploads in database.""" + + async def _create_pending(filename: str = "test.3mf"): + from datetime import datetime + + from backend.app.models.pending_upload import PendingUpload + + upload = PendingUpload( + filename=filename, + file_path=f"/tmp/{filename}", + file_size=1024, + source_ip="192.168.1.100", + status="pending", + ) + db_session.add(upload) + await db_session.commit() + await db_session.refresh(upload) + return upload + + return _create_pending + + # ======================================================================== + # List pending uploads + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_pending_uploads_empty(self, async_client: AsyncClient): + """Verify empty list is returned when no pending uploads.""" + response = await async_client.get("/api/v1/pending-uploads/") + + assert response.status_code == 200 + result = response.json() + assert isinstance(result, list) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_pending_uploads_count(self, async_client: AsyncClient): + """Verify count endpoint returns correct count.""" + response = await async_client.get("/api/v1/pending-uploads/count") + + assert response.status_code == 200 + result = response.json() + assert "count" in result + assert isinstance(result["count"], int) + + # ======================================================================== + # Archive pending upload + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archive_nonexistent_upload(self, async_client: AsyncClient): + """Verify archiving non-existent upload returns 404.""" + response = await async_client.post("/api/v1/pending-uploads/99999/archive") + + assert response.status_code == 404 + + # ======================================================================== + # Discard pending upload + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_discard_nonexistent_upload(self, async_client: AsyncClient): + """Verify discarding non-existent upload returns 404.""" + response = await async_client.delete("/api/v1/pending-uploads/99999") + + assert response.status_code == 404 + + # ======================================================================== + # Bulk operations + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archive_all_empty(self, async_client: AsyncClient): + """Verify archive all with no pending uploads.""" + response = await async_client.post("/api/v1/pending-uploads/archive-all") + + assert response.status_code == 200 + result = response.json() + assert "archived" in result + assert "failed" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_discard_all_empty(self, async_client: AsyncClient): + """Verify discard all with no pending uploads.""" + response = await async_client.delete("/api/v1/pending-uploads/discard-all") + + assert response.status_code == 200 + result = response.json() + assert "discarded" in result diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py new file mode 100644 index 000000000..2ff847d1e --- /dev/null +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -0,0 +1,390 @@ +"""Unit tests for Virtual Printer services. + +Tests the virtual printer manager, FTP server, and SSDP server components. +""" + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestVirtualPrinterManager: + """Tests for VirtualPrinterManager class.""" + + @pytest.fixture + def manager(self): + """Create a VirtualPrinterManager instance.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterManager + + return VirtualPrinterManager() + + # ======================================================================== + # Tests for configuration + # ======================================================================== + + @pytest.mark.asyncio + async def test_configure_sets_parameters(self, manager): + """Verify configure stores parameters correctly.""" + # Mock the start/stop methods to avoid actually starting services + manager._start = AsyncMock() + + await manager.configure( + enabled=True, + access_code="12345678", + mode="immediate", + ) + + assert manager._enabled is True + assert manager._access_code == "12345678" + assert manager._mode == "immediate" + + @pytest.mark.asyncio + async def test_configure_disabled_stops_services(self, manager): + """Verify disabling stops all services.""" + # First simulate enabled state + manager._enabled = True + manager._tasks = [MagicMock(done=MagicMock(return_value=False))] + manager._stop = AsyncMock() + + await manager.configure(enabled=False, access_code="12345678") + + assert manager._enabled is False + manager._stop.assert_called_once() + + @pytest.mark.asyncio + async def test_configure_requires_access_code_when_enabling(self, manager): + """Verify access code is required when enabling.""" + with pytest.raises(ValueError, match="Access code is required"): + await manager.configure(enabled=True) + + # ======================================================================== + # Tests for status + # ======================================================================== + + def test_get_status_returns_correct_format(self, manager): + """Verify get_status returns expected fields.""" + manager._enabled = True + manager._mode = "immediate" + manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} + # Simulate running tasks + manager._tasks = [MagicMock(done=MagicMock(return_value=False))] + + status = manager.get_status() + + assert status["enabled"] is True + assert status["running"] is True + assert status["mode"] == "immediate" + assert status["name"] == "Bambuddy" + assert status["serial"] == "00M09A391800001" + assert status["pending_files"] == 1 + + def test_get_status_when_stopped(self, manager): + """Verify get_status when not running.""" + manager._enabled = False + manager._tasks = [] + + status = manager.get_status() + + assert status["enabled"] is False + assert status["running"] is False + + def test_is_running_with_active_tasks(self, manager): + """Verify is_running is True when tasks are active.""" + mock_task = MagicMock() + mock_task.done.return_value = False + manager._tasks = [mock_task] + + assert manager.is_running is True + + def test_is_running_with_no_tasks(self, manager): + """Verify is_running is False when no tasks.""" + manager._tasks = [] + + assert manager.is_running is False + + # ======================================================================== + # Tests for file handling + # ======================================================================== + + @pytest.mark.asyncio + async def test_on_file_received_adds_to_pending(self, manager): + """Verify received file is added to pending list.""" + manager._mode = "queue" + manager._session_factory = None # Disable actual archiving + + file_path = Path("/tmp/test.3mf") + + with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue: + await manager._on_file_received(file_path, "192.168.1.100") + + assert "test.3mf" in manager._pending_files + mock_queue.assert_called_once() + + @pytest.mark.asyncio + async def test_on_file_received_archives_immediately(self, manager): + """Verify file is archived in immediate mode.""" + manager._mode = "immediate" + manager._session_factory = None # Will prevent actual archiving + + file_path = Path("/tmp/test.3mf") + + with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive: + await manager._on_file_received(file_path, "192.168.1.100") + + mock_archive.assert_called_once_with(file_path, "192.168.1.100") + + @pytest.mark.asyncio + async def test_archive_file_skips_non_3mf(self, manager): + """Verify non-3MF files are skipped and cleaned up.""" + manager._session_factory = MagicMock() + manager._pending_files["verify_job"] = Path("/tmp/verify_job") + + with patch("pathlib.Path.unlink"): + await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100") + + # Should be removed from pending + assert "verify_job" not in manager._pending_files + + +class TestFTPSession: + """Tests for FTP session handling.""" + + @pytest.fixture + def mock_reader(self): + """Create a mock StreamReader.""" + reader = AsyncMock() + return reader + + @pytest.fixture + def mock_writer(self): + """Create a mock StreamWriter.""" + writer = MagicMock() + writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345)) + writer.write = MagicMock() + writer.drain = AsyncMock() + writer.close = MagicMock() + writer.wait_closed = AsyncMock() + writer.is_closing = MagicMock(return_value=False) + return writer + + @pytest.fixture + def ssl_context(self): + """Create a mock SSL context.""" + return MagicMock() + + @pytest.fixture + def session(self, mock_reader, mock_writer, ssl_context, tmp_path): + """Create an FTPSession instance.""" + from backend.app.services.virtual_printer.ftp_server import FTPSession + + return FTPSession( + reader=mock_reader, + writer=mock_writer, + upload_dir=tmp_path, + access_code="12345678", + ssl_context=ssl_context, + on_file_received=None, + ) + + # ======================================================================== + # Tests for authentication + # ======================================================================== + + @pytest.mark.asyncio + async def test_user_command_accepts_bblp(self, session): + """Verify USER command accepts bblp user.""" + await session.cmd_USER("bblp") + + assert session.username == "bblp" + + @pytest.mark.asyncio + async def test_pass_command_authenticates(self, session): + """Verify PASS command authenticates with correct code.""" + session.username = "bblp" + + await session.cmd_PASS("12345678") + + assert session.authenticated is True + + @pytest.mark.asyncio + async def test_pass_command_rejects_wrong_code(self, session): + """Verify PASS command rejects wrong access code.""" + session.username = "bblp" + + await session.cmd_PASS("wrongcode") + + assert session.authenticated is False + + # ======================================================================== + # Tests for FTP commands + # ======================================================================== + + @pytest.mark.asyncio + async def test_syst_command(self, session): + """Verify SYST returns UNIX type.""" + await session.cmd_SYST("") + + session.writer.write.assert_called() + call_args = session.writer.write.call_args[0][0].decode() + assert "215" in call_args + assert "UNIX" in call_args + + @pytest.mark.asyncio + async def test_pwd_command_requires_auth(self, session): + """Verify PWD requires authentication.""" + session.authenticated = False + + await session.cmd_PWD("") + + call_args = session.writer.write.call_args[0][0].decode() + assert "530" in call_args + + @pytest.mark.asyncio + async def test_pwd_command_when_authenticated(self, session): + """Verify PWD returns root directory when authenticated.""" + session.authenticated = True + + await session.cmd_PWD("") + + call_args = session.writer.write.call_args[0][0].decode() + assert "257" in call_args + + @pytest.mark.asyncio + async def test_type_command_sets_binary(self, session): + """Verify TYPE I sets binary mode.""" + session.authenticated = True + + await session.cmd_TYPE("I") + + assert session.transfer_type == "I" + + @pytest.mark.asyncio + async def test_pbsz_command(self, session): + """Verify PBSZ returns success.""" + await session.cmd_PBSZ("0") + + call_args = session.writer.write.call_args[0][0].decode() + assert "200" in call_args + + @pytest.mark.asyncio + async def test_prot_command_accepts_p(self, session): + """Verify PROT P is accepted.""" + await session.cmd_PROT("P") + + call_args = session.writer.write.call_args[0][0].decode() + assert "200" in call_args + + @pytest.mark.asyncio + async def test_quit_command(self, session): + """Verify QUIT sends goodbye and raises CancelledError.""" + with pytest.raises(asyncio.CancelledError): + await session.cmd_QUIT("") + + +class TestSSDPServer: + """Tests for Virtual Printer SSDP server.""" + + @pytest.fixture + def ssdp_server(self): + """Create a VirtualPrinterSSDPServer instance.""" + from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer + + return VirtualPrinterSSDPServer( + serial="TEST123", + name="TestPrinter", + model="BL-P001", + ) + + # ======================================================================== + # Tests for SSDP response + # ======================================================================== + + def test_build_notify_message(self, ssdp_server): + """Verify NOTIFY packet contains required headers.""" + # Set a known IP for testing + ssdp_server._local_ip = "192.168.1.100" + + message = ssdp_server._build_notify_message() + + assert b"NOTIFY" in message + assert b"DevName.bambu.com: TestPrinter" in message + assert b"USN: TEST123" in message + + def test_build_response_message(self, ssdp_server): + """Verify response packet contains required headers.""" + # Set a known IP for testing + ssdp_server._local_ip = "192.168.1.100" + + message = ssdp_server._build_response_message() + + assert b"HTTP/1.1 200 OK" in message + assert b"DevName.bambu.com: TestPrinter" in message + assert b"USN: TEST123" in message + + def test_ssdp_server_uses_correct_model(self, ssdp_server): + """Verify SSDP server uses the provided model.""" + ssdp_server._local_ip = "192.168.1.100" + + message = ssdp_server._build_notify_message() + + assert b"DevModel.bambu.com: BL-P001" in message + + +class TestCertificateService: + """Tests for TLS certificate generation.""" + + @pytest.fixture + def cert_service(self, tmp_path): + """Create a CertificateService instance.""" + from backend.app.services.virtual_printer.certificate import CertificateService + + return CertificateService(cert_dir=tmp_path, serial="TEST123") + + def test_generate_certificates(self, cert_service, tmp_path): + """Verify certificates are generated correctly.""" + cert_path, key_path = cert_service.generate_certificates() + + assert cert_path.exists() + assert key_path.exists() + + # Verify certificate content + cert_content = cert_path.read_text() + assert "BEGIN CERTIFICATE" in cert_content + + key_content = key_path.read_text() + assert "BEGIN" in key_content and "KEY" in key_content + + def test_certificates_reused_if_exist(self, cert_service): + """Verify existing certificates are reused.""" + # First generation + cert_path1, key_path1 = cert_service.generate_certificates() + mtime1 = cert_path1.stat().st_mtime + + # Second call should reuse (via ensure_certificates) + cert_path2, key_path2 = cert_service.ensure_certificates() + mtime2 = cert_path2.stat().st_mtime + + assert mtime1 == mtime2 # File wasn't regenerated + + def test_delete_certificates(self, cert_service): + """Verify certificates can be deleted.""" + cert_service.generate_certificates() + + assert cert_service.cert_path.exists() + assert cert_service.key_path.exists() + + cert_service.delete_certificates() + + assert not cert_service.cert_path.exists() + assert not cert_service.key_path.exists() + + def test_ensure_creates_if_not_exist(self, cert_service): + """Verify ensure_certificates generates if not existing.""" + assert not cert_service.cert_path.exists() + + cert_path, key_path = cert_service.ensure_certificates() + + assert cert_path.exists() + assert key_path.exists() diff --git a/docker-compose.yml b/docker-compose.yml index 333dba6a1..43d9d9742 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx new file mode 100644 index 000000000..8363b9034 --- /dev/null +++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx @@ -0,0 +1,408 @@ +/** + * Tests for the VirtualPrinterSettings component. + * + * Tests the virtual printer configuration UI including: + * - Enable/disable toggle + * - Access code management + * - Archive mode selection + * - Status display + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings'; + +// Mock the API client +vi.mock('../../api/client', () => ({ + virtualPrinterApi: { + getSettings: vi.fn(), + updateSettings: vi.fn(), + }, +})); + +// Import mocked module +import { virtualPrinterApi } from '../../api/client'; + +// Mock data factory +const createMockSettings = (overrides = {}) => ({ + enabled: false, + access_code_set: false, + mode: 'immediate' as const, + status: { + enabled: false, + running: false, + mode: 'immediate', + name: 'Bambuddy', + serial: '00M09A391800001', + pending_files: 0, + }, + ...overrides, +}); + +describe('VirtualPrinterSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementation + vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings()); + vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings()); + }); + + describe('rendering', () => { + it('renders loading state initially', () => { + // Delay the API response to catch loading state + vi.mocked(virtualPrinterApi.getSettings).mockImplementation( + () => new Promise(() => {}) // Never resolves + ); + render(); + + // Should show loading spinner + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('renders component title', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Virtual Printer')).toBeInTheDocument(); + }); + }); + + it('renders enable toggle', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument(); + }); + }); + + it('renders access code section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Access Code')).toBeInTheDocument(); + }); + }); + + it('renders archive mode section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Archive Mode')).toBeInTheDocument(); + }); + }); + + it('renders how it works info', async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('Access code is set')).toBeInTheDocument(); + }); + }); + + it('shows character count while typing', async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText(/Required ports: 2021.*8883.*990/)).toBeInTheDocument(); + }); + }); + + it('shows iptables instructions', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/iptables -t nat -A PREROUTING/)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2e9305f4e..eb03271ac 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2342,3 +2342,77 @@ export const discoveryApi = { stopSubnetScan: () => request('/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('/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(`/settings/virtual-printer?${params.toString()}`, { + method: 'PUT', + }); + }, +}; + +// Pending Uploads API +export const pendingUploadsApi = { + list: () => request('/pending-uploads/'), + + getCount: () => request<{ count: number }>('/pending-uploads/count'), + + get: (id: number) => request(`/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' }), +}; diff --git a/frontend/src/components/BackupModal.tsx b/frontend/src/components/BackupModal.tsx index 7e6af1bd7..308305718 100644 --- a/frontend/src/components/BackupModal.tsx +++ b/frontend/src/components/BackupModal.tsx @@ -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: , + default: false, + description: 'Virtual printer uploads awaiting review', + }, ]; interface BackupModalProps { diff --git a/frontend/src/components/PendingUploadsPanel.tsx b/frontend/src/components/PendingUploadsPanel.tsx new file mode 100644 index 000000000..abc6cfacf --- /dev/null +++ b/frontend/src/components/PendingUploadsPanel.tsx @@ -0,0 +1,361 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react'; +import { pendingUploadsApi } from '../api/client'; +import type { PendingUpload, ProjectListItem } from '../api/client'; +import { api } from '../api/client'; +import { Card, CardContent, CardHeader } from './Card'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; +import { ConfirmModal } from './ConfirmModal'; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatTimeAgo(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +interface PendingUploadItemProps { + upload: PendingUpload; + projects: ProjectListItem[]; + onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void; + onDiscard: (id: number) => void; + isArchiving: boolean; + isDiscarding: boolean; +} + +function PendingUploadItem({ + upload, + projects, + onArchive, + onDiscard, + isArchiving, + isDiscarding, +}: PendingUploadItemProps) { + const [expanded, setExpanded] = useState(false); + const [tags, setTags] = useState(upload.tags || ''); + const [notes, setNotes] = useState(upload.notes || ''); + const [projectId, setProjectId] = useState(upload.project_id); + + return ( + + +
+
+ +
+

{upload.filename}

+
+ {formatFileSize(upload.file_size)} + · + + + {formatTimeAgo(upload.uploaded_at)} + + {upload.source_ip && ( + <> + · + from {upload.source_ip} + + )} +
+
+
+
+ + + +
+
+ + {/* Expanded details for adding tags/notes/project */} + {expanded && ( +
+
+ + 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" + /> +
+
+ +