mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
- Add Virtual Printer feature - emulate Bambu printer on network
- Virtual printer appears in Bambu Studio/Orca Slicer via SSDP discovery - Secure TLS/MQTT communication with auto-generated certificates - Queue mode (pending uploads) or auto-start mode - Configurable access code for authentication - Docker support with network_mode: host and certificate persistence - Fix backup/restore for virtual printer settings (auto-save no longer overwrites)
This commit is contained in:
@@ -31,6 +31,9 @@ npm-debug.log*
|
||||
# Archive files (user data)
|
||||
archive/
|
||||
|
||||
# Virtual printer (auto-generated certs and uploads)
|
||||
virtual_printer/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -6,16 +6,23 @@ class AppSettings(BaseModel):
|
||||
|
||||
auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
|
||||
save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
|
||||
capture_finish_photo: bool = Field(default=True, description="Capture photo from printer camera when print completes")
|
||||
capture_finish_photo: bool = Field(
|
||||
default=True, description="Capture photo from printer camera when print completes"
|
||||
)
|
||||
default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
|
||||
currency: str = Field(default="USD", description="Currency for cost tracking")
|
||||
energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
|
||||
energy_tracking_mode: str = Field(default="total", description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption")
|
||||
energy_tracking_mode: str = Field(
|
||||
default="total",
|
||||
description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption",
|
||||
)
|
||||
|
||||
# Spoolman integration
|
||||
spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
|
||||
spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
|
||||
spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
|
||||
spoolman_sync_mode: str = Field(
|
||||
default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press"
|
||||
)
|
||||
|
||||
# Updates
|
||||
check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
|
||||
@@ -25,9 +32,13 @@ class AppSettings(BaseModel):
|
||||
|
||||
# AMS threshold settings for humidity and temperature coloring
|
||||
ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
|
||||
ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
|
||||
ams_humidity_fair: int = Field(
|
||||
default=60, description="Humidity threshold for fair (orange): <= this value, > is red"
|
||||
)
|
||||
ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
|
||||
ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
|
||||
ams_temp_fair: float = Field(
|
||||
default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
|
||||
)
|
||||
ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
|
||||
|
||||
# Date/time display format
|
||||
@@ -40,6 +51,11 @@ class AppSettings(BaseModel):
|
||||
# Telemetry
|
||||
telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
|
||||
|
||||
# Virtual Printer
|
||||
virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
|
||||
virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
|
||||
virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'")
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
"""Schema for updating settings (all fields optional)."""
|
||||
@@ -65,3 +81,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
time_format: str | None = None
|
||||
default_printer_id: int | None = None
|
||||
telemetry_enabled: bool | None = None
|
||||
virtual_printer_enabled: bool | None = None
|
||||
virtual_printer_access_code: str | None = None
|
||||
virtual_printer_mode: str | None = None
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Virtual printer services for slicer integration."""
|
||||
|
||||
from backend.app.services.virtual_printer.manager import virtual_printer_manager
|
||||
|
||||
__all__ = ["virtual_printer_manager"]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""TLS certificate generation for virtual printer services.
|
||||
|
||||
Generates certificates that mimic real Bambu printer certificate format:
|
||||
- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
|
||||
- Printer certificate has CN = serial number, signed by the CA
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default serial number for virtual printer (matches SSDP/MQTT config)
|
||||
DEFAULT_SERIAL = "00M09A391800001"
|
||||
|
||||
|
||||
def _get_local_ip() -> str:
|
||||
"""Get the local IP address."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
class CertificateService:
|
||||
"""Generate and manage TLS certificates for virtual printer.
|
||||
|
||||
Creates a certificate chain mimicking real Bambu printers:
|
||||
- Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN"
|
||||
- Printer cert with CN=serial_number, signed by the CA
|
||||
"""
|
||||
|
||||
def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL):
|
||||
"""Initialize the certificate service.
|
||||
|
||||
Args:
|
||||
cert_dir: Directory to store certificates
|
||||
serial: Serial number to use as CN in printer certificate
|
||||
"""
|
||||
self.cert_dir = cert_dir
|
||||
self.serial = serial
|
||||
self.ca_cert_path = cert_dir / "bbl_ca.crt"
|
||||
self.ca_key_path = cert_dir / "bbl_ca.key"
|
||||
self.cert_path = cert_dir / "virtual_printer.crt"
|
||||
self.key_path = cert_dir / "virtual_printer.key"
|
||||
|
||||
def ensure_certificates(self) -> tuple[Path, Path]:
|
||||
"""Ensure certificates exist, generate if needed.
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_path, key_path)
|
||||
"""
|
||||
if self.cert_path.exists() and self.key_path.exists():
|
||||
logger.debug("Using existing virtual printer certificates")
|
||||
return self.cert_path, self.key_path
|
||||
return self.generate_certificates()
|
||||
|
||||
def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
|
||||
"""Generate a CA certificate for the virtual printer.
|
||||
|
||||
We use a generic name instead of mimicking BBL CA, since the slicer
|
||||
may specifically reject certificates claiming to be from BBL but
|
||||
with a different public key.
|
||||
|
||||
Returns:
|
||||
Tuple of (ca_private_key, ca_certificate)
|
||||
"""
|
||||
logger.info("Generating Virtual Printer CA certificate...")
|
||||
|
||||
# Generate CA private key
|
||||
ca_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Use a generic CA name - NOT BBL to avoid being rejected as fake
|
||||
ca_name = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"),
|
||||
]
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(ca_name)
|
||||
.issuer_name(ca_name)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + timedelta(days=7300)) # 20 years
|
||||
.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=0),
|
||||
critical=True,
|
||||
)
|
||||
.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=True,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
return ca_key, ca_cert
|
||||
|
||||
def generate_certificates(self) -> tuple[Path, Path]:
|
||||
"""Generate CA and printer certificates.
|
||||
|
||||
Creates a certificate chain mimicking real Bambu printers:
|
||||
- BBL CA (self-signed root)
|
||||
- Printer certificate (CN=serial, signed by BBL CA)
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_path, key_path)
|
||||
"""
|
||||
logger.info(f"Generating certificates for virtual printer (serial: {self.serial})...")
|
||||
|
||||
# Ensure directory exists
|
||||
self.cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate or load CA
|
||||
ca_key, ca_cert = self._generate_ca_certificate()
|
||||
|
||||
# Save CA certificate and key
|
||||
self.ca_key_path.write_bytes(
|
||||
ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
self.ca_key_path.chmod(0o600)
|
||||
self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
# Generate printer private key
|
||||
printer_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Printer certificate subject - CN is the serial number (like real Bambu printers)
|
||||
printer_subject = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, self.serial),
|
||||
]
|
||||
)
|
||||
|
||||
# Issuer is the CA
|
||||
issuer = ca_cert.subject
|
||||
|
||||
now = datetime.now(UTC)
|
||||
local_ip = _get_local_ip()
|
||||
logger.info(f"Generating printer certificate with CN={self.serial}, local IP: {local_ip}")
|
||||
|
||||
# Build printer certificate signed by CA
|
||||
printer_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(printer_subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(printer_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + timedelta(days=3650)) # 10 years
|
||||
.add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None),
|
||||
critical=True,
|
||||
)
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(
|
||||
[
|
||||
x509.DNSName("localhost"),
|
||||
x509.DNSName("bambuddy"),
|
||||
x509.DNSName(self.serial),
|
||||
x509.IPAddress(IPv4Address(local_ip)),
|
||||
x509.IPAddress(IPv4Address("127.0.0.1")),
|
||||
]
|
||||
),
|
||||
critical=False,
|
||||
)
|
||||
.add_extension(
|
||||
x509.ExtendedKeyUsage(
|
||||
[
|
||||
ExtendedKeyUsageOID.SERVER_AUTH,
|
||||
ExtendedKeyUsageOID.CLIENT_AUTH,
|
||||
]
|
||||
),
|
||||
critical=False,
|
||||
)
|
||||
.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=True,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed
|
||||
)
|
||||
|
||||
# Write printer private key
|
||||
self.key_path.write_bytes(
|
||||
printer_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
self.key_path.chmod(0o600)
|
||||
|
||||
# Write printer certificate (include CA cert in chain for full chain)
|
||||
cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
)
|
||||
self.cert_path.write_bytes(cert_chain)
|
||||
|
||||
logger.info(f"Generated certificate chain at {self.cert_dir}")
|
||||
logger.info(" CA: CN=Virtual Printer CA")
|
||||
logger.info(f" Printer: CN={self.serial}")
|
||||
return self.cert_path, self.key_path
|
||||
|
||||
def delete_certificates(self) -> None:
|
||||
"""Delete existing certificates."""
|
||||
for path in [self.cert_path, self.key_path, self.ca_cert_path, self.ca_key_path]:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.info("Deleted virtual printer certificates")
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Implicit FTPS server for receiving 3MF uploads from slicers.
|
||||
|
||||
Implements an implicit FTPS server (TLS from byte 0) that accepts file uploads
|
||||
from Bambu Studio and OrcaSlicer, matching the real Bambu printer behavior.
|
||||
|
||||
Unlike explicit FTPS (AUTH TLS), implicit FTPS wraps the connection in TLS
|
||||
immediately upon connection, before any FTP commands are exchanged.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import ssl
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default FTP port for Bambu printers (implicit FTPS)
|
||||
FTP_PORT = 9990
|
||||
|
||||
|
||||
class FTPSession:
|
||||
"""Handles a single FTP client session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
upload_dir: Path,
|
||||
access_code: str,
|
||||
ssl_context: ssl.SSLContext,
|
||||
on_file_received: Callable[[Path, str], None] | None,
|
||||
):
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.upload_dir = upload_dir
|
||||
self.access_code = access_code
|
||||
self.ssl_context = ssl_context
|
||||
self.on_file_received = on_file_received
|
||||
|
||||
self.authenticated = False
|
||||
self.username: str | None = None
|
||||
self.current_dir = upload_dir
|
||||
self.transfer_type = "A" # ASCII by default
|
||||
self.data_server: asyncio.Server | None = None
|
||||
self.data_port: int | None = None
|
||||
|
||||
# For data transfer coordination
|
||||
self._data_reader: asyncio.StreamReader | None = None
|
||||
self._data_writer: asyncio.StreamWriter | None = None
|
||||
self._data_connected = asyncio.Event()
|
||||
|
||||
peername = writer.get_extra_info("peername")
|
||||
self.remote_ip = peername[0] if peername else "unknown"
|
||||
|
||||
async def send(self, code: int, message: str) -> None:
|
||||
"""Send an FTP response."""
|
||||
response = f"{code} {message}\r\n"
|
||||
logger.info(f"FTP -> {self.remote_ip}: {response.strip()}")
|
||||
self.writer.write(response.encode("utf-8"))
|
||||
await self.writer.drain()
|
||||
|
||||
async def handle(self) -> None:
|
||||
"""Handle the FTP session."""
|
||||
try:
|
||||
# Send welcome banner
|
||||
await self.send(220, "Bambuddy Virtual Printer FTP ready")
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = await asyncio.wait_for(
|
||||
self.reader.readline(),
|
||||
timeout=300, # 5 minute timeout
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.debug(f"FTP session timeout from {self.remote_ip}")
|
||||
break
|
||||
|
||||
if not line:
|
||||
break
|
||||
|
||||
try:
|
||||
command_line = line.decode("utf-8").strip()
|
||||
except UnicodeDecodeError:
|
||||
command_line = line.decode("latin-1").strip()
|
||||
|
||||
if not command_line:
|
||||
continue
|
||||
|
||||
logger.info(f"FTP <- {self.remote_ip}: {command_line}")
|
||||
|
||||
# Parse command and argument
|
||||
parts = command_line.split(" ", 1)
|
||||
cmd = parts[0].upper()
|
||||
arg = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Dispatch command
|
||||
handler = getattr(self, f"cmd_{cmd}", None)
|
||||
if handler:
|
||||
await handler(arg)
|
||||
else:
|
||||
logger.warning(f"FTP command not implemented: {cmd}")
|
||||
await self.send(502, f"Command {cmd} not implemented")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"FTP session cancelled from {self.remote_ip}")
|
||||
except Exception as e:
|
||||
logger.error(f"FTP session error from {self.remote_ip}: {e}")
|
||||
finally:
|
||||
logger.info(f"FTP session ended from {self.remote_ip}")
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""Clean up session resources."""
|
||||
if self.data_server:
|
||||
self.data_server.close()
|
||||
try:
|
||||
await self.data_server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self.data_server = None
|
||||
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# FTP Commands
|
||||
|
||||
async def cmd_USER(self, arg: str) -> None:
|
||||
"""Handle USER command."""
|
||||
self.username = arg
|
||||
if arg.lower() == "bblp":
|
||||
await self.send(331, "Password required")
|
||||
else:
|
||||
await self.send(530, "Invalid user")
|
||||
|
||||
async def cmd_PASS(self, arg: str) -> None:
|
||||
"""Handle PASS command."""
|
||||
if self.username and self.username.lower() == "bblp":
|
||||
if arg == self.access_code:
|
||||
self.authenticated = True
|
||||
await self.send(230, "Login successful")
|
||||
logger.info(f"FTP login from {self.remote_ip}")
|
||||
else:
|
||||
await self.send(530, "Login incorrect")
|
||||
logger.warning(f"FTP failed login from {self.remote_ip}")
|
||||
else:
|
||||
await self.send(503, "Login with USER first")
|
||||
|
||||
async def cmd_SYST(self, arg: str) -> None:
|
||||
"""Handle SYST command."""
|
||||
await self.send(215, "UNIX Type: L8")
|
||||
|
||||
async def cmd_FEAT(self, arg: str) -> None:
|
||||
"""Handle FEAT command."""
|
||||
features = [
|
||||
"211-Features:",
|
||||
" PASV",
|
||||
" UTF8",
|
||||
" SIZE",
|
||||
"211 End",
|
||||
]
|
||||
for line in features[:-1]:
|
||||
self.writer.write(f"{line}\r\n".encode())
|
||||
await self.writer.drain()
|
||||
self.writer.write(f"{features[-1]}\r\n".encode())
|
||||
await self.writer.drain()
|
||||
|
||||
async def cmd_PWD(self, arg: str) -> None:
|
||||
"""Handle PWD command."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
await self.send(257, '"/" is current directory')
|
||||
|
||||
async def cmd_CWD(self, arg: str) -> None:
|
||||
"""Handle CWD command."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
# Accept any directory change (we use a flat structure)
|
||||
await self.send(250, "Directory changed")
|
||||
|
||||
async def cmd_TYPE(self, arg: str) -> None:
|
||||
"""Handle TYPE command."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
if arg.upper() in ("A", "I"):
|
||||
self.transfer_type = arg.upper()
|
||||
type_name = "ASCII" if arg.upper() == "A" else "Binary"
|
||||
await self.send(200, f"Type set to {type_name}")
|
||||
else:
|
||||
await self.send(504, "Type not supported")
|
||||
|
||||
async def cmd_PASV(self, arg: str) -> None:
|
||||
"""Handle PASV command - set up passive data connection."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
|
||||
# Close any existing data connection/server
|
||||
await self._close_data_connection()
|
||||
|
||||
# Reset connection state
|
||||
self._data_connected.clear()
|
||||
self._data_reader = None
|
||||
self._data_writer = None
|
||||
|
||||
# Find a free port for passive data connection
|
||||
self.data_port = random.randint(50000, 60000)
|
||||
|
||||
try:
|
||||
# Create data server with TLS
|
||||
self.data_server = await asyncio.start_server(
|
||||
self._handle_data_connection,
|
||||
"0.0.0.0",
|
||||
self.data_port,
|
||||
ssl=self.ssl_context,
|
||||
)
|
||||
|
||||
# Get server's IP for response
|
||||
# Use the IP the client connected to
|
||||
sockname = self.writer.get_extra_info("sockname")
|
||||
ip = sockname[0] if sockname else "127.0.0.1"
|
||||
|
||||
# Format IP and port for PASV response
|
||||
ip_parts = ip.split(".")
|
||||
port_hi = self.data_port // 256
|
||||
port_lo = self.data_port % 256
|
||||
|
||||
await self.send(
|
||||
227,
|
||||
f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
|
||||
)
|
||||
logger.info(f"FTP PASV listening on port {self.data_port}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create passive data connection: {e}")
|
||||
await self.send(425, "Cannot open data connection")
|
||||
|
||||
async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""Handle incoming data connection (used by PASV)."""
|
||||
logger.info(f"FTP data connection established from {self.remote_ip}")
|
||||
self._data_reader = reader
|
||||
self._data_writer = writer
|
||||
self._data_connected.set()
|
||||
# Don't close - let the transfer command handle it
|
||||
|
||||
async def _close_data_connection(self) -> None:
|
||||
"""Close the data connection and server."""
|
||||
if self._data_writer:
|
||||
try:
|
||||
self._data_writer.close()
|
||||
await self._data_writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._data_writer = None
|
||||
self._data_reader = None
|
||||
|
||||
if self.data_server:
|
||||
try:
|
||||
self.data_server.close()
|
||||
await self.data_server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self.data_server = None
|
||||
|
||||
async def cmd_STOR(self, arg: str) -> None:
|
||||
"""Handle STOR command - receive file upload."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
|
||||
if not self.data_server:
|
||||
await self.send(425, "Use PASV first")
|
||||
return
|
||||
|
||||
filename = Path(arg).name # Sanitize filename
|
||||
file_path = self.upload_dir / filename
|
||||
|
||||
logger.info(f"FTP receiving file: {filename} from {self.remote_ip}")
|
||||
|
||||
await self.send(150, f"Opening data connection for {filename}")
|
||||
|
||||
# Wait for data connection to be established (client connects after 150)
|
||||
try:
|
||||
await asyncio.wait_for(self._data_connected.wait(), timeout=30)
|
||||
except TimeoutError:
|
||||
logger.error("FTP data connection timeout - client didn't connect")
|
||||
await self.send(425, "Data connection timeout")
|
||||
await self._close_data_connection()
|
||||
return
|
||||
|
||||
if not self._data_reader:
|
||||
await self.send(425, "Data connection failed")
|
||||
await self._close_data_connection()
|
||||
return
|
||||
|
||||
# Receive data
|
||||
data_content: list[bytes] = []
|
||||
try:
|
||||
while True:
|
||||
chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
|
||||
if not chunk:
|
||||
break
|
||||
data_content.append(chunk)
|
||||
logger.debug(f"FTP received chunk: {len(chunk)} bytes")
|
||||
except TimeoutError:
|
||||
logger.error("FTP data transfer timeout")
|
||||
await self.send(426, "Transfer timeout")
|
||||
await self._close_data_connection()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"FTP data transfer error: {e}")
|
||||
await self.send(426, f"Transfer failed: {e}")
|
||||
await self._close_data_connection()
|
||||
return
|
||||
|
||||
# Close data connection
|
||||
await self._close_data_connection()
|
||||
|
||||
# Write file
|
||||
try:
|
||||
total_size = sum(len(c) for c in data_content)
|
||||
file_path.write_bytes(b"".join(data_content))
|
||||
logger.info(f"FTP saved file: {file_path} ({total_size} bytes)")
|
||||
await self.send(226, "Transfer complete")
|
||||
|
||||
# Notify callback
|
||||
if self.on_file_received:
|
||||
try:
|
||||
result = self.on_file_received(file_path, self.remote_ip)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"File received callback error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save file {file_path}: {e}")
|
||||
await self.send(550, "Failed to save file")
|
||||
|
||||
async def cmd_SIZE(self, arg: str) -> None:
|
||||
"""Handle SIZE command."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
# We don't store files for SIZE queries
|
||||
await self.send(550, "File not found")
|
||||
|
||||
async def cmd_QUIT(self, arg: str) -> None:
|
||||
"""Handle QUIT command."""
|
||||
await self.send(221, "Goodbye")
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
async def cmd_NOOP(self, arg: str) -> None:
|
||||
"""Handle NOOP command."""
|
||||
await self.send(200, "OK")
|
||||
|
||||
async def cmd_OPTS(self, arg: str) -> None:
|
||||
"""Handle OPTS command."""
|
||||
if arg.upper().startswith("UTF8"):
|
||||
await self.send(200, "UTF8 mode enabled")
|
||||
else:
|
||||
await self.send(501, "Option not supported")
|
||||
|
||||
async def cmd_PBSZ(self, arg: str) -> None:
|
||||
"""Handle PBSZ (Protection Buffer Size) command.
|
||||
|
||||
Required for FTP security extensions. With TLS, buffer size is 0.
|
||||
"""
|
||||
await self.send(200, "PBSZ=0")
|
||||
|
||||
async def cmd_PROT(self, arg: str) -> None:
|
||||
"""Handle PROT (Data Channel Protection Level) command.
|
||||
|
||||
P = Private (encrypted), which we always use with implicit FTPS.
|
||||
"""
|
||||
if arg.upper() == "P":
|
||||
await self.send(200, "Protection level set to Private")
|
||||
elif arg.upper() == "C":
|
||||
# Clear (unprotected) - we don't support this
|
||||
await self.send(536, "Protection level C not supported")
|
||||
else:
|
||||
await self.send(504, f"Protection level {arg} not supported")
|
||||
|
||||
async def cmd_MKD(self, arg: str) -> None:
|
||||
"""Handle MKD (Make Directory) command."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
# We don't really create directories, just pretend it works
|
||||
await self.send(257, f'"{arg}" directory created')
|
||||
|
||||
async def cmd_LIST(self, arg: str) -> None:
|
||||
"""Handle LIST command - list directory contents."""
|
||||
if not self.authenticated:
|
||||
await self.send(530, "Not logged in")
|
||||
return
|
||||
# We don't support listing, return empty
|
||||
await self.send(150, "Opening data connection")
|
||||
await self.send(226, "Transfer complete")
|
||||
|
||||
|
||||
class VirtualPrinterFTPServer:
|
||||
"""Implicit FTPS server that accepts uploads from slicers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
upload_dir: Path,
|
||||
access_code: str,
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
port: int = FTP_PORT,
|
||||
on_file_received: Callable[[Path, str], None] | None = None,
|
||||
):
|
||||
"""Initialize the FTPS server.
|
||||
|
||||
Args:
|
||||
upload_dir: Directory to store uploaded files
|
||||
access_code: Password for authentication (bblp user)
|
||||
cert_path: Path to TLS certificate file
|
||||
key_path: Path to TLS private key file
|
||||
port: Port to listen on (default 990)
|
||||
on_file_received: Callback when file upload completes (path, source_ip)
|
||||
"""
|
||||
self.upload_dir = upload_dir
|
||||
self.access_code = access_code
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
self.port = port
|
||||
self.on_file_received = on_file_received
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
self._ssl_context: ssl.SSLContext | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the implicit FTPS server."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
logger.info(f"Starting virtual printer implicit FTPS on port {self.port}")
|
||||
|
||||
# Ensure upload directory exists
|
||||
self.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_dir = self.upload_dir / "cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create SSL context for implicit FTPS (TLS from byte 0)
|
||||
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
|
||||
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
|
||||
try:
|
||||
# Create server with SSL - TLS handshake happens before any FTP data
|
||||
self._server = await asyncio.start_server(
|
||||
self._handle_client,
|
||||
"0.0.0.0",
|
||||
self.port,
|
||||
ssl=self._ssl_context, # This makes it implicit FTPS!
|
||||
)
|
||||
self._running = True
|
||||
|
||||
logger.info(f"Implicit FTPS server started on port {self.port}")
|
||||
|
||||
async with self._server:
|
||||
await self._server.serve_forever()
|
||||
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
logger.error(f"FTP port {self.port} is already in use")
|
||||
else:
|
||||
logger.error(f"FTP server error: {e}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("FTP server task cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"FTP server error: {e}")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""Handle a new FTP client connection."""
|
||||
peername = writer.get_extra_info("peername")
|
||||
logger.info(f"FTP connection from {peername}")
|
||||
|
||||
session = FTPSession(
|
||||
reader=reader,
|
||||
writer=writer,
|
||||
upload_dir=self.upload_dir,
|
||||
access_code=self.access_code,
|
||||
ssl_context=self._ssl_context,
|
||||
on_file_received=self.on_file_received,
|
||||
)
|
||||
|
||||
await session.handle()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the FTPS server."""
|
||||
logger.info("Stopping FTP server")
|
||||
self._running = False
|
||||
|
||||
if self._server:
|
||||
try:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing FTP server: {e}")
|
||||
self._server = None
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.services.virtual_printer.certificate import CertificateService
|
||||
from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
|
||||
from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
|
||||
from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VirtualPrinterManager:
|
||||
"""Manages the virtual printer lifecycle and coordinates all services."""
|
||||
|
||||
# Fixed configuration
|
||||
PRINTER_NAME = "Bambuddy"
|
||||
PRINTER_SERIAL = "00M09A391800001" # X1C serial format
|
||||
PRINTER_MODEL = "3DPrinter-X1-Carbon" # Full model name for slicer compatibility
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the virtual printer manager."""
|
||||
self._session_factory: Callable | None = None
|
||||
self._enabled = False
|
||||
self._access_code = ""
|
||||
self._mode = "immediate"
|
||||
|
||||
# Service instances
|
||||
self._ssdp: VirtualPrinterSSDPServer | None = None
|
||||
self._ftp: VirtualPrinterFTPServer | None = None
|
||||
self._mqtt: SimpleMQTTServer | None = None
|
||||
|
||||
# Background tasks
|
||||
self._tasks: list[asyncio.Task] = []
|
||||
|
||||
# Directories
|
||||
self._base_dir = app_settings.base_dir / "virtual_printer"
|
||||
self._upload_dir = self._base_dir / "uploads"
|
||||
self._cert_dir = self._base_dir / "certs"
|
||||
|
||||
# Certificate service - pass serial to match CN in certificate
|
||||
self._cert_service = CertificateService(self._cert_dir, serial=self.PRINTER_SERIAL)
|
||||
|
||||
# Track pending uploads for MQTT correlation
|
||||
self._pending_files: dict[str, Path] = {}
|
||||
|
||||
def set_session_factory(self, session_factory: Callable) -> None:
|
||||
"""Set the database session factory.
|
||||
|
||||
Args:
|
||||
session_factory: Async context manager for database sessions
|
||||
"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if virtual printer is enabled."""
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if virtual printer services are running."""
|
||||
return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
|
||||
|
||||
async def configure(
|
||||
self,
|
||||
enabled: bool,
|
||||
access_code: str = "",
|
||||
mode: str = "immediate",
|
||||
) -> None:
|
||||
"""Configure and start/stop virtual printer.
|
||||
|
||||
Args:
|
||||
enabled: Whether to enable the virtual printer
|
||||
access_code: Authentication password for slicer connections
|
||||
mode: Archive mode - 'immediate' or 'queue'
|
||||
"""
|
||||
if enabled and not access_code:
|
||||
raise ValueError("Access code is required when enabling virtual printer")
|
||||
|
||||
self._access_code = access_code
|
||||
self._mode = mode
|
||||
|
||||
if enabled and not self._enabled:
|
||||
await self._start()
|
||||
elif not enabled and self._enabled:
|
||||
await self._stop()
|
||||
|
||||
self._enabled = enabled
|
||||
|
||||
async def _start(self) -> None:
|
||||
"""Start all virtual printer services."""
|
||||
logger.info("Starting virtual printer services...")
|
||||
|
||||
# Ensure certificates exist
|
||||
cert_path, key_path = self._cert_service.ensure_certificates()
|
||||
|
||||
# Create directories
|
||||
self._upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self._upload_dir / "cache").mkdir(exist_ok=True)
|
||||
|
||||
# Initialize services
|
||||
self._ssdp = VirtualPrinterSSDPServer(
|
||||
name=self.PRINTER_NAME,
|
||||
serial=self.PRINTER_SERIAL,
|
||||
model=self.PRINTER_MODEL,
|
||||
)
|
||||
|
||||
self._ftp = VirtualPrinterFTPServer(
|
||||
upload_dir=self._upload_dir,
|
||||
access_code=self._access_code,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
on_file_received=self._on_file_received,
|
||||
)
|
||||
|
||||
self._mqtt = SimpleMQTTServer(
|
||||
serial=self.PRINTER_SERIAL,
|
||||
access_code=self._access_code,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
on_print_command=self._on_print_command,
|
||||
)
|
||||
|
||||
# Start services as background tasks
|
||||
# Wrap each in error handler so one failure doesn't stop others
|
||||
async def run_with_logging(coro, name):
|
||||
try:
|
||||
await coro
|
||||
except Exception as e:
|
||||
logger.error(f"Virtual printer {name} failed: {e}")
|
||||
|
||||
self._tasks = [
|
||||
asyncio.create_task(run_with_logging(self._ssdp.start(), "SSDP"), name="virtual_printer_ssdp"),
|
||||
asyncio.create_task(run_with_logging(self._ftp.start(), "FTP"), name="virtual_printer_ftp"),
|
||||
asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
|
||||
]
|
||||
|
||||
logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.PRINTER_SERIAL})")
|
||||
|
||||
async def _stop(self) -> None:
|
||||
"""Stop all virtual printer services."""
|
||||
logger.info("Stopping virtual printer services...")
|
||||
|
||||
# Cancel all tasks
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._tasks = []
|
||||
|
||||
# Stop services
|
||||
if self._ssdp:
|
||||
await self._ssdp.stop()
|
||||
self._ssdp = None
|
||||
|
||||
if self._ftp:
|
||||
await self._ftp.stop()
|
||||
self._ftp = None
|
||||
|
||||
if self._mqtt:
|
||||
await self._mqtt.stop()
|
||||
self._mqtt = None
|
||||
|
||||
logger.info("Virtual printer stopped")
|
||||
|
||||
async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
|
||||
"""Handle file upload completion from FTP.
|
||||
|
||||
Args:
|
||||
file_path: Path to uploaded file
|
||||
source_ip: IP address of the uploading slicer
|
||||
"""
|
||||
logger.info(f"Virtual printer received file: {file_path.name} from {source_ip}")
|
||||
|
||||
# Store file reference for MQTT correlation
|
||||
self._pending_files[file_path.name] = file_path
|
||||
|
||||
# In immediate mode, archive right away
|
||||
# In queue mode, create pending upload record
|
||||
if self._mode == "immediate":
|
||||
await self._archive_file(file_path, source_ip)
|
||||
else:
|
||||
await self._queue_file(file_path, source_ip)
|
||||
|
||||
async def _on_print_command(self, filename: str, data: dict) -> None:
|
||||
"""Handle print command from MQTT.
|
||||
|
||||
In a real printer, this would start the print. For virtual printer,
|
||||
we just log it since archiving is handled by file upload.
|
||||
|
||||
Args:
|
||||
filename: Name of the file to print
|
||||
data: Print command data (contains settings like timelapse, bed_leveling, etc.)
|
||||
"""
|
||||
logger.info(f"Virtual printer received print command for: {filename}")
|
||||
logger.debug(f"Print command data: {data}")
|
||||
|
||||
# The file should already be archived from FTP upload
|
||||
# This command just confirms the slicer's intent to "print"
|
||||
|
||||
async def _archive_file(self, file_path: Path, source_ip: str) -> None:
|
||||
"""Archive file immediately.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
source_ip: IP address of uploader
|
||||
"""
|
||||
if not self._session_factory:
|
||||
logger.error("Cannot archive: no database session factory configured")
|
||||
return
|
||||
|
||||
# Only archive 3MF files
|
||||
if file_path.suffix.lower() != ".3mf":
|
||||
logger.debug(f"Skipping non-3MF file: {file_path.name}")
|
||||
# Remove from pending and clean up
|
||||
self._pending_files.pop(file_path.name, None)
|
||||
try:
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.app.services.archive import ArchiveService
|
||||
|
||||
async with self._session_factory() as db:
|
||||
service = ArchiveService(db)
|
||||
|
||||
# Archive the print
|
||||
archive = await service.archive_print(
|
||||
printer_id=None, # No physical printer
|
||||
source_file=file_path,
|
||||
print_data={
|
||||
"status": "archived",
|
||||
"source": "virtual_printer",
|
||||
"source_ip": source_ip,
|
||||
},
|
||||
)
|
||||
|
||||
if archive:
|
||||
logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
|
||||
|
||||
# Clean up uploaded file (it's now copied to archive)
|
||||
try:
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove from pending
|
||||
self._pending_files.pop(file_path.name, None)
|
||||
else:
|
||||
logger.error(f"Failed to archive file: {file_path.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error archiving file: {e}")
|
||||
|
||||
async def _queue_file(self, file_path: Path, source_ip: str) -> None:
|
||||
"""Queue file for user review.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
source_ip: IP address of uploader
|
||||
"""
|
||||
if not self._session_factory:
|
||||
logger.error("Cannot queue: no database session factory configured")
|
||||
return
|
||||
|
||||
# Only queue 3MF files
|
||||
if file_path.suffix.lower() != ".3mf":
|
||||
logger.warning(f"Skipping non-3MF file: {file_path.name}")
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
|
||||
async with self._session_factory() as db:
|
||||
pending = PendingUpload(
|
||||
filename=file_path.name,
|
||||
file_path=str(file_path),
|
||||
file_size=file_path.stat().st_size,
|
||||
source_ip=source_ip,
|
||||
status="pending",
|
||||
uploaded_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(pending)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Queued virtual printer upload: {pending.id} - {file_path.name}")
|
||||
|
||||
# Remove from pending files dict
|
||||
self._pending_files.pop(file_path.name, None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error queueing file: {e}")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get virtual printer status.
|
||||
|
||||
Returns:
|
||||
Status dictionary with enabled, running, mode, etc.
|
||||
"""
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"running": self.is_running,
|
||||
"mode": self._mode,
|
||||
"name": self.PRINTER_NAME,
|
||||
"serial": self.PRINTER_SERIAL,
|
||||
"model": self.PRINTER_MODEL,
|
||||
"pending_files": len(self._pending_files),
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
virtual_printer_manager = VirtualPrinterManager()
|
||||
@@ -0,0 +1,799 @@
|
||||
"""MQTT broker for virtual printer.
|
||||
|
||||
Implements an MQTT broker that accepts connections from slicers,
|
||||
authenticates with the configured access code, and logs print commands.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default MQTT port for Bambu printers (MQTT over TLS)
|
||||
MQTT_PORT = 8883
|
||||
|
||||
|
||||
class VirtualPrinterMQTTServer:
|
||||
"""MQTT broker that accepts connections from slicers.
|
||||
|
||||
This is a minimal MQTT broker implementation that:
|
||||
- Accepts TLS connections on port 8883
|
||||
- Authenticates with username 'bblp' and the configured access code
|
||||
- Receives print commands on device/{serial}/request
|
||||
- Can publish status on device/{serial}/report
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
serial: str,
|
||||
access_code: str,
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
port: int = MQTT_PORT,
|
||||
on_print_command: Callable[[str, dict], None] | None = None,
|
||||
):
|
||||
"""Initialize the MQTT server.
|
||||
|
||||
Args:
|
||||
serial: Virtual printer serial number
|
||||
access_code: Password for authentication
|
||||
cert_path: Path to TLS certificate
|
||||
key_path: Path to TLS private key
|
||||
port: Port to listen on (default 8883)
|
||||
on_print_command: Callback when print command received (filename, data)
|
||||
"""
|
||||
self.serial = serial
|
||||
self.access_code = access_code
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
self.port = port
|
||||
self.on_print_command = on_print_command
|
||||
self._running = False
|
||||
self._broker = None
|
||||
self._broker_task = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the MQTT broker."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Try to import amqtt
|
||||
try:
|
||||
from amqtt.broker import Broker
|
||||
except ImportError:
|
||||
logger.error("amqtt not installed. Run: pip install amqtt")
|
||||
return
|
||||
|
||||
logger.info(f"Starting virtual printer MQTT broker on port {self.port}")
|
||||
|
||||
# Build broker configuration
|
||||
config = {
|
||||
"listeners": {
|
||||
"default": {
|
||||
"type": "tcp",
|
||||
"bind": f"0.0.0.0:{self.port}",
|
||||
"ssl": "on",
|
||||
"certfile": str(self.cert_path),
|
||||
"keyfile": str(self.key_path),
|
||||
},
|
||||
},
|
||||
"auth": {
|
||||
"allow-anonymous": False,
|
||||
"plugins": ["auth_custom"],
|
||||
},
|
||||
"topic-check": {
|
||||
"enabled": False, # Allow any topic
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
|
||||
# Create and start broker
|
||||
self._broker = Broker(config)
|
||||
|
||||
# Register custom auth plugin
|
||||
self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate
|
||||
|
||||
# Start the broker
|
||||
await self._broker.start()
|
||||
logger.info(f"MQTT broker started on port {self.port}")
|
||||
|
||||
# Keep running
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
logger.error(f"MQTT port {self.port} is already in use")
|
||||
else:
|
||||
logger.error(f"MQTT broker error: {e}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MQTT broker task cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT broker error: {e}")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def _authenticate(self, session) -> bool:
|
||||
"""Authenticate MQTT connection.
|
||||
|
||||
Args:
|
||||
session: MQTT session with username/password
|
||||
|
||||
Returns:
|
||||
True if authentication successful
|
||||
"""
|
||||
username = getattr(session, "username", None)
|
||||
password = getattr(session, "password", None)
|
||||
|
||||
# Bambu slicers use 'bblp' as username and access code as password
|
||||
if username == "bblp" and password == self.access_code:
|
||||
logger.debug(f"MQTT client authenticated from {session.remote_address}")
|
||||
return True
|
||||
|
||||
logger.warning(f"MQTT auth failed for user '{username}' from {session.remote_address}")
|
||||
return False
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the MQTT broker."""
|
||||
logger.info("Stopping MQTT broker")
|
||||
self._running = False
|
||||
|
||||
if self._broker:
|
||||
try:
|
||||
await self._broker.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error shutting down MQTT broker: {e}")
|
||||
self._broker = None
|
||||
|
||||
|
||||
class SimpleMQTTServer:
|
||||
"""Simplified MQTT server using raw sockets.
|
||||
|
||||
This is a fallback implementation that handles basic MQTT protocol
|
||||
without requiring the amqtt library. It's less feature-complete but
|
||||
more lightweight.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
serial: str,
|
||||
access_code: str,
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
port: int = MQTT_PORT,
|
||||
on_print_command: Callable[[str, dict], None] | None = None,
|
||||
):
|
||||
self.serial = serial
|
||||
self.access_code = access_code
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
self.port = port
|
||||
self.on_print_command = on_print_command
|
||||
self._running = False
|
||||
self._server = None
|
||||
self._clients: dict[str, asyncio.StreamWriter] = {}
|
||||
self._status_push_task: asyncio.Task | None = None
|
||||
self._sequence_id = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the MQTT server."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
logger.info(f"Starting simple MQTT server on port {self.port}")
|
||||
|
||||
# Create SSL context with Bambu-compatible settings
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
|
||||
# Match Bambu printer behavior - accept any client
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
# Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
|
||||
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
# Disable hostname checking
|
||||
ssl_context.check_hostname = False
|
||||
|
||||
# Log certificate info
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-in", str(self.cert_path), "-noout", "-subject", "-issuer"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info(f"MQTT SSL cert info: {result.stdout.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"MQTT SSL context: TLS 1.2+, cert={self.cert_path}")
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
|
||||
# Wrapper to log ALL connection attempts including SSL errors
|
||||
async def connection_handler(reader, writer):
|
||||
try:
|
||||
addr = writer.get_extra_info("peername")
|
||||
ssl_obj = writer.get_extra_info("ssl_object")
|
||||
if ssl_obj:
|
||||
logger.info(
|
||||
f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"MQTT connection from {addr} (no TLS?)")
|
||||
await self._handle_client(reader, writer)
|
||||
except ssl.SSLError as e:
|
||||
logger.error(f"MQTT SSL error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT connection handler error: {e}")
|
||||
|
||||
# Custom protocol factory to log raw connection attempts
|
||||
logger.info("Setting up MQTT server with SSL error handling...")
|
||||
|
||||
# Add SSL handshake error callback
|
||||
def handle_ssl_error(loop, context):
|
||||
exception = context.get("exception")
|
||||
message = context.get("message", "")
|
||||
if "ssl" in str(exception).lower() or "ssl" in message.lower():
|
||||
logger.error(f"SSL error: {message} - {exception}")
|
||||
else:
|
||||
logger.debug(f"Asyncio error: {message}")
|
||||
|
||||
asyncio.get_event_loop().set_exception_handler(handle_ssl_error)
|
||||
|
||||
self._server = await asyncio.start_server(
|
||||
connection_handler,
|
||||
"0.0.0.0",
|
||||
self.port,
|
||||
ssl=ssl_context,
|
||||
)
|
||||
|
||||
logger.info(f"Simple MQTT server listening on port {self.port}")
|
||||
|
||||
# Start periodic status push task
|
||||
self._status_push_task = asyncio.create_task(self._periodic_status_push())
|
||||
|
||||
async with self._server:
|
||||
await self._server.serve_forever()
|
||||
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
logger.error(f"MQTT port {self.port} is already in use")
|
||||
else:
|
||||
logger.error(f"MQTT server error: {e}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MQTT server task cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT server error: {e}")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the MQTT server."""
|
||||
logger.info("Stopping simple MQTT server")
|
||||
self._running = False
|
||||
|
||||
# Stop periodic status push
|
||||
if self._status_push_task:
|
||||
self._status_push_task.cancel()
|
||||
try:
|
||||
await self._status_push_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._status_push_task = None
|
||||
|
||||
# Close all client connections
|
||||
for _client_id, writer in self._clients.items():
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._clients.clear()
|
||||
|
||||
if self._server:
|
||||
try:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._server = None
|
||||
|
||||
async def _periodic_status_push(self) -> None:
|
||||
"""Send periodic status updates to all connected clients."""
|
||||
logger.info("Starting periodic status push task")
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(1) # Push every 1 second like real printers
|
||||
|
||||
# Send status to all connected clients
|
||||
disconnected = []
|
||||
for client_id, writer in list(self._clients.items()):
|
||||
try:
|
||||
if writer.is_closing():
|
||||
disconnected.append(client_id)
|
||||
continue
|
||||
await self._send_status_report(writer)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to push status to {client_id}: {e}")
|
||||
disconnected.append(client_id)
|
||||
|
||||
# Remove disconnected clients
|
||||
for client_id in disconnected:
|
||||
self._clients.pop(client_id, None)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Periodic status push error: {e}")
|
||||
|
||||
logger.info("Periodic status push task stopped")
|
||||
|
||||
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""Handle an MQTT client connection."""
|
||||
addr = writer.get_extra_info("peername")
|
||||
client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
|
||||
logger.info(f"MQTT client connected: {client_id}")
|
||||
|
||||
authenticated = False
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
# Read MQTT fixed header
|
||||
try:
|
||||
header = await asyncio.wait_for(reader.read(1), timeout=60)
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
if not header:
|
||||
break
|
||||
|
||||
packet_type = (header[0] & 0xF0) >> 4
|
||||
|
||||
# Read remaining length
|
||||
remaining_length = await self._read_remaining_length(reader)
|
||||
if remaining_length is None:
|
||||
break
|
||||
|
||||
# Read payload
|
||||
payload = await reader.read(remaining_length) if remaining_length > 0 else b""
|
||||
|
||||
# Handle packet types
|
||||
if packet_type == 1: # CONNECT
|
||||
authenticated = await self._handle_connect(payload, writer)
|
||||
if not authenticated:
|
||||
break
|
||||
# Register client for periodic status pushes
|
||||
self._clients[client_id] = writer
|
||||
elif packet_type == 3: # PUBLISH
|
||||
if authenticated:
|
||||
await self._handle_publish(header[0], payload, writer)
|
||||
elif packet_type == 8: # SUBSCRIBE
|
||||
if authenticated:
|
||||
await self._handle_subscribe(payload, writer)
|
||||
elif packet_type == 12: # PINGREQ
|
||||
# Send PINGRESP
|
||||
writer.write(bytes([0xD0, 0x00]))
|
||||
await writer.drain()
|
||||
elif packet_type == 14: # DISCONNECT
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"MQTT client error: {e}")
|
||||
finally:
|
||||
logger.debug(f"MQTT client disconnected: {client_id}")
|
||||
if client_id in self._clients:
|
||||
del self._clients[client_id]
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:
|
||||
"""Read MQTT remaining length (variable byte integer)."""
|
||||
multiplier = 1
|
||||
value = 0
|
||||
|
||||
for _ in range(4):
|
||||
try:
|
||||
byte = await reader.read(1)
|
||||
if not byte:
|
||||
return None
|
||||
encoded = byte[0]
|
||||
value += (encoded & 127) * multiplier
|
||||
if (encoded & 128) == 0:
|
||||
return value
|
||||
multiplier *= 128
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> bool:
|
||||
"""Handle MQTT CONNECT packet.
|
||||
|
||||
Returns True if authentication successful.
|
||||
"""
|
||||
try:
|
||||
# Parse CONNECT packet
|
||||
# Skip protocol name length and name
|
||||
idx = 0
|
||||
proto_len = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2 + proto_len
|
||||
|
||||
# Skip protocol level and connect flags
|
||||
# connect_flags = payload[idx + 1]
|
||||
idx += 2
|
||||
|
||||
# Skip keepalive
|
||||
idx += 2
|
||||
|
||||
# Read client ID
|
||||
client_id_len = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2
|
||||
# client_id = payload[idx : idx + client_id_len].decode("utf-8")
|
||||
idx += client_id_len
|
||||
|
||||
# Read username
|
||||
username_len = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2
|
||||
username = payload[idx : idx + username_len].decode("utf-8")
|
||||
idx += username_len
|
||||
|
||||
# Read password
|
||||
password_len = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2
|
||||
password = payload[idx : idx + password_len].decode("utf-8")
|
||||
|
||||
# Authenticate
|
||||
if username == "bblp" and password == self.access_code:
|
||||
# Send CONNACK with success
|
||||
writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
|
||||
await writer.drain()
|
||||
logger.info("MQTT client authenticated successfully")
|
||||
|
||||
# Send immediate status report after auth - slicer expects this
|
||||
await self._send_status_report(writer)
|
||||
return True
|
||||
else:
|
||||
# Send CONNACK with auth failure
|
||||
writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized
|
||||
await writer.drain()
|
||||
logger.warning(f"MQTT auth failed for user '{username}'")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"MQTT CONNECT parse error: {e}")
|
||||
# Send CONNACK with error
|
||||
writer.write(bytes([0x20, 0x02, 0x00, 0x02])) # Protocol error
|
||||
await writer.drain()
|
||||
return False
|
||||
|
||||
async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter) -> None:
|
||||
"""Handle MQTT SUBSCRIBE packet."""
|
||||
try:
|
||||
# Parse packet ID
|
||||
packet_id = (payload[0] << 8) | payload[1]
|
||||
|
||||
# Parse topic filters (just acknowledge them)
|
||||
idx = 2
|
||||
granted_qos = []
|
||||
while idx < len(payload):
|
||||
topic_len = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2
|
||||
topic = payload[idx : idx + topic_len].decode("utf-8")
|
||||
idx += topic_len
|
||||
requested_qos = payload[idx]
|
||||
idx += 1
|
||||
|
||||
logger.info(f"MQTT subscribe: {topic} QoS={requested_qos}")
|
||||
granted_qos.append(min(requested_qos, 1)) # Grant up to QoS 1
|
||||
|
||||
# Send SUBACK
|
||||
suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF])
|
||||
suback += bytes(granted_qos)
|
||||
writer.write(suback)
|
||||
await writer.drain()
|
||||
|
||||
# Send initial status report after subscribe
|
||||
await self._send_status_report(writer)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"MQTT SUBSCRIBE error: {e}")
|
||||
|
||||
async def _send_status_report(self, writer: asyncio.StreamWriter) -> None:
|
||||
"""Send a status report to the slicer after connection."""
|
||||
try:
|
||||
# Build status message matching Bambu printer format
|
||||
self._sequence_id += 1
|
||||
status = {
|
||||
"print": {
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"command": "push_status",
|
||||
"msg": 0,
|
||||
"gcode_state": "IDLE",
|
||||
"gcode_file": "",
|
||||
"gcode_file_prepare_percent": "0",
|
||||
"subtask_name": "",
|
||||
"mc_print_stage": "",
|
||||
"mc_percent": 0,
|
||||
"mc_remaining_time": 0,
|
||||
"wifi_signal": "-44dBm",
|
||||
"print_error": 0,
|
||||
"print_type": "",
|
||||
"bed_temper": 25.0,
|
||||
"bed_target_temper": 0.0,
|
||||
"nozzle_temper": 25.0,
|
||||
"nozzle_target_temper": 0.0,
|
||||
"chamber_temper": 25.0,
|
||||
"cooling_fan_speed": "0",
|
||||
"big_fan1_speed": "0",
|
||||
"big_fan2_speed": "0",
|
||||
"heatbreak_fan_speed": "0",
|
||||
"spd_lvl": 1,
|
||||
"spd_mag": 100,
|
||||
"stg": [],
|
||||
"stg_cur": 0,
|
||||
"layer_num": 0,
|
||||
"total_layer_num": 0,
|
||||
"home_flag": 256, # Bit 8 = SD card present (HAS_SDCARD_NORMAL)
|
||||
"hw_switch_state": 0,
|
||||
"online": {"ahb": False, "rfid": False, "version": 7},
|
||||
"ams_status": 0,
|
||||
"sdcard": True,
|
||||
"storage": {"free": 1000000000, "total": 32000000000},
|
||||
"upgrade_state": {
|
||||
"sequence_id": 0,
|
||||
"progress": "",
|
||||
"status": "",
|
||||
"consistency_request": False,
|
||||
"dis_state": 0,
|
||||
"err_code": 0,
|
||||
"force_upgrade": False,
|
||||
"message": "",
|
||||
"module": "",
|
||||
"new_version_state": 2,
|
||||
"new_ver_list": [],
|
||||
"ota_new_version_number": "",
|
||||
"ahb_new_version_number": "",
|
||||
},
|
||||
"ipcam": {
|
||||
"ipcam_dev": "1",
|
||||
"ipcam_record": "enable",
|
||||
"timelapse": "disable",
|
||||
"resolution": "1080p",
|
||||
"mode_bits": 0,
|
||||
},
|
||||
"xcam": {
|
||||
"allow_skip_parts": False,
|
||||
"buildplate_marker_detector": True,
|
||||
"first_layer_inspector": True,
|
||||
"halt_print_sensitivity": "medium",
|
||||
"print_halt": True,
|
||||
"printing_monitor": True,
|
||||
"spaghetti_detector": True,
|
||||
},
|
||||
"lights_report": [{"node": "chamber_light", "mode": "on"}],
|
||||
"nozzle_diameter": "0.4",
|
||||
"nozzle_type": "hardened_steel",
|
||||
}
|
||||
}
|
||||
|
||||
topic = f"device/{self.serial}/report"
|
||||
message = json.dumps(status)
|
||||
|
||||
# Build MQTT PUBLISH packet
|
||||
topic_bytes = topic.encode("utf-8")
|
||||
message_bytes = message.encode("utf-8")
|
||||
|
||||
# Calculate remaining length
|
||||
remaining = 2 + len(topic_bytes) + len(message_bytes)
|
||||
|
||||
# Build packet
|
||||
packet = bytes([0x30]) # PUBLISH, QoS 0
|
||||
|
||||
# Encode remaining length
|
||||
while remaining > 0:
|
||||
byte = remaining % 128
|
||||
remaining //= 128
|
||||
if remaining > 0:
|
||||
byte |= 0x80
|
||||
packet += bytes([byte])
|
||||
|
||||
# Topic length and topic
|
||||
packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
|
||||
packet += topic_bytes
|
||||
|
||||
# Message payload
|
||||
packet += message_bytes
|
||||
|
||||
writer.write(packet)
|
||||
await writer.drain()
|
||||
|
||||
logger.info(f"Sent initial status report on {topic}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send status report: {e}")
|
||||
|
||||
async def _send_version_response(self, writer: asyncio.StreamWriter, sequence_id: str) -> None:
|
||||
"""Send version info response to the slicer."""
|
||||
try:
|
||||
# Build version response matching OrcaSlicer expectations
|
||||
# Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag
|
||||
version_info = {
|
||||
"info": {
|
||||
"command": "get_version",
|
||||
"sequence_id": sequence_id,
|
||||
"module": [
|
||||
{
|
||||
"name": "ota",
|
||||
"product_name": "X1 Carbon",
|
||||
"sw_ver": "01.07.00.00",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "OTA",
|
||||
"sn": self.serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "esp32",
|
||||
"product_name": "X1 Carbon",
|
||||
"sw_ver": "01.07.22.25",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "AP05",
|
||||
"sn": self.serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "rv1126",
|
||||
"product_name": "X1 Carbon",
|
||||
"sw_ver": "00.00.27.38",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "AP05",
|
||||
"sn": self.serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "th",
|
||||
"product_name": "X1 Carbon",
|
||||
"sw_ver": "00.00.04.00",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "TH07",
|
||||
"sn": self.serial,
|
||||
"flag": 0,
|
||||
},
|
||||
{
|
||||
"name": "mc",
|
||||
"product_name": "X1 Carbon",
|
||||
"sw_ver": "00.00.10.00",
|
||||
"sw_new_ver": "",
|
||||
"hw_ver": "MC07",
|
||||
"sn": self.serial,
|
||||
"flag": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
topic = f"device/{self.serial}/report"
|
||||
message = json.dumps(version_info)
|
||||
|
||||
# Build MQTT PUBLISH packet
|
||||
topic_bytes = topic.encode("utf-8")
|
||||
message_bytes = message.encode("utf-8")
|
||||
|
||||
# Calculate remaining length
|
||||
remaining = 2 + len(topic_bytes) + len(message_bytes)
|
||||
|
||||
# Build packet
|
||||
packet = bytes([0x30]) # PUBLISH, QoS 0
|
||||
|
||||
# Encode remaining length
|
||||
while remaining > 0:
|
||||
byte = remaining % 128
|
||||
remaining //= 128
|
||||
if remaining > 0:
|
||||
byte |= 0x80
|
||||
packet += bytes([byte])
|
||||
|
||||
# Topic length and topic
|
||||
packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
|
||||
packet += topic_bytes
|
||||
|
||||
# Message payload
|
||||
packet += message_bytes
|
||||
|
||||
writer.write(packet)
|
||||
await writer.drain()
|
||||
|
||||
logger.info(f"Sent version response on {topic}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send version response: {e}")
|
||||
|
||||
async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
|
||||
"""Handle MQTT PUBLISH packet."""
|
||||
try:
|
||||
# Parse topic
|
||||
idx = 0
|
||||
topic_len = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2
|
||||
topic = payload[idx : idx + topic_len].decode("utf-8")
|
||||
idx += topic_len
|
||||
|
||||
# Check for packet ID (QoS > 0)
|
||||
qos = (header & 0x06) >> 1
|
||||
if qos > 0:
|
||||
# packet_id = (payload[idx] << 8) | payload[idx + 1]
|
||||
idx += 2
|
||||
|
||||
# Parse message
|
||||
message = payload[idx:].decode("utf-8")
|
||||
|
||||
logger.info(f"MQTT publish to {topic}: {message[:100]}...")
|
||||
|
||||
# Handle commands on device request topic
|
||||
if f"device/{self.serial}/request" in topic:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
|
||||
# Handle pushing command (status request)
|
||||
if "pushing" in data:
|
||||
pushing_data = data["pushing"]
|
||||
command = pushing_data.get("command", "")
|
||||
logger.info(f"MQTT pushing command: {command}")
|
||||
|
||||
if command == "pushall":
|
||||
# Slicer is requesting full status - send response
|
||||
logger.info("Sending status report in response to pushall")
|
||||
await self._send_status_report(writer)
|
||||
elif command == "start":
|
||||
# Slicer wants periodic status updates - send one now
|
||||
logger.info("Starting status push stream")
|
||||
await self._send_status_report(writer)
|
||||
|
||||
# Handle info commands (get_version, etc.)
|
||||
if "info" in data:
|
||||
info_data = data["info"]
|
||||
command = info_data.get("command", "")
|
||||
sequence_id = info_data.get("sequence_id", "0")
|
||||
logger.info(f"MQTT info command: {command}")
|
||||
|
||||
if command == "get_version":
|
||||
await self._send_version_response(writer, sequence_id)
|
||||
|
||||
# Handle print commands
|
||||
if "print" in data:
|
||||
print_data = data["print"]
|
||||
command = print_data.get("command", "")
|
||||
filename = print_data.get("subtask_name", "")
|
||||
|
||||
logger.info(f"MQTT print command: {command} for {filename}")
|
||||
|
||||
if self.on_print_command and command == "project_file":
|
||||
await self._notify_print_command(filename, print_data)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"MQTT PUBLISH error: {e}")
|
||||
|
||||
async def _notify_print_command(self, filename: str, data: dict) -> None:
|
||||
"""Notify callback of print command."""
|
||||
if self.on_print_command:
|
||||
try:
|
||||
result = self.on_print_command(filename, data)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"Print command callback error: {e}")
|
||||
@@ -0,0 +1,270 @@
|
||||
"""SSDP discovery responder for virtual printer.
|
||||
|
||||
Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
|
||||
announcements so the virtual printer appears as a discoverable Bambu printer.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SSDP multicast address - Bambu uses port 2021
|
||||
SSDP_ADDR = "239.255.255.250"
|
||||
SSDP_PORT = 2021
|
||||
|
||||
# Bambu service target
|
||||
BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
|
||||
|
||||
|
||||
class VirtualPrinterSSDPServer:
|
||||
"""SSDP server that responds to discovery requests as a virtual Bambu printer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "Bambuddy",
|
||||
serial: str = "00M09A391800001", # X1C serial format for compatibility
|
||||
model: str = "BL-P001", # X1C model code for best compatibility
|
||||
):
|
||||
"""Initialize the SSDP server.
|
||||
|
||||
Args:
|
||||
name: Display name shown in slicer discovery
|
||||
serial: Unique serial number for this virtual printer (must match cert CN)
|
||||
model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
|
||||
"""
|
||||
self.name = name
|
||||
self.serial = serial
|
||||
self.model = model
|
||||
self._running = False
|
||||
self._socket: socket.socket | None = None
|
||||
self._local_ip: str | None = None
|
||||
|
||||
def _get_local_ip(self) -> str:
|
||||
"""Get the local IP address to advertise."""
|
||||
if self._local_ip:
|
||||
return self._local_ip
|
||||
|
||||
# Try to determine local IP by connecting to a public address
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
self._local_ip = ip
|
||||
return ip
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
def _build_notify_message(self) -> bytes:
|
||||
"""Build SSDP NOTIFY message for periodic announcements."""
|
||||
ip = self._get_local_ip()
|
||||
# Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
|
||||
# Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
|
||||
message = (
|
||||
"NOTIFY * HTTP/1.1\r\n"
|
||||
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
||||
"Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
|
||||
"Cache-Control: max-age=1800\r\n"
|
||||
f"Location: {ip}\r\n"
|
||||
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
|
||||
"NTS: ssdp:alive\r\n"
|
||||
"EXT:\r\n"
|
||||
f"USN: {self.serial}\r\n"
|
||||
f"DevModel.bambu.com: {self.model}\r\n"
|
||||
f"DevName.bambu.com: {self.name}\r\n"
|
||||
"DevSignal.bambu.com: -44\r\n"
|
||||
"DevConnect.bambu.com: lan\r\n"
|
||||
"DevBind.bambu.com: free\r\n"
|
||||
"Devseclink.bambu.com: secure\r\n"
|
||||
"DevVersion.bambu.com: 01.07.00.00\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
return message.encode()
|
||||
|
||||
def _build_response_message(self) -> bytes:
|
||||
"""Build SSDP response message for M-SEARCH requests."""
|
||||
ip = self._get_local_ip()
|
||||
# Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
|
||||
# Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
|
||||
# Added: Devseclink, DevVersion, DevCap for better compatibility
|
||||
message = (
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
|
||||
f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
|
||||
f"Location: {ip}\r\n"
|
||||
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
|
||||
"EXT:\r\n"
|
||||
f"USN: {self.serial}\r\n"
|
||||
"Cache-Control: max-age=1800\r\n"
|
||||
f"DevModel.bambu.com: {self.model}\r\n"
|
||||
f"DevName.bambu.com: {self.name}\r\n"
|
||||
"DevSignal.bambu.com: -44\r\n"
|
||||
"DevConnect.bambu.com: lan\r\n"
|
||||
"DevBind.bambu.com: free\r\n"
|
||||
"Devseclink.bambu.com: secure\r\n"
|
||||
"DevVersion.bambu.com: 01.07.00.00\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
return message.encode()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the SSDP server."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
logger.info(f"Starting virtual printer SSDP server: {self.name} ({self.serial})")
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
# Create UDP socket
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
# Try to set SO_REUSEPORT if available
|
||||
try:
|
||||
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
# Set non-blocking mode
|
||||
self._socket.setblocking(False)
|
||||
|
||||
# Bind to SSDP port
|
||||
self._socket.bind(("", SSDP_PORT))
|
||||
|
||||
# Join multicast group
|
||||
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
|
||||
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
|
||||
# Enable broadcast
|
||||
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
# Set multicast TTL
|
||||
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
|
||||
local_ip = self._get_local_ip()
|
||||
logger.info(f"SSDP server listening on port {SSDP_PORT}, advertising IP: {local_ip}")
|
||||
logger.info(f"Virtual printer: {self.name} ({self.serial}) model={self.model}")
|
||||
|
||||
# Send initial NOTIFY
|
||||
await self._send_notify()
|
||||
logger.info("Sent initial SSDP NOTIFY announcement")
|
||||
|
||||
# Run receive and announce loops
|
||||
last_notify = asyncio.get_event_loop().time()
|
||||
notify_interval = 30.0 # Send NOTIFY every 30 seconds
|
||||
|
||||
while self._running:
|
||||
# Try to receive M-SEARCH requests
|
||||
try:
|
||||
data, addr = self._socket.recvfrom(4096)
|
||||
message = data.decode("utf-8", errors="ignore")
|
||||
await self._handle_message(message, addr)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
logger.debug(f"SSDP receive error: {e}")
|
||||
|
||||
# Send periodic NOTIFY
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - last_notify >= notify_interval:
|
||||
await self._send_notify()
|
||||
last_notify = now
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
logger.warning(f"SSDP port {SSDP_PORT} in use - real printers may be running")
|
||||
else:
|
||||
logger.error(f"SSDP server error: {e}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("SSDP server cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"SSDP server error: {e}")
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the SSDP server."""
|
||||
logger.info("Stopping SSDP server")
|
||||
self._running = False
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self._socket:
|
||||
try:
|
||||
# Send byebye message
|
||||
await self._send_byebye()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
|
||||
async def _send_notify(self) -> None:
|
||||
"""Send SSDP NOTIFY message."""
|
||||
if not self._socket:
|
||||
return
|
||||
|
||||
try:
|
||||
msg = self._build_notify_message()
|
||||
self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
|
||||
logger.debug(f"Sent SSDP NOTIFY for {self.name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to send NOTIFY: {e}")
|
||||
|
||||
async def _send_byebye(self) -> None:
|
||||
"""Send SSDP byebye message when shutting down."""
|
||||
if not self._socket:
|
||||
return
|
||||
|
||||
message = (
|
||||
"NOTIFY * HTTP/1.1\r\n"
|
||||
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
||||
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
|
||||
"NTS: ssdp:byebye\r\n"
|
||||
f"USN: {self.serial}\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
|
||||
try:
|
||||
self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
|
||||
logger.debug("Sent SSDP byebye")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
|
||||
"""Handle incoming SSDP message.
|
||||
|
||||
Args:
|
||||
message: The SSDP message content
|
||||
addr: Tuple of (ip_address, port) of sender
|
||||
"""
|
||||
# Check if this is an M-SEARCH request for Bambu printers
|
||||
if "M-SEARCH" not in message:
|
||||
return
|
||||
|
||||
# Check search target
|
||||
if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
|
||||
return
|
||||
|
||||
logger.debug(f"Received M-SEARCH from {addr[0]}")
|
||||
|
||||
# Send response
|
||||
if self._socket:
|
||||
try:
|
||||
response = self._build_response_message()
|
||||
self._socket.sendto(response, addr)
|
||||
logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to send SSDP response: {e}")
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Integration tests for Virtual Printer API endpoints.
|
||||
|
||||
Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestVirtualPrinterSettingsAPI:
|
||||
"""Integration tests for /api/v1/settings/virtual-printer endpoints."""
|
||||
|
||||
# ========================================================================
|
||||
# Get settings
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_virtual_printer_settings(self, async_client: AsyncClient):
|
||||
"""Verify virtual printer settings can be retrieved."""
|
||||
response = await async_client.get("/api/v1/settings/virtual-printer")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "enabled" in result
|
||||
assert "access_code_set" in result
|
||||
assert "mode" in result
|
||||
assert "status" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_settings_has_status(self, async_client: AsyncClient):
|
||||
"""Verify settings include status details."""
|
||||
response = await async_client.get("/api/v1/settings/virtual-printer")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
status = result["status"]
|
||||
assert "enabled" in status
|
||||
assert "running" in status
|
||||
assert "mode" in status
|
||||
assert "name" in status
|
||||
assert "serial" in status
|
||||
assert "pending_files" in status
|
||||
|
||||
# ========================================================================
|
||||
# Update settings
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_mode(self, async_client: AsyncClient):
|
||||
"""Verify mode can be updated."""
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["mode"] == "queue"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_mode_to_immediate(self, async_client: AsyncClient):
|
||||
"""Verify mode can be set to immediate."""
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["mode"] == "immediate"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_access_code(self, async_client: AsyncClient):
|
||||
"""Verify access code can be set."""
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["access_code_set"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_access_code_wrong_length(self, async_client: AsyncClient):
|
||||
"""Verify access code validation for length."""
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123")
|
||||
|
||||
# Should fail validation
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_enable_without_access_code(self, async_client: AsyncClient):
|
||||
"""Verify enabling fails without access code set."""
|
||||
# First ensure no access code is set by checking current state
|
||||
# Then try to enable
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
|
||||
|
||||
# If access code wasn't set, this should fail
|
||||
# If it was already set, it will succeed
|
||||
# Both are valid test outcomes
|
||||
assert response.status_code in [200, 400]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_enable_with_access_code(self, async_client: AsyncClient):
|
||||
"""Verify enabling succeeds when access code is set."""
|
||||
# First set access code
|
||||
await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
|
||||
|
||||
# Then enable (this will start the servers which may fail in test env)
|
||||
# We mock the manager to avoid actually starting servers
|
||||
with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
|
||||
mock_manager.configure = AsyncMock()
|
||||
mock_manager.get_status = MagicMock(
|
||||
return_value={
|
||||
"enabled": True,
|
||||
"running": True,
|
||||
"mode": "immediate",
|
||||
"name": "Bambuddy",
|
||||
"serial": "00M09A391800001",
|
||||
"pending_files": 0,
|
||||
}
|
||||
)
|
||||
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_disable_virtual_printer(self, async_client: AsyncClient):
|
||||
"""Verify virtual printer can be disabled."""
|
||||
with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
|
||||
mock_manager.configure = AsyncMock()
|
||||
mock_manager.get_status = MagicMock(
|
||||
return_value={
|
||||
"enabled": False,
|
||||
"running": False,
|
||||
"mode": "immediate",
|
||||
"name": "Bambuddy",
|
||||
"serial": "00M09A391800001",
|
||||
"pending_files": 0,
|
||||
}
|
||||
)
|
||||
|
||||
response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["enabled"] is False
|
||||
|
||||
|
||||
class TestPendingUploadsAPI:
|
||||
"""Integration tests for /api/v1/pending-uploads/ endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pending_uploads(self, db_session):
|
||||
"""Create mock pending uploads in database."""
|
||||
|
||||
async def _create_pending(filename: str = "test.3mf"):
|
||||
from datetime import datetime
|
||||
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
|
||||
upload = PendingUpload(
|
||||
filename=filename,
|
||||
file_path=f"/tmp/{filename}",
|
||||
file_size=1024,
|
||||
source_ip="192.168.1.100",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(upload)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(upload)
|
||||
return upload
|
||||
|
||||
return _create_pending
|
||||
|
||||
# ========================================================================
|
||||
# List pending uploads
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
|
||||
"""Verify empty list is returned when no pending uploads."""
|
||||
response = await async_client.get("/api/v1/pending-uploads/")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert isinstance(result, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_pending_uploads_count(self, async_client: AsyncClient):
|
||||
"""Verify count endpoint returns correct count."""
|
||||
response = await async_client.get("/api/v1/pending-uploads/count")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "count" in result
|
||||
assert isinstance(result["count"], int)
|
||||
|
||||
# ========================================================================
|
||||
# Archive pending upload
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
|
||||
"""Verify archiving non-existent upload returns 404."""
|
||||
response = await async_client.post("/api/v1/pending-uploads/99999/archive")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# ========================================================================
|
||||
# Discard pending upload
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
|
||||
"""Verify discarding non-existent upload returns 404."""
|
||||
response = await async_client.delete("/api/v1/pending-uploads/99999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# ========================================================================
|
||||
# Bulk operations
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_archive_all_empty(self, async_client: AsyncClient):
|
||||
"""Verify archive all with no pending uploads."""
|
||||
response = await async_client.post("/api/v1/pending-uploads/archive-all")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "archived" in result
|
||||
assert "failed" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_discard_all_empty(self, async_client: AsyncClient):
|
||||
"""Verify discard all with no pending uploads."""
|
||||
response = await async_client.delete("/api/v1/pending-uploads/discard-all")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "discarded" in result
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Unit tests for Virtual Printer services.
|
||||
|
||||
Tests the virtual printer manager, FTP server, and SSDP server components.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestVirtualPrinterManager:
|
||||
"""Tests for VirtualPrinterManager class."""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
"""Create a VirtualPrinterManager instance."""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterManager
|
||||
|
||||
return VirtualPrinterManager()
|
||||
|
||||
# ========================================================================
|
||||
# Tests for configuration
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_sets_parameters(self, manager):
|
||||
"""Verify configure stores parameters correctly."""
|
||||
# Mock the start/stop methods to avoid actually starting services
|
||||
manager._start = AsyncMock()
|
||||
|
||||
await manager.configure(
|
||||
enabled=True,
|
||||
access_code="12345678",
|
||||
mode="immediate",
|
||||
)
|
||||
|
||||
assert manager._enabled is True
|
||||
assert manager._access_code == "12345678"
|
||||
assert manager._mode == "immediate"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_disabled_stops_services(self, manager):
|
||||
"""Verify disabling stops all services."""
|
||||
# First simulate enabled state
|
||||
manager._enabled = True
|
||||
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
|
||||
manager._stop = AsyncMock()
|
||||
|
||||
await manager.configure(enabled=False, access_code="12345678")
|
||||
|
||||
assert manager._enabled is False
|
||||
manager._stop.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_requires_access_code_when_enabling(self, manager):
|
||||
"""Verify access code is required when enabling."""
|
||||
with pytest.raises(ValueError, match="Access code is required"):
|
||||
await manager.configure(enabled=True)
|
||||
|
||||
# ========================================================================
|
||||
# Tests for status
|
||||
# ========================================================================
|
||||
|
||||
def test_get_status_returns_correct_format(self, manager):
|
||||
"""Verify get_status returns expected fields."""
|
||||
manager._enabled = True
|
||||
manager._mode = "immediate"
|
||||
manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
|
||||
# Simulate running tasks
|
||||
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
|
||||
|
||||
status = manager.get_status()
|
||||
|
||||
assert status["enabled"] is True
|
||||
assert status["running"] is True
|
||||
assert status["mode"] == "immediate"
|
||||
assert status["name"] == "Bambuddy"
|
||||
assert status["serial"] == "00M09A391800001"
|
||||
assert status["pending_files"] == 1
|
||||
|
||||
def test_get_status_when_stopped(self, manager):
|
||||
"""Verify get_status when not running."""
|
||||
manager._enabled = False
|
||||
manager._tasks = []
|
||||
|
||||
status = manager.get_status()
|
||||
|
||||
assert status["enabled"] is False
|
||||
assert status["running"] is False
|
||||
|
||||
def test_is_running_with_active_tasks(self, manager):
|
||||
"""Verify is_running is True when tasks are active."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.done.return_value = False
|
||||
manager._tasks = [mock_task]
|
||||
|
||||
assert manager.is_running is True
|
||||
|
||||
def test_is_running_with_no_tasks(self, manager):
|
||||
"""Verify is_running is False when no tasks."""
|
||||
manager._tasks = []
|
||||
|
||||
assert manager.is_running is False
|
||||
|
||||
# ========================================================================
|
||||
# Tests for file handling
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_file_received_adds_to_pending(self, manager):
|
||||
"""Verify received file is added to pending list."""
|
||||
manager._mode = "queue"
|
||||
manager._session_factory = None # Disable actual archiving
|
||||
|
||||
file_path = Path("/tmp/test.3mf")
|
||||
|
||||
with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue:
|
||||
await manager._on_file_received(file_path, "192.168.1.100")
|
||||
|
||||
assert "test.3mf" in manager._pending_files
|
||||
mock_queue.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_file_received_archives_immediately(self, manager):
|
||||
"""Verify file is archived in immediate mode."""
|
||||
manager._mode = "immediate"
|
||||
manager._session_factory = None # Will prevent actual archiving
|
||||
|
||||
file_path = Path("/tmp/test.3mf")
|
||||
|
||||
with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive:
|
||||
await manager._on_file_received(file_path, "192.168.1.100")
|
||||
|
||||
mock_archive.assert_called_once_with(file_path, "192.168.1.100")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_file_skips_non_3mf(self, manager):
|
||||
"""Verify non-3MF files are skipped and cleaned up."""
|
||||
manager._session_factory = MagicMock()
|
||||
manager._pending_files["verify_job"] = Path("/tmp/verify_job")
|
||||
|
||||
with patch("pathlib.Path.unlink"):
|
||||
await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
|
||||
|
||||
# Should be removed from pending
|
||||
assert "verify_job" not in manager._pending_files
|
||||
|
||||
|
||||
class TestFTPSession:
|
||||
"""Tests for FTP session handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader(self):
|
||||
"""Create a mock StreamReader."""
|
||||
reader = AsyncMock()
|
||||
return reader
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer(self):
|
||||
"""Create a mock StreamWriter."""
|
||||
writer = MagicMock()
|
||||
writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
writer.is_closing = MagicMock(return_value=False)
|
||||
return writer
|
||||
|
||||
@pytest.fixture
|
||||
def ssl_context(self):
|
||||
"""Create a mock SSL context."""
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
|
||||
"""Create an FTPSession instance."""
|
||||
from backend.app.services.virtual_printer.ftp_server import FTPSession
|
||||
|
||||
return FTPSession(
|
||||
reader=mock_reader,
|
||||
writer=mock_writer,
|
||||
upload_dir=tmp_path,
|
||||
access_code="12345678",
|
||||
ssl_context=ssl_context,
|
||||
on_file_received=None,
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tests for authentication
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_command_accepts_bblp(self, session):
|
||||
"""Verify USER command accepts bblp user."""
|
||||
await session.cmd_USER("bblp")
|
||||
|
||||
assert session.username == "bblp"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_command_authenticates(self, session):
|
||||
"""Verify PASS command authenticates with correct code."""
|
||||
session.username = "bblp"
|
||||
|
||||
await session.cmd_PASS("12345678")
|
||||
|
||||
assert session.authenticated is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_command_rejects_wrong_code(self, session):
|
||||
"""Verify PASS command rejects wrong access code."""
|
||||
session.username = "bblp"
|
||||
|
||||
await session.cmd_PASS("wrongcode")
|
||||
|
||||
assert session.authenticated is False
|
||||
|
||||
# ========================================================================
|
||||
# Tests for FTP commands
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_syst_command(self, session):
|
||||
"""Verify SYST returns UNIX type."""
|
||||
await session.cmd_SYST("")
|
||||
|
||||
session.writer.write.assert_called()
|
||||
call_args = session.writer.write.call_args[0][0].decode()
|
||||
assert "215" in call_args
|
||||
assert "UNIX" in call_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pwd_command_requires_auth(self, session):
|
||||
"""Verify PWD requires authentication."""
|
||||
session.authenticated = False
|
||||
|
||||
await session.cmd_PWD("")
|
||||
|
||||
call_args = session.writer.write.call_args[0][0].decode()
|
||||
assert "530" in call_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pwd_command_when_authenticated(self, session):
|
||||
"""Verify PWD returns root directory when authenticated."""
|
||||
session.authenticated = True
|
||||
|
||||
await session.cmd_PWD("")
|
||||
|
||||
call_args = session.writer.write.call_args[0][0].decode()
|
||||
assert "257" in call_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_type_command_sets_binary(self, session):
|
||||
"""Verify TYPE I sets binary mode."""
|
||||
session.authenticated = True
|
||||
|
||||
await session.cmd_TYPE("I")
|
||||
|
||||
assert session.transfer_type == "I"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pbsz_command(self, session):
|
||||
"""Verify PBSZ returns success."""
|
||||
await session.cmd_PBSZ("0")
|
||||
|
||||
call_args = session.writer.write.call_args[0][0].decode()
|
||||
assert "200" in call_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prot_command_accepts_p(self, session):
|
||||
"""Verify PROT P is accepted."""
|
||||
await session.cmd_PROT("P")
|
||||
|
||||
call_args = session.writer.write.call_args[0][0].decode()
|
||||
assert "200" in call_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_command(self, session):
|
||||
"""Verify QUIT sends goodbye and raises CancelledError."""
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await session.cmd_QUIT("")
|
||||
|
||||
|
||||
class TestSSDPServer:
|
||||
"""Tests for Virtual Printer SSDP server."""
|
||||
|
||||
@pytest.fixture
|
||||
def ssdp_server(self):
|
||||
"""Create a VirtualPrinterSSDPServer instance."""
|
||||
from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
|
||||
|
||||
return VirtualPrinterSSDPServer(
|
||||
serial="TEST123",
|
||||
name="TestPrinter",
|
||||
model="BL-P001",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tests for SSDP response
|
||||
# ========================================================================
|
||||
|
||||
def test_build_notify_message(self, ssdp_server):
|
||||
"""Verify NOTIFY packet contains required headers."""
|
||||
# Set a known IP for testing
|
||||
ssdp_server._local_ip = "192.168.1.100"
|
||||
|
||||
message = ssdp_server._build_notify_message()
|
||||
|
||||
assert b"NOTIFY" in message
|
||||
assert b"DevName.bambu.com: TestPrinter" in message
|
||||
assert b"USN: TEST123" in message
|
||||
|
||||
def test_build_response_message(self, ssdp_server):
|
||||
"""Verify response packet contains required headers."""
|
||||
# Set a known IP for testing
|
||||
ssdp_server._local_ip = "192.168.1.100"
|
||||
|
||||
message = ssdp_server._build_response_message()
|
||||
|
||||
assert b"HTTP/1.1 200 OK" in message
|
||||
assert b"DevName.bambu.com: TestPrinter" in message
|
||||
assert b"USN: TEST123" in message
|
||||
|
||||
def test_ssdp_server_uses_correct_model(self, ssdp_server):
|
||||
"""Verify SSDP server uses the provided model."""
|
||||
ssdp_server._local_ip = "192.168.1.100"
|
||||
|
||||
message = ssdp_server._build_notify_message()
|
||||
|
||||
assert b"DevModel.bambu.com: BL-P001" in message
|
||||
|
||||
|
||||
class TestCertificateService:
|
||||
"""Tests for TLS certificate generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def cert_service(self, tmp_path):
|
||||
"""Create a CertificateService instance."""
|
||||
from backend.app.services.virtual_printer.certificate import CertificateService
|
||||
|
||||
return CertificateService(cert_dir=tmp_path, serial="TEST123")
|
||||
|
||||
def test_generate_certificates(self, cert_service, tmp_path):
|
||||
"""Verify certificates are generated correctly."""
|
||||
cert_path, key_path = cert_service.generate_certificates()
|
||||
|
||||
assert cert_path.exists()
|
||||
assert key_path.exists()
|
||||
|
||||
# Verify certificate content
|
||||
cert_content = cert_path.read_text()
|
||||
assert "BEGIN CERTIFICATE" in cert_content
|
||||
|
||||
key_content = key_path.read_text()
|
||||
assert "BEGIN" in key_content and "KEY" in key_content
|
||||
|
||||
def test_certificates_reused_if_exist(self, cert_service):
|
||||
"""Verify existing certificates are reused."""
|
||||
# First generation
|
||||
cert_path1, key_path1 = cert_service.generate_certificates()
|
||||
mtime1 = cert_path1.stat().st_mtime
|
||||
|
||||
# Second call should reuse (via ensure_certificates)
|
||||
cert_path2, key_path2 = cert_service.ensure_certificates()
|
||||
mtime2 = cert_path2.stat().st_mtime
|
||||
|
||||
assert mtime1 == mtime2 # File wasn't regenerated
|
||||
|
||||
def test_delete_certificates(self, cert_service):
|
||||
"""Verify certificates can be deleted."""
|
||||
cert_service.generate_certificates()
|
||||
|
||||
assert cert_service.cert_path.exists()
|
||||
assert cert_service.key_path.exists()
|
||||
|
||||
cert_service.delete_certificates()
|
||||
|
||||
assert not cert_service.cert_path.exists()
|
||||
assert not cert_service.key_path.exists()
|
||||
|
||||
def test_ensure_creates_if_not_exist(self, cert_service):
|
||||
"""Verify ensure_certificates generates if not existing."""
|
||||
assert not cert_service.cert_path.exists()
|
||||
|
||||
cert_path, key_path = cert_service.ensure_certificates()
|
||||
|
||||
assert cert_path.exists()
|
||||
assert key_path.exists()
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
volumes:
|
||||
- bambuddy_data:/app/data
|
||||
- bambuddy_logs:/app/logs
|
||||
- bambuddy_vprinter:/app/virtual_printer
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
restart: unless-stopped
|
||||
@@ -21,3 +22,4 @@ services:
|
||||
volumes:
|
||||
bambuddy_data:
|
||||
bambuddy_logs:
|
||||
bambuddy_vprinter:
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Tests for the VirtualPrinterSettings component.
|
||||
*
|
||||
* Tests the virtual printer configuration UI including:
|
||||
* - Enable/disable toggle
|
||||
* - Access code management
|
||||
* - Archive mode selection
|
||||
* - Status display
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
virtualPrinterApi: {
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked module
|
||||
import { virtualPrinterApi } from '../../api/client';
|
||||
|
||||
// Mock data factory
|
||||
const createMockSettings = (overrides = {}) => ({
|
||||
enabled: false,
|
||||
access_code_set: false,
|
||||
mode: 'immediate' as const,
|
||||
status: {
|
||||
enabled: false,
|
||||
running: false,
|
||||
mode: 'immediate',
|
||||
name: 'Bambuddy',
|
||||
serial: '00M09A391800001',
|
||||
pending_files: 0,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('VirtualPrinterSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementation
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings());
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings());
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders loading state initially', () => {
|
||||
// Delay the API response to catch loading state
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
// Should show loading spinner
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders component title', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Virtual Printer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders enable toggle', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders access code section', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access Code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders archive mode section', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Archive Mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders how it works info', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How it works:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('status indicator', () => {
|
||||
it('shows Stopped when not running', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ status: { ...createMockSettings().status, running: false } })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Stopped')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Running when active', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({
|
||||
enabled: true,
|
||||
status: { ...createMockSettings().status, enabled: true, running: true },
|
||||
})
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows status details when running', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({
|
||||
enabled: true,
|
||||
status: {
|
||||
enabled: true,
|
||||
running: true,
|
||||
mode: 'immediate',
|
||||
name: 'Bambuddy',
|
||||
serial: '00M09A391800001',
|
||||
pending_files: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Status Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bambuddy')).toBeInTheDocument();
|
||||
expect(screen.getByText('00M09A391800001')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('access code', () => {
|
||||
it('shows warning when access code not set', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ access_code_set: false })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access code set - required to enable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success when access code is set', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ access_code_set: true })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access code is set')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows character count while typing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access Code')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter 8-char code');
|
||||
await user.type(input, '1234');
|
||||
|
||||
expect(screen.getByText('(4/8)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves access code on button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
|
||||
createMockSettings({ access_code_set: true })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access Code')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter 8-char code');
|
||||
await user.type(input, '12345678');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({
|
||||
access_code: '12345678',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access Code')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter 8-char code');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
// Find and click the visibility toggle (eye icon button)
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const visibilityToggle = toggleButtons.find(
|
||||
(btn) => btn.querySelector('svg') && btn.className.includes('absolute')
|
||||
);
|
||||
|
||||
if (visibilityToggle) {
|
||||
await user.click(visibilityToggle);
|
||||
expect(input).toHaveAttribute('type', 'text');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive mode', () => {
|
||||
it('renders immediate mode option', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Immediate')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Archive files as soon as they are uploaded')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders queue mode option', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue for Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review and tag files before archiving')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights current mode', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ mode: 'queue' })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const queueButton = screen.getByText('Queue for Review').closest('button');
|
||||
expect(queueButton?.className).toContain('border-bambu-green');
|
||||
});
|
||||
});
|
||||
|
||||
it('changes mode on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
|
||||
createMockSettings({ mode: 'queue' })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue for Review')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const queueButton = screen.getByText('Queue for Review').closest('button');
|
||||
if (queueButton) {
|
||||
await user.click(queueButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable/disable toggle', () => {
|
||||
it('cannot enable without access code', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ enabled: false, access_code_set: false })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the toggle switch (it's a button with relative class containing the slider)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
|
||||
|
||||
if (toggle) {
|
||||
await user.click(toggle);
|
||||
}
|
||||
|
||||
// Should not call update API (no access code set)
|
||||
expect(virtualPrinterApi.updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can enable when access code is set', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ enabled: false, access_code_set: true })
|
||||
);
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
|
||||
createMockSettings({ enabled: true, access_code_set: true })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the toggle switch (it's a button with rounded-full and w-12 classes)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
|
||||
|
||||
expect(toggle).toBeDefined();
|
||||
if (toggle) {
|
||||
await user.click(toggle);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('can disable when enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ enabled: true, access_code_set: true })
|
||||
);
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
|
||||
createMockSettings({ enabled: false, access_code_set: true })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the toggle switch
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
|
||||
|
||||
expect(toggle).toBeDefined();
|
||||
if (toggle) {
|
||||
await user.click(toggle);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('info section', () => {
|
||||
it('shows required ports warning', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Required ports: 2021.*8883.*990/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows iptables instructions', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/iptables -t nat -A PREROUTING/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2342,3 +2342,77 @@ export const discoveryApi = {
|
||||
stopSubnetScan: () =>
|
||||
request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Virtual Printer types
|
||||
export interface VirtualPrinterStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
mode: 'immediate' | 'queue';
|
||||
name: string;
|
||||
serial: string;
|
||||
model: string;
|
||||
pending_files: number;
|
||||
}
|
||||
|
||||
export interface VirtualPrinterSettings {
|
||||
enabled: boolean;
|
||||
access_code_set: boolean;
|
||||
mode: 'immediate' | 'queue';
|
||||
status: VirtualPrinterStatus;
|
||||
}
|
||||
|
||||
export interface PendingUpload {
|
||||
id: number;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
source_ip: string | null;
|
||||
status: string;
|
||||
tags: string | null;
|
||||
notes: string | null;
|
||||
project_id: number | null;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
// Virtual Printer API
|
||||
export const virtualPrinterApi = {
|
||||
getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
|
||||
|
||||
updateSettings: (data: {
|
||||
enabled?: boolean;
|
||||
access_code?: string;
|
||||
mode?: 'immediate' | 'queue';
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
|
||||
if (data.access_code !== undefined) params.set('access_code', data.access_code);
|
||||
if (data.mode !== undefined) params.set('mode', data.mode);
|
||||
|
||||
return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Pending Uploads API
|
||||
export const pendingUploadsApi = {
|
||||
list: () => request<PendingUpload[]>('/pending-uploads/'),
|
||||
|
||||
getCount: () => request<{ count: number }>('/pending-uploads/count'),
|
||||
|
||||
get: (id: number) => request<PendingUpload>(`/pending-uploads/${id}`),
|
||||
|
||||
archive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) =>
|
||||
request<{ id: number; print_name: string; filename: string }>(`/pending-uploads/${id}/archive`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
}),
|
||||
|
||||
discard: (id: number) =>
|
||||
request<{ success: boolean }>(`/pending-uploads/${id}`, { method: 'DELETE' }),
|
||||
|
||||
archiveAll: () =>
|
||||
request<{ archived: number; failed: number }>('/pending-uploads/archive-all', { method: 'POST' }),
|
||||
|
||||
discardAll: () =>
|
||||
request<{ discarded: number }>('/pending-uploads/discard-all', { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban } from 'lucide-react';
|
||||
import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban, Upload } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
@@ -95,6 +95,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
|
||||
default: false,
|
||||
description: 'Projects, BOM items, and attachments',
|
||||
},
|
||||
{
|
||||
id: 'pending_uploads',
|
||||
labelKey: 'backup.categories.pendingUploads',
|
||||
defaultLabel: 'Pending Uploads',
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
default: false,
|
||||
description: 'Virtual printer uploads awaiting review',
|
||||
},
|
||||
];
|
||||
|
||||
interface BackupModalProps {
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { pendingUploadsApi } from '../api/client';
|
||||
import type { PendingUpload, ProjectListItem } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
interface PendingUploadItemProps {
|
||||
upload: PendingUpload;
|
||||
projects: ProjectListItem[];
|
||||
onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void;
|
||||
onDiscard: (id: number) => void;
|
||||
isArchiving: boolean;
|
||||
isDiscarding: boolean;
|
||||
}
|
||||
|
||||
function PendingUploadItem({
|
||||
upload,
|
||||
projects,
|
||||
onArchive,
|
||||
onDiscard,
|
||||
isArchiving,
|
||||
isDiscarding,
|
||||
}: PendingUploadItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tags, setTags] = useState(upload.tags || '');
|
||||
const [notes, setNotes] = useState(upload.notes || '');
|
||||
const [projectId, setProjectId] = useState<number | null>(upload.project_id);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-white font-medium">{upload.filename}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-bambu-gray">
|
||||
<span>{formatFileSize(upload.file_size)}</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTimeAgo(upload.uploaded_at)}
|
||||
</span>
|
||||
{upload.source_ip && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>from {upload.source_ip}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="p-1 text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onArchive(upload.id, { tags, notes, project_id: projectId || undefined })}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{isArchiving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Archive className="w-4 h-4" />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDiscard(upload.id)}
|
||||
disabled={isDiscarding}
|
||||
>
|
||||
{isDiscarding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details for adding tags/notes/project */}
|
||||
{expanded && (
|
||||
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="e.g., functional, prototype, gift"
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes about this print..."
|
||||
rows={2}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Project</label>
|
||||
<select
|
||||
value={projectId || ''}
|
||||
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">No project</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingUploadsPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);
|
||||
const [showDiscardAllConfirm, setShowDiscardAllConfirm] = useState(false);
|
||||
const [archivingIds, setArchivingIds] = useState<Set<number>>(new Set());
|
||||
const [discardingIds, setDiscardingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Fetch pending uploads
|
||||
const { data: uploads, isLoading: uploadsLoading } = useQuery({
|
||||
queryKey: ['pending-uploads'],
|
||||
queryFn: pendingUploadsApi.list,
|
||||
refetchInterval: 10000, // Refresh every 10 seconds
|
||||
});
|
||||
|
||||
// Fetch projects for dropdown
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => api.getProjects(),
|
||||
});
|
||||
|
||||
// Archive mutation
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>
|
||||
pendingUploadsApi.archive(id, data),
|
||||
onMutate: ({ id }) => {
|
||||
setArchivingIds((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onSettled: (_, __, { id }) => {
|
||||
setArchivingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Archived: ${data.print_name}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to archive', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Discard mutation
|
||||
const discardMutation = useMutation({
|
||||
mutationFn: (id: number) => pendingUploadsApi.discard(id),
|
||||
onMutate: (id) => {
|
||||
setDiscardingIds((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onSettled: (_, __, id) => {
|
||||
setDiscardingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
||||
showToast('Upload discarded');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to discard', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Archive all mutation
|
||||
const archiveAllMutation = useMutation({
|
||||
mutationFn: pendingUploadsApi.archiveAll,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to archive all', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Discard all mutation
|
||||
const discardAllMutation = useMutation({
|
||||
mutationFn: pendingUploadsApi.discardAll,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
||||
showToast(`Discarded ${data.discarded} files`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to discard all', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
if (uploadsLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 flex justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!uploads || uploads.length === 0) {
|
||||
return null; // Don't render if no pending uploads
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Card className="border-l-4 border-l-yellow-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-yellow-500" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Pending Uploads ({uploads.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowArchiveAllConfirm(true)}
|
||||
disabled={archiveAllMutation.isPending}
|
||||
>
|
||||
{archiveAllMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Archive className="w-4 h-4" />
|
||||
Archive All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDiscardAllConfirm(true)}
|
||||
disabled={discardAllMutation.isPending}
|
||||
>
|
||||
{discardAllMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Discard All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
These files were uploaded via the virtual printer. Review and archive them to add to your collection.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{uploads.map((upload) => (
|
||||
<PendingUploadItem
|
||||
key={upload.id}
|
||||
upload={upload}
|
||||
projects={projects || []}
|
||||
onArchive={(id, data) => archiveMutation.mutate({ id, data })}
|
||||
onDiscard={(id) => discardMutation.mutate(id)}
|
||||
isArchiving={archivingIds.has(upload.id)}
|
||||
isDiscarding={discardingIds.has(upload.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Archive All Confirmation */}
|
||||
{showArchiveAllConfirm && (
|
||||
<ConfirmModal
|
||||
title="Archive All Uploads"
|
||||
message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}
|
||||
confirmText="Archive All"
|
||||
onConfirm={() => {
|
||||
archiveAllMutation.mutate();
|
||||
setShowArchiveAllConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowArchiveAllConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discard All Confirmation */}
|
||||
{showDiscardAllConfirm && (
|
||||
<ConfirmModal
|
||||
title="Discard All Uploads"
|
||||
message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}
|
||||
confirmText="Discard All"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
discardAllMutation.mutate();
|
||||
setShowDiscardAllConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowDiscardAllConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
filaments: 'Filaments',
|
||||
maintenance_types: 'Maintenance Types',
|
||||
archives: 'Archives',
|
||||
projects: 'Projects',
|
||||
pending_uploads: 'Pending Uploads',
|
||||
external_links: 'External Links',
|
||||
};
|
||||
|
||||
export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
|
||||
@@ -194,6 +197,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
|
||||
<li><strong>Notification Providers</strong> - matched by name</li>
|
||||
<li><strong>Filaments</strong> - matched by name + type + brand</li>
|
||||
<li><strong>Archives</strong> - matched by content hash (always skipped)</li>
|
||||
<li><strong>Pending Uploads</strong> - matched by filename</li>
|
||||
<li><strong>Settings & Templates</strong> - always overwritten</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info } from 'lucide-react';
|
||||
import { virtualPrinterApi } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
export function VirtualPrinterSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
const [localAccessCode, setLocalAccessCode] = useState('');
|
||||
const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
|
||||
// Fetch current settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['virtual-printer-settings'],
|
||||
queryFn: virtualPrinterApi.getSettings,
|
||||
refetchInterval: 10000, // Refresh every 10 seconds for status updates
|
||||
});
|
||||
|
||||
// Initialize local state from settings
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setLocalEnabled(settings.enabled);
|
||||
setLocalMode(settings.mode);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue' }) =>
|
||||
virtualPrinterApi.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
|
||||
showToast('Virtual printer settings updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to update settings', 'error');
|
||||
// Revert local state on error
|
||||
if (settings) {
|
||||
setLocalEnabled(settings.enabled);
|
||||
setLocalMode(settings.mode);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
const newEnabled = !localEnabled;
|
||||
|
||||
// If enabling, must have access code
|
||||
if (newEnabled && !localAccessCode && !settings?.access_code_set) {
|
||||
showToast('Please set an access code first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalEnabled(newEnabled);
|
||||
updateMutation.mutate({
|
||||
enabled: newEnabled,
|
||||
access_code: localAccessCode || undefined,
|
||||
mode: localMode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccessCodeChange = () => {
|
||||
if (!localAccessCode) {
|
||||
showToast('Access code cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (localAccessCode.length !== 8) {
|
||||
showToast('Access code must be exactly 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
access_code: localAccessCode,
|
||||
});
|
||||
setLocalAccessCode(''); // Clear after saving
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: 'immediate' | 'queue') => {
|
||||
setLocalMode(mode);
|
||||
updateMutation.mutate({ mode });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 flex justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const status = settings?.status;
|
||||
const isRunning = status?.running || false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
|
||||
{/* Left Column - Settings */}
|
||||
<div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Printer className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Virtual Printer</h2>
|
||||
</div>
|
||||
{status && (
|
||||
<div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
|
||||
{isRunning ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer
|
||||
will be archived directly without printing.
|
||||
</p>
|
||||
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<div className="text-white font-medium">Enable Virtual Printer</div>
|
||||
<div className="text-sm text-bambu-gray">
|
||||
{isRunning ? 'Visible as "Bambuddy" in slicer discovery' : 'Not visible to slicers'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
disabled={updateMutation.isPending}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
} ${updateMutation.isPending ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
localEnabled ? 'translate-x-6' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Access Code */}
|
||||
<div className="py-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white font-medium mb-2">Access Code</div>
|
||||
<div className="text-sm text-bambu-gray mb-3">
|
||||
{settings?.access_code_set ? (
|
||||
<span className="flex items-center gap-1 text-green-400">
|
||||
<Check className="w-4 h-4" />
|
||||
Access code is set
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-yellow-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
No access code set - required to enable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showAccessCode ? 'text' : 'password'}
|
||||
value={localAccessCode}
|
||||
onChange={(e) => setLocalAccessCode(e.target.value)}
|
||||
placeholder={settings?.access_code_set ? 'Enter new code to change' : 'Enter 8-char code'}
|
||||
maxLength={8}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowAccessCode(!showAccessCode)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
|
||||
>
|
||||
{showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAccessCodeChange}
|
||||
disabled={!localAccessCode || updateMutation.isPending}
|
||||
variant="primary"
|
||||
>
|
||||
{updateMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
Must be exactly 8 characters. Used by slicers to authenticate.
|
||||
{localAccessCode && (
|
||||
<span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{' '}({localAccessCode.length}/8)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Archive Mode */}
|
||||
<div className="py-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white font-medium mb-2">Archive Mode</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleModeChange('immediate')}
|
||||
disabled={updateMutation.isPending}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
localMode === 'immediate'
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="text-white font-medium">Immediate</div>
|
||||
<div className="text-xs text-bambu-gray">Archive files as soon as they are uploaded</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('queue')}
|
||||
disabled={updateMutation.isPending}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
localMode === 'queue'
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="text-white font-medium">Queue for Review</div>
|
||||
<div className="text-xs text-bambu-gray">Review and tag files before archiving</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Info & Status */}
|
||||
<div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-bambu-gray">
|
||||
<p className="mb-2">
|
||||
<strong className="text-white">How it works:</strong>
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Enable the virtual printer and set an access code</li>
|
||||
<li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
|
||||
<li>The "Bambuddy" printer should appear in the discovery list</li>
|
||||
<li>Connect using the access code you set</li>
|
||||
<li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
|
||||
</ol>
|
||||
<p className="mt-3 text-yellow-400/80">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Required ports: 2021 (SSDP), 8883 (MQTT), 990 (FTP)
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-bambu-gray space-y-1">
|
||||
<p>Port 990 requires root or iptables redirect:</p>
|
||||
<code className="block bg-bambu-dark-tertiary px-2 py-1 rounded text-[10px]">
|
||||
sudo iptables -t nat -A PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990
|
||||
</code>
|
||||
<code className="block bg-bambu-dark-tertiary px-2 py-1 rounded text-[10px]">
|
||||
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Details (when running) */}
|
||||
{status && isRunning && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-md font-semibold text-white">Status Details</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-bambu-gray">Printer Name</div>
|
||||
<div className="text-white">{status.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-bambu-gray">Model</div>
|
||||
<div className="text-white">{status.model?.replace('3DPrinter-', '').replace('-', ' ') || 'X1 Carbon'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-bambu-gray">Serial Number</div>
|
||||
<div className="text-white font-mono">{status.serial}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-bambu-gray">Mode</div>
|
||||
<div className="text-white capitalize">{status.mode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-bambu-gray">Pending Files</div>
|
||||
<div className="text-white">{status.pending_files}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
|
||||
import { TimelapseViewer } from '../components/TimelapseViewer';
|
||||
import { AddToQueueModal } from '../components/AddToQueueModal';
|
||||
import { CompareArchivesModal } from '../components/CompareArchivesModal';
|
||||
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -1722,6 +1723,9 @@ export function ArchivesPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending Uploads Panel (visible when in queue mode with pending files) */}
|
||||
<PendingUploadsPanel />
|
||||
|
||||
{/* Archives */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-bambu-gray">Loading archives...</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield } from 'lucide-react';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
|
||||
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { SmartPlugCard } from '../components/SmartPlugCard';
|
||||
@@ -16,6 +16,8 @@ import { BackupModal } from '../components/BackupModal';
|
||||
import { RestoreModal } from '../components/RestoreModal';
|
||||
import { SpoolmanSettings } from '../components/SpoolmanSettings';
|
||||
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
|
||||
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
|
||||
import { virtualPrinterApi } from '../api/client';
|
||||
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
|
||||
import { availableLanguages } from '../i18n';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
@@ -33,7 +35,7 @@ export function SettingsPage() {
|
||||
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
|
||||
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys' | 'virtual-printer'>('general');
|
||||
const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
|
||||
const [newAPIKeyName, setNewAPIKeyName] = useState('');
|
||||
const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
|
||||
@@ -165,6 +167,14 @@ export function SettingsPage() {
|
||||
queryFn: api.getNotificationTemplates,
|
||||
});
|
||||
|
||||
// Virtual printer status for tab indicator
|
||||
const { data: virtualPrinterSettings } = useQuery({
|
||||
queryKey: ['virtual-printer-settings'],
|
||||
queryFn: virtualPrinterApi.getSettings,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;
|
||||
|
||||
const { data: ffmpegStatus } = useQuery({
|
||||
queryKey: ['ffmpeg-status'],
|
||||
queryFn: api.checkFfmpeg,
|
||||
@@ -332,7 +342,28 @@ export function SettingsPage() {
|
||||
|
||||
// Set new debounced save (500ms delay)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
updateMutation.mutate(localSettings);
|
||||
// Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately)
|
||||
const settingsToSave: AppSettingsUpdate = {
|
||||
auto_archive: localSettings.auto_archive,
|
||||
save_thumbnails: localSettings.save_thumbnails,
|
||||
capture_finish_photo: localSettings.capture_finish_photo,
|
||||
default_filament_cost: localSettings.default_filament_cost,
|
||||
currency: localSettings.currency,
|
||||
energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
|
||||
energy_tracking_mode: localSettings.energy_tracking_mode,
|
||||
check_updates: localSettings.check_updates,
|
||||
notification_language: localSettings.notification_language,
|
||||
telemetry_enabled: localSettings.telemetry_enabled,
|
||||
ams_humidity_good: localSettings.ams_humidity_good,
|
||||
ams_humidity_fair: localSettings.ams_humidity_fair,
|
||||
ams_temp_good: localSettings.ams_temp_good,
|
||||
ams_temp_fair: localSettings.ams_temp_fair,
|
||||
ams_history_retention_days: localSettings.ams_history_retention_days,
|
||||
date_format: localSettings.date_format,
|
||||
time_format: localSettings.time_format,
|
||||
default_printer_id: localSettings.default_printer_id,
|
||||
};
|
||||
updateMutation.mutate(settingsToSave);
|
||||
}, 500);
|
||||
|
||||
// Cleanup on unmount or when localSettings changes again
|
||||
@@ -422,6 +453,18 @@ export function SettingsPage() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('virtual-printer')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
activeTab === 'virtual-printer'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
Virtual Printer
|
||||
<span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* General Tab */}
|
||||
@@ -1725,6 +1768,11 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Printer Tab */}
|
||||
{activeTab === 'virtual-printer' && (
|
||||
<VirtualPrinterSettings />
|
||||
)}
|
||||
|
||||
{/* Delete API Key Confirmation */}
|
||||
{showDeleteAPIKeyConfirm !== null && (
|
||||
<ConfirmModal
|
||||
@@ -1878,6 +1926,9 @@ export function SettingsPage() {
|
||||
return await api.importBackup(file, overwrite);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
// Reset local settings to force re-sync from restored data
|
||||
setLocalSettings(null);
|
||||
isInitialLoadRef.current = true;
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,10 @@ pydantic-settings>=2.0.0
|
||||
paho-mqtt>=2.0.0
|
||||
aioftp>=0.22.0
|
||||
|
||||
# Virtual Printer (emulates Bambu printer for slicer uploads)
|
||||
pyftpdlib>=2.0.0
|
||||
cryptography>=41.0.0
|
||||
|
||||
# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
|
||||
|
||||
# Excel Export
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Bv0AILUD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BOEy1ke3.css">
|
||||
<script type="module" crossorigin src="/assets/index-CYUCozUo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DUGbV7GF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user