diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e593051b..2f3e5d379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ All notable changes to Bambuddy will be documented in this file. - No authentication required - designed for OBS browser source embedding - Gradient overlay at bottom for readable text over camera feed - Auto-reconnect on camera stream errors +- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173): + - New "MQTT" plug type alongside Tasmota and Home Assistant + - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.) + - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state + - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`) + - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh) + - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1") + - Monitor-only: displays power/energy data without control capabilities + - Reuses existing MQTT broker settings from Settings → Network + - Energy data included in statistics and per-print tracking + - Full backup/restore support for MQTT plug configurations - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks: - Prevents Bambuddy from checking Bambu Lab servers for firmware updates - Useful for users who prefer to manage firmware manually or have network restrictions diff --git a/README.md b/README.md index 9efad852f..f14d785dd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support) - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates) - Archive comparison (side-by-side diff) +- Tag management (rename/delete across all archives) ### 📊 Monitoring & Control - Real-time printer status via WebSocket @@ -76,17 +77,21 @@ ### ⏰ Scheduling & Automation - Print queue with drag-and-drop - Multi-printer selection (send to multiple printers at once) +- Model-based queue assignment (send to "any X1C" for load balancing) +- Filament validation (only assign to printers with required filaments) - Per-printer AMS mapping (individual slot configuration for print farms) - Scheduled prints (date/time) - Queue Only mode (stage without auto-start) -- Smart plug integration (Tasmota, Home Assistant) +- Smart plug integration (Tasmota, Home Assistant, MQTT) +- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring - Energy consumption tracking (per-print kWh and cost) - HA energy sensor support (for plugs with separate power/energy sensors) - Auto power-on before print - Auto power-off after cooldown ### 📁 File Manager (Library) -- Upload and organize sliced files (3MF, gcode) +- Upload and organize sliced files (3MF, gcode, STL) +- **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files - ZIP file extraction with folder structure preservation - Option to create folder from ZIP filename - Folder structure with drag-and-drop @@ -117,6 +122,7 @@ - Print finish photo URL in notifications - HMS error alerts (AMS, nozzle, etc.) - Build plate detection alerts +- Queue events (waiting, skipped, failed) ### 🔧 Integrations - [Spoolman](https://github.com/Donkie/Spoolman) filament sync @@ -507,10 +513,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. --- -If you like Bambuddy and want to support it, you can buy Martin a coffee. - ---- - ## 📄 License MIT License — see [LICENSE](LICENSE) for details. @@ -525,6 +527,10 @@ MIT License — see [LICENSE](LICENSE) for details. --- +If you like Bambuddy and want to support it, you can buy Martin a coffee. + +--- +

Made with ❤️ for the 3D printing community

diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index 811e3bd30..000000000 Binary files a/backend/.coverage and /dev/null differ diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 8aff35dcb..b19855f21 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -78,6 +78,7 @@ def archive_to_response( "nozzle_diameter": archive.nozzle_diameter, "bed_temperature": archive.bed_temperature, "nozzle_temperature": archive.nozzle_temperature, + "sliced_for_model": archive.sliced_for_model, "status": archive.status, "started_at": archive.started_at, "completed_at": archive.completed_at, @@ -514,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): if energy_tracking_mode == "total": # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption) from backend.app.models.smart_plug import SmartPlug + from backend.app.services.homeassistant import homeassistant_service + from backend.app.services.mqtt_relay import mqtt_relay from backend.app.services.tasmota import tasmota_service plugs_result = await db.execute(select(SmartPlug)) @@ -521,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_energy_kwh = 0.0 for plug in plugs: - energy = await tasmota_service.get_energy(plug) - if energy and energy.get("total") is not None: - total_energy_kwh += energy["total"] + if plug.plug_type == "tasmota": + energy = await tasmota_service.get_energy(plug) + if energy and energy.get("total") is not None: + total_energy_kwh += energy["total"] + elif plug.plug_type == "homeassistant": + energy = await homeassistant_service.get_energy(plug) + if energy and energy.get("total") is not None: + total_energy_kwh += energy["total"] + elif plug.plug_type == "mqtt": + # MQTT plugs report "today" energy, not lifetime total + mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id) + if mqtt_data and mqtt_data.energy is not None: + total_energy_kwh += mqtt_data.energy total_energy_kwh = round(total_energy_kwh, 3) total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2) @@ -551,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): ) +@router.get("/tags") +async def get_all_tags(db: AsyncSession = Depends(get_db)): + """List all unique tags with usage counts. + + Returns a list of tags sorted by count (descending), then by name. + """ + # Query all archives with non-null tags + result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None))) + all_tags_rows = result.all() + + # Count occurrences of each tag + tag_counts: dict[str, int] = {} + for (tags_str,) in all_tags_rows: + if tags_str: + for tag in tags_str.split(","): + tag = tag.strip() + if tag: + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + # Convert to list and sort by count (desc), then name (asc) + tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()] + tags_list.sort(key=lambda x: (-x["count"], x["name"].lower())) + + return tags_list + + +@router.put("/tags/{tag_name}") +async def rename_tag( + tag_name: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Rename a tag across all archives. + + Request body should contain {"new_name": "new tag name"}. + Returns the count of affected archives. + """ + body = await request.json() + new_name = body.get("new_name", "").strip() + + if not new_name: + raise HTTPException(400, "new_name is required") + + if new_name == tag_name: + return {"affected": 0} + + # Find all archives containing the old tag + result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None))) + archives = list(result.scalars().all()) + + affected = 0 + for archive in archives: + if not archive.tags: + continue + tags = [t.strip() for t in archive.tags.split(",")] + if tag_name in tags: + # Replace old tag with new tag + new_tags = [new_name if t == tag_name else t for t in tags] + # Remove duplicates while preserving order + seen = set() + unique_tags = [] + for t in new_tags: + if t not in seen: + seen.add(t) + unique_tags.append(t) + archive.tags = ", ".join(unique_tags) + affected += 1 + + await db.commit() + return {"affected": affected} + + +@router.delete("/tags/{tag_name}") +async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)): + """Delete a tag from all archives. + + Returns the count of affected archives. + """ + # Find all archives containing the tag + result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None))) + archives = list(result.scalars().all()) + + affected = 0 + for archive in archives: + if not archive.tags: + continue + tags = [t.strip() for t in archive.tags.split(",")] + if tag_name in tags: + # Remove the tag + new_tags = [t for t in tags if t != tag_name] + archive.tags = ", ".join(new_tags) if new_tags else None + affected += 1 + + await db.commit() + return {"affected": affected} + + @router.get("/{archive_id}", response_model=ArchiveResponse) async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): """Get a specific archive.""" diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index fdf126c72..2015ed0b6 100644 --- a/backend/app/api/routes/camera.py +++ b/backend/app/api/routes/camera.py @@ -713,14 +713,15 @@ async def check_plate_empty( ) from backend.app.services.printer_manager import printer_manager + # Check printer exists first (before OpenCV check) + printer = await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException( status_code=503, detail="Plate detection not available. Install opencv-python-headless to enable.", ) - printer = await get_printer_or_404(printer_id, db) - # Check chamber light status light_warning = False state = printer_manager.get_status(printer_id) @@ -818,14 +819,15 @@ async def calibrate_plate_detection( ) from backend.app.services.printer_manager import printer_manager + # Check printer exists first (before OpenCV check) + printer = await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException( status_code=503, detail="Plate detection not available. Install opencv-python-headless to enable.", ) - printer = await get_printer_or_404(printer_id, db) - # Check chamber light - warn but don't block state = printer_manager.get_status(printer_id) light_warning = state and not state.chamber_light @@ -869,15 +871,15 @@ async def delete_plate_calibration( is_plate_detection_available, ) + # Verify printer exists first (before OpenCV check) + await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException( status_code=503, detail="Plate detection not available. Install opencv-python-headless to enable.", ) - # Verify printer exists - await get_printer_or_404(printer_id, db) - deleted = delete_calibration(printer_id, plate_type) plate_msg = f" for '{plate_type}'" if plate_type else "" @@ -909,6 +911,9 @@ async def get_plate_detection_status( ) from backend.app.services.printer_manager import printer_manager + # Verify printer exists first (before OpenCV check) + await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): return { "available": False, @@ -918,9 +923,6 @@ async def get_plate_detection_status( "message": "OpenCV not installed", } - # Verify printer exists - await get_printer_or_404(printer_id, db) - # Get chamber light status state = printer_manager.get_status(printer_id) chamber_light = state.chamber_light if state else False @@ -942,11 +944,12 @@ async def get_plate_references( """ from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + # Verify printer exists first (before OpenCV check) + await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException(503, "Plate detection not available") - await get_printer_or_404(printer_id, db) - detector = PlateDetector() references = detector.get_references(printer_id) @@ -973,11 +976,12 @@ async def get_reference_thumbnail( from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + # Verify printer exists first (before OpenCV check) + await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException(503, "Plate detection not available") - await get_printer_or_404(printer_id, db) - detector = PlateDetector() thumbnail = detector.get_reference_thumbnail(printer_id, index) @@ -997,11 +1001,12 @@ async def update_reference_label( """Update the label for a calibration reference.""" from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + # Verify printer exists first (before OpenCV check) + await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException(503, "Plate detection not available") - await get_printer_or_404(printer_id, db) - detector = PlateDetector() success = detector.update_reference_label(printer_id, index, label) @@ -1020,11 +1025,12 @@ async def delete_reference( """Delete a specific calibration reference.""" from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + # Verify printer exists first (before OpenCV check) + await get_printer_or_404(printer_id, db) + if not is_plate_detection_available(): raise HTTPException(503, "Plate detection not available") - await get_printer_or_404(printer_id, db) - detector = PlateDetector() success = detector.delete_reference(printer_id, index) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index d19bf2ca5..0c75b63ef 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -25,6 +25,9 @@ from backend.app.schemas.library import ( AddToQueueRequest, AddToQueueResponse, AddToQueueResult, + BatchThumbnailRequest, + BatchThumbnailResponse, + BatchThumbnailResult, BulkDeleteRequest, BulkDeleteResponse, FileDuplicate, @@ -43,6 +46,7 @@ from backend.app.schemas.library import ( ZipExtractResult, ) from backend.app.services.archive import ArchiveService, ThreeMFParser +from backend.app.services.stl_thumbnail import generate_stl_thumbnail logger = logging.getLogger(__name__) @@ -621,6 +625,7 @@ async def list_files( async def upload_file( file: UploadFile = File(...), folder_id: int | None = None, + generate_stl_thumbnails: bool = Query(default=True), db: AsyncSession = Depends(get_db), ): """Upload a file to the library.""" @@ -712,6 +717,11 @@ async def upload_file( # For image files, create a thumbnail from the image itself thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + elif ext == ".stl": + # Generate STL thumbnail if enabled + if generate_stl_thumbnails: + thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) + # Create database entry library_file = LibraryFile( folder_id=folder_id, @@ -749,6 +759,7 @@ async def extract_zip_file( folder_id: int | None = Query(default=None), preserve_structure: bool = Query(default=True), create_folder_from_zip: bool = Query(default=False), + generate_stl_thumbnails: bool = Query(default=True), db: AsyncSession = Depends(get_db), ): """Upload and extract a ZIP file to the library. @@ -758,6 +769,7 @@ async def extract_zip_file( folder_id: Target folder ID (None = root) preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it + generate_stl_thumbnails: If True, generate thumbnails for STL files """ import tempfile import zipfile @@ -941,6 +953,11 @@ async def extract_zip_file( elif ext.lower() in IMAGE_EXTENSIONS: thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + elif ext == ".stl": + # Generate STL thumbnail if enabled + if generate_stl_thumbnails: + thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) + # Create database entry library_file = LibraryFile( folder_id=target_folder_id, @@ -994,6 +1011,118 @@ async def extract_zip_file( pass +# ============ STL Thumbnail Batch Generation ============ + + +@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse) +async def batch_generate_stl_thumbnails( + request: BatchThumbnailRequest, + db: AsyncSession = Depends(get_db), +): + """Generate thumbnails for STL files in batch. + + Can generate thumbnails for: + - Specific file IDs (file_ids) + - All STL files in a folder (folder_id) + - All STL files missing thumbnails (all_missing=True) + """ + thumbnails_dir = get_library_thumbnails_dir() + results: list[BatchThumbnailResult] = [] + + # Build query based on request + query = select(LibraryFile).where(LibraryFile.file_type == "stl") + + if request.file_ids: + # Specific files + query = query.where(LibraryFile.id.in_(request.file_ids)) + elif request.folder_id is not None: + # All STL files in a specific folder + query = query.where(LibraryFile.folder_id == request.folder_id) + if not request.all_missing: + # If not specifically asking for missing thumbnails, get all + pass + else: + query = query.where(LibraryFile.thumbnail_path.is_(None)) + elif request.all_missing: + # All STL files without thumbnails + query = query.where(LibraryFile.thumbnail_path.is_(None)) + else: + # No criteria specified - return empty + return BatchThumbnailResponse( + processed=0, + succeeded=0, + failed=0, + results=[], + ) + + result = await db.execute(query) + stl_files = result.scalars().all() + + succeeded = 0 + failed = 0 + + for stl_file in stl_files: + file_path = Path(stl_file.file_path) + + if not file_path.exists(): + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=False, + error="File not found on disk", + ) + ) + failed += 1 + continue + + try: + thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) + + if thumbnail_path: + # Update database + stl_file.thumbnail_path = thumbnail_path + await db.flush() + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=True, + ) + ) + succeeded += 1 + else: + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=False, + error="Thumbnail generation failed", + ) + ) + failed += 1 + except Exception as e: + logger.error(f"Failed to generate thumbnail for {stl_file.filename}: {e}") + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=False, + error=str(e), + ) + ) + failed += 1 + + await db.commit() + + return BatchThumbnailResponse( + processed=len(stl_files), + succeeded=succeeded, + failed=failed, + results=results, + ) + + # ============ Queue Operations ============ # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py index ea5d20819..b47004072 100644 --- a/backend/app/api/routes/notification_templates.py +++ b/backend/app/api/routes/notification_templates.py @@ -32,6 +32,14 @@ EVENT_NAMES = { "filament_low": "Filament Low", "maintenance_due": "Maintenance Due", "test": "Test Notification", + # Queue notifications + "queue_job_added": "Queue Job Added", + "queue_job_assigned": "Queue Job Assigned", + "queue_job_started": "Queue Job Started", + "queue_job_waiting": "Queue Job Waiting", + "queue_job_skipped": "Queue Job Skipped", + "queue_job_failed": "Queue Job Failed", + "queue_completed": "Queue Completed", } diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index a6c925719..f94ce1431 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -53,6 +53,14 @@ def _provider_to_dict(provider: NotificationProvider) -> dict: "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high, # Build plate detection "on_plate_not_empty": provider.on_plate_not_empty, + # Print queue events + "on_queue_job_added": provider.on_queue_job_added, + "on_queue_job_assigned": provider.on_queue_job_assigned, + "on_queue_job_started": provider.on_queue_job_started, + "on_queue_job_waiting": provider.on_queue_job_waiting, + "on_queue_job_skipped": provider.on_queue_job_skipped, + "on_queue_job_failed": provider.on_queue_job_failed, + "on_queue_completed": provider.on_queue_completed, # Quiet hours "quiet_hours_enabled": provider.quiet_hours_enabled, "quiet_hours_start": provider.quiet_hours_start, diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index a8ed36a0f..4e1ad7b65 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -2,13 +2,17 @@ import json import logging +import xml.etree.ElementTree as ET +import zipfile from datetime import datetime +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.models.archive import PrintArchive from backend.app.models.library import LibraryFile @@ -22,12 +26,75 @@ from backend.app.schemas.print_queue import ( PrintQueueItemUpdate, PrintQueueReorder, ) +from backend.app.services.notification_service import notification_service +from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/queue", tags=["queue"]) +def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]: + """Extract unique filament types from a 3MF file. + + Args: + file_path: Path to the 3MF file + plate_id: Optional plate index to filter for (for multi-plate files) + + Returns: + List of unique filament types (e.g., ["PLA", "PETG"]) + """ + types: set[str] = set() + + try: + with zipfile.ZipFile(file_path, "r") as zf: + if "Metadata/slice_info.config" not in zf.namelist(): + return [] + + content = zf.read("Metadata/slice_info.config").decode() + root = ET.fromstring(content) + + if plate_id is not None: + # Find the plate element with matching index + for plate_elem in root.findall(".//plate"): + plate_index = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + try: + plate_index = int(meta.get("value", "0")) + except ValueError: + pass + break + + if plate_index == plate_id: + for filament_elem in plate_elem.findall("filament"): + filament_type = filament_elem.get("type", "") + used_g = filament_elem.get("used_g", "0") + try: + used_grams = float(used_g) + except (ValueError, TypeError): + used_grams = 0 + if used_grams > 0 and filament_type: + types.add(filament_type) + break + else: + # No plate_id specified - extract all filaments with used_g > 0 + for filament_elem in root.findall(".//filament"): + filament_type = filament_elem.get("type", "") + used_g = filament_elem.get("used_g", "0") + try: + used_grams = float(used_g) + except (ValueError, TypeError): + used_grams = 0 + if used_grams > 0 and filament_type: + types.add(filament_type) + + except Exception as e: + logger.warning(f"Failed to extract filament types from {file_path}: {e}") + + return sorted(types) + + def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: """Add nested archive/printer/library_file info to response.""" # Parse ams_mapping from JSON string BEFORE model_validate @@ -38,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: except json.JSONDecodeError: ams_mapping_parsed = None + # Parse required_filament_types from JSON string + required_filament_types_parsed = None + if item.required_filament_types: + try: + required_filament_types_parsed = json.loads(item.required_filament_types) + except json.JSONDecodeError: + required_filament_types_parsed = None + # Create response with parsed ams_mapping item_dict = { "id": item.id, "printer_id": item.printer_id, + "target_model": item.target_model, + "required_filament_types": required_filament_types_parsed, + "waiting_reason": item.waiting_reason, "archive_id": item.archive_id, "library_file_id": item.library_file_id, "position": item.position, @@ -120,29 +198,71 @@ async def add_to_queue( db: AsyncSession = Depends(get_db), ): """Add an item to the print queue.""" + # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E") + target_model_norm = None + if data.target_model: + target_model_norm = ( + normalize_printer_model(data.target_model) + or normalize_printer_model_id(data.target_model) + or data.target_model + ) + # Validate that either archive_id or library_file_id is provided if not data.archive_id and not data.library_file_id: raise HTTPException(400, "Either archive_id or library_file_id must be provided") + # Cannot specify both printer_id and target_model + if data.printer_id and target_model_norm: + raise HTTPException(400, "Cannot specify both printer_id and target_model") + # Validate printer exists (if assigned) if data.printer_id is not None: result = await db.execute(select(Printer).where(Printer.id == data.printer_id)) if not result.scalar_one_or_none(): raise HTTPException(400, "Printer not found") - # Validate archive exists (if provided) + # Validate target_model has active printers + if target_model_norm: + result = await db.execute( + select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True) # noqa: E712 + ) + if not result.scalars().first(): + raise HTTPException(400, f"No active printers for model: {target_model_norm}") + + # Validate archive exists (if provided) and get it for filament extraction + archive = None if data.archive_id: result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id)) - if not result.scalar_one_or_none(): + archive = result.scalar_one_or_none() + if not archive: raise HTTPException(400, "Archive not found") - # Validate library file exists (if provided) + # Validate library file exists (if provided) and get it for filament extraction + library_file = None if data.library_file_id: result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id)) - if not result.scalar_one_or_none(): + library_file = result.scalar_one_or_none() + if not library_file: raise HTTPException(400, "Library file not found") - # Get next position for this printer (or for unassigned items) + # Extract filament types for model-based assignment (used by scheduler for validation) + required_filament_types = None + if target_model_norm: + # Get file path from archive or library file + file_path = None + if archive: + file_path = settings.base_dir / archive.file_path + elif library_file: + lib_path = Path(library_file.file_path) + file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path + + if file_path and file_path.exists(): + filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id) + if filament_types: + required_filament_types = json.dumps(filament_types) + logger.info(f"Extracted filament types for model-based queue: {filament_types}") + + # Get next position for this printer (or for unassigned/model-based items) if data.printer_id is not None: result = await db.execute( select(func.max(PrintQueueItem.position)) @@ -150,7 +270,7 @@ async def add_to_queue( .where(PrintQueueItem.status == "pending") ) else: - # For unassigned items, get max position across all unassigned + # For unassigned/model-based items, get max position across all unassigned result = await db.execute( select(func.max(PrintQueueItem.position)) .where(PrintQueueItem.printer_id.is_(None)) @@ -160,6 +280,8 @@ async def add_to_queue( item = PrintQueueItem( printer_id=data.printer_id, + target_model=target_model_norm, + required_filament_types=required_filament_types, archive_id=data.archive_id, library_file_id=data.library_file_id, scheduled_time=data.scheduled_time, @@ -185,7 +307,8 @@ async def add_to_queue( await db.refresh(item, ["archive", "printer", "library_file"]) source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}" - logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}") + target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned") + logger.info(f"Added {source_name} to queue for {target_desc}") # MQTT relay - publish queue job added try: @@ -200,6 +323,29 @@ async def add_to_queue( except Exception: pass # Don't fail queue add if MQTT fails + # Send notification for job added + try: + job_name = ( + item.archive.filename + if item.archive + else item.library_file.filename + if item.library_file + else f"Job #{item.id}" + ) + job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "") + target = ( + item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned") + ) + await notification_service.on_queue_job_added( + job_name=job_name, + target=target, + db=db, + printer_id=item.printer_id, + printer_name=item.printer.name if item.printer else None, + ) + except Exception: + pass # Don't fail queue add if notification fails + return _enrich_response(item) @@ -287,12 +433,34 @@ async def update_queue_item( update_data = data.model_dump(exclude_unset=True) + # Normalize target_model if being updated + if "target_model" in update_data and update_data["target_model"]: + update_data["target_model"] = ( + normalize_printer_model(update_data["target_model"]) + or normalize_printer_model_id(update_data["target_model"]) + or update_data["target_model"] + ) + + # Cannot specify both printer_id and target_model + new_printer_id = update_data.get("printer_id", item.printer_id) + new_target_model = update_data.get("target_model", item.target_model) + if new_printer_id and new_target_model: + raise HTTPException(400, "Cannot specify both printer_id and target_model") + # Validate new printer_id if being changed (and not None) if "printer_id" in update_data and update_data["printer_id"] is not None: result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"])) if not result.scalar_one_or_none(): raise HTTPException(400, "Printer not found") + # Validate target_model has active printers + if "target_model" in update_data and update_data["target_model"]: + result = await db.execute( + select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True) # noqa: E712 + ) + if not result.scalars().first(): + raise HTTPException(400, f"No active printers for model: {update_data['target_model']}") + # Serialize ams_mapping to JSON for TEXT column storage if "ams_mapping" in update_data: update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index de1e235e6..6810be969 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -311,6 +311,13 @@ async def export_backup( "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False), "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False), "on_plate_not_empty": getattr(p, "on_plate_not_empty", True), + "on_queue_job_added": getattr(p, "on_queue_job_added", False), + "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False), + "on_queue_job_started": getattr(p, "on_queue_job_started", False), + "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True), + "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True), + "on_queue_job_failed": getattr(p, "on_queue_job_failed", True), + "on_queue_completed": getattr(p, "on_queue_completed", False), "quiet_hours_enabled": p.quiet_hours_enabled, "quiet_hours_start": p.quiet_hours_start, "quiet_hours_end": p.quiet_hours_end, @@ -360,6 +367,21 @@ async def export_backup( "ha_power_entity": plug.ha_power_entity, "ha_energy_today_entity": plug.ha_energy_today_entity, "ha_energy_total_entity": plug.ha_energy_total_entity, + # MQTT plug fields (legacy) + "mqtt_topic": plug.mqtt_topic, + "mqtt_multiplier": plug.mqtt_multiplier, + # MQTT power fields + "mqtt_power_topic": plug.mqtt_power_topic, + "mqtt_power_path": plug.mqtt_power_path, + "mqtt_power_multiplier": plug.mqtt_power_multiplier, + # MQTT energy fields + "mqtt_energy_topic": plug.mqtt_energy_topic, + "mqtt_energy_path": plug.mqtt_energy_path, + "mqtt_energy_multiplier": plug.mqtt_energy_multiplier, + # MQTT state fields + "mqtt_state_topic": plug.mqtt_state_topic, + "mqtt_state_path": plug.mqtt_state_path, + "mqtt_state_on_value": plug.mqtt_state_on_value, "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, "enabled": plug.enabled, "auto_on": plug.auto_on, @@ -376,6 +398,7 @@ async def export_backup( "schedule_on_time": plug.schedule_on_time, "schedule_off_time": plug.schedule_off_time, "show_in_switchbar": plug.show_in_switchbar, + "show_on_printer_card": plug.show_on_printer_card, } ) backup["included"].append("smart_plugs") @@ -1176,6 +1199,13 @@ async def import_backup( existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False) existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False) existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True) + existing.on_queue_job_added = provider_data.get("on_queue_job_added", False) + existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False) + existing.on_queue_job_started = provider_data.get("on_queue_job_started", False) + existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True) + existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True) + existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True) + existing.on_queue_completed = provider_data.get("on_queue_completed", False) existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False) existing.quiet_hours_start = provider_data.get("quiet_hours_start") existing.quiet_hours_end = provider_data.get("quiet_hours_end") @@ -1206,6 +1236,13 @@ async def import_backup( on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False), on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False), on_plate_not_empty=provider_data.get("on_plate_not_empty", True), + on_queue_job_added=provider_data.get("on_queue_job_added", False), + on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False), + on_queue_job_started=provider_data.get("on_queue_job_started", False), + on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True), + on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True), + on_queue_job_failed=provider_data.get("on_queue_job_failed", True), + on_queue_completed=provider_data.get("on_queue_completed", False), quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False), quiet_hours_start=provider_data.get("quiet_hours_start"), quiet_hours_end=provider_data.get("quiet_hours_end"), @@ -1257,12 +1294,23 @@ async def import_backup( # Determine plug type (default to tasmota for backwards compatibility) plug_type = plug_data.get("plug_type", "tasmota") - # Find existing plug by IP (Tasmota) or entity_id (Home Assistant) + # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT) existing = None + plug_identifier = None if plug_type == "homeassistant" and plug_data.get("ha_entity_id"): result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"])) existing = result.scalar_one_or_none() plug_identifier = plug_data["ha_entity_id"] + elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")): + # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy) + power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic") + result = await db.execute( + select(SmartPlug).where( + (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic) + ) + ) + existing = result.scalar_one_or_none() + plug_identifier = power_topic elif plug_data.get("ip_address"): result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) existing = result.scalar_one_or_none() @@ -1279,6 +1327,21 @@ async def import_backup( existing.ha_power_entity = plug_data.get("ha_power_entity") existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity") existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity") + # MQTT fields (legacy) + existing.mqtt_topic = plug_data.get("mqtt_topic") + existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0) + # MQTT power fields + existing.mqtt_power_topic = plug_data.get("mqtt_power_topic") + existing.mqtt_power_path = plug_data.get("mqtt_power_path") + existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0) + # MQTT energy fields + existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic") + existing.mqtt_energy_path = plug_data.get("mqtt_energy_path") + existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0) + # MQTT state fields + existing.mqtt_state_topic = plug_data.get("mqtt_state_topic") + existing.mqtt_state_path = plug_data.get("mqtt_state_path") + existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value") existing.printer_id = printer_id existing.enabled = plug_data.get("enabled", True) existing.auto_on = plug_data.get("auto_on", True) @@ -1295,6 +1358,7 @@ async def import_backup( existing.schedule_on_time = plug_data.get("schedule_on_time") existing.schedule_off_time = plug_data.get("schedule_off_time") existing.show_in_switchbar = plug_data.get("show_in_switchbar", False) + existing.show_on_printer_card = plug_data.get("show_on_printer_card", True) restored["smart_plugs"] += 1 else: skipped["smart_plugs"] += 1 @@ -1308,6 +1372,21 @@ async def import_backup( ha_power_entity=plug_data.get("ha_power_entity"), ha_energy_today_entity=plug_data.get("ha_energy_today_entity"), ha_energy_total_entity=plug_data.get("ha_energy_total_entity"), + # MQTT fields (legacy) + mqtt_topic=plug_data.get("mqtt_topic"), + mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0), + # MQTT power fields + mqtt_power_topic=plug_data.get("mqtt_power_topic"), + mqtt_power_path=plug_data.get("mqtt_power_path"), + mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0), + # MQTT energy fields + mqtt_energy_topic=plug_data.get("mqtt_energy_topic"), + mqtt_energy_path=plug_data.get("mqtt_energy_path"), + mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0), + # MQTT state fields + mqtt_state_topic=plug_data.get("mqtt_state_topic"), + mqtt_state_path=plug_data.get("mqtt_state_path"), + mqtt_state_on_value=plug_data.get("mqtt_state_on_value"), printer_id=printer_id, enabled=plug_data.get("enabled", True), auto_on=plug_data.get("auto_on", True), @@ -1324,6 +1403,7 @@ async def import_backup( schedule_on_time=plug_data.get("schedule_on_time"), schedule_off_time=plug_data.get("schedule_off_time"), show_in_switchbar=plug_data.get("show_in_switchbar", False), + show_on_printer_card=plug_data.get("show_on_printer_card", True), ) db.add(plug) restored["smart_plugs"] += 1 diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 1a73338cf..79732a104 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import ( ) from backend.app.services.discovery import tasmota_scanner from backend.app.services.homeassistant import homeassistant_service +from backend.app.services.mqtt_relay import mqtt_relay from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import printer_manager from backend.app.services.tasmota import tasmota_service @@ -56,16 +57,84 @@ async def create_smart_plug( raise HTTPException(400, "Printer not found") # Check if printer already has a plug assigned - result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + # Scripts can coexist with other plugs (they're for multi-device control, not power on/off) + is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.") + if not is_script: + # For non-script plugs, check there's no other non-script plug assigned + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") + + # For MQTT plugs, ensure MQTT broker is configured and service is connected + if data.plug_type == "mqtt": + # Try to configure the smart plug service if not already configured + if not mqtt_relay.smart_plug_service.is_configured(): + # Get MQTT broker settings from database + mqtt_broker = await get_setting(db, "mqtt_broker") or "" + if not mqtt_broker: + raise HTTPException( + 400, + "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.", + ) + + # Configure the smart plug service with broker settings + mqtt_settings = { + "mqtt_enabled": True, # Enable for smart plug subscription + "mqtt_broker": mqtt_broker, + "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"), + "mqtt_username": await get_setting(db, "mqtt_username") or "", + "mqtt_password": await get_setting(db, "mqtt_password") or "", + "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true", + } + await mqtt_relay.smart_plug_service.configure(mqtt_settings) + + # Check if connection succeeded + if not mqtt_relay.smart_plug_service.is_configured(): + raise HTTPException( + 400, + f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.", + ) plug = SmartPlug(**data.model_dump()) db.add(plug) await db.commit() await db.refresh(plug) - if plug.plug_type == "homeassistant": + # Subscribe MQTT plugs to their topics + if plug.plug_type == "mqtt": + # Determine effective topics (new fields take priority, fall back to legacy) + power_topic = plug.mqtt_power_topic or plug.mqtt_topic + energy_topic = plug.mqtt_energy_topic + state_topic = plug.mqtt_state_topic + + # Only subscribe if at least one topic is configured + if power_topic or energy_topic or state_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + # Power source (path is optional) + power_topic=power_topic, + power_path=plug.mqtt_power_path, + power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0, + # Energy source (path is optional) + energy_topic=energy_topic, + energy_path=plug.mqtt_energy_path, + energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0, + # State source (path is optional) + state_topic=state_topic, + state_path=plug.mqtt_state_path, + state_on_value=plug.mqtt_state_on_value, + ) + topics = [t for t in [power_topic, energy_topic, state_topic] if t] + logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}") + elif plug.plug_type == "homeassistant": logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})") else: logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}") @@ -74,12 +143,48 @@ async def create_smart_plug( @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None) async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): - """Get the smart plug assigned to a printer.""" + """Get the main smart plug assigned to a printer. + + When multiple plugs are assigned (e.g., a regular plug + script), + returns the main (non-script) plug for power control. + """ result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plug = result.scalar_one_or_none() - if not plug: + plugs = result.scalars().all() + + if not plugs: return None - return plug + + # If multiple plugs, prefer the non-script one (main power plug) + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + return plug + + # All are scripts, return the first one + return plugs[0] + + +@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse]) +async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get all HA script plugs assigned to a printer. + + Returns only script entities (script.*) for the printer that have + show_on_printer_card enabled. + Used to display "Run Script" buttons alongside the main power plug. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Filter to only scripts with show_on_printer_card enabled + scripts = [ + plug + for plug in plugs + if plug.plug_type == "homeassistant" + and plug.ha_entity_id + and plug.ha_entity_id.startswith("script.") + and plug.show_on_printer_card + ] + return scripts # Tasmota Discovery Endpoints @@ -287,14 +392,43 @@ async def update_smart_plug( raise HTTPException(400, "Printer not found") # Check if that printer already has a different plug assigned - result = await db.execute( - select(SmartPlug).where( - SmartPlug.printer_id == new_printer_id, - SmartPlug.id != plug_id, + # Scripts can coexist with other plugs + # Determine if the plug being updated is/will be a script + new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id) + new_plug_type = update_data.get("plug_type", plug.plug_type) + is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.") + + if not is_script: + result = await db.execute( + select(SmartPlug).where( + SmartPlug.printer_id == new_printer_id, + SmartPlug.id != plug_id, + ) ) - ) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") + + # Track old MQTT settings for comparison + old_plug_type = plug.plug_type + old_mqtt_config = { + "power_topic": plug.mqtt_power_topic or plug.mqtt_topic, + "power_path": plug.mqtt_power_path, + "power_multiplier": plug.mqtt_power_multiplier, + "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic, + "energy_path": plug.mqtt_energy_path, + "energy_multiplier": plug.mqtt_energy_multiplier, + "state_topic": plug.mqtt_state_topic or plug.mqtt_topic, + "state_path": plug.mqtt_state_path, + "state_on_value": plug.mqtt_state_on_value, + } for field, value in update_data.items(): setattr(plug, field, value) @@ -302,6 +436,54 @@ async def update_smart_plug( await db.commit() await db.refresh(plug) + # Handle MQTT subscription changes + if old_plug_type == "mqtt" and plug.plug_type != "mqtt": + # Changed away from MQTT - unsubscribe + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + elif plug.plug_type == "mqtt": + # Check if any MQTT config changed + new_mqtt_config = { + "power_topic": plug.mqtt_power_topic or plug.mqtt_topic, + "power_path": plug.mqtt_power_path, + "power_multiplier": plug.mqtt_power_multiplier, + "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic, + "energy_path": plug.mqtt_energy_path, + "energy_multiplier": plug.mqtt_energy_multiplier, + "state_topic": plug.mqtt_state_topic or plug.mqtt_topic, + "state_path": plug.mqtt_state_path, + "state_on_value": plug.mqtt_state_on_value, + } + + mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config + + if mqtt_changed: + # Unsubscribe from old topics first + if old_plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + + # Subscribe to new topics + power_topic = plug.mqtt_power_topic or plug.mqtt_topic + energy_topic = plug.mqtt_energy_topic + state_topic = plug.mqtt_state_topic + + # Only subscribe if at least one topic is configured + if power_topic or energy_topic or state_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + # Power source (path is optional) + power_topic=power_topic, + power_path=plug.mqtt_power_path, + power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0, + # Energy source (path is optional) + energy_topic=energy_topic, + energy_path=plug.mqtt_energy_path, + energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0, + # State source (path is optional) + state_topic=state_topic, + state_path=plug.mqtt_state_path, + state_on_value=plug.mqtt_state_on_value, + ) + logger.info(f"Updated smart plug '{plug.name}'") return plug @@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)): raise HTTPException(404, "Smart plug not found") plug_name = plug.name + plug_type = plug.plug_type + + # Unsubscribe MQTT plug before deletion + if plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug_id) + await db.delete(plug) await db.commit() @@ -348,6 +536,13 @@ async def control_smart_plug( if not plug: raise HTTPException(404, "Smart plug not found") + # MQTT plugs are monitor-only - cannot control them + if plug.plug_type == "mqtt": + raise HTTPException( + 400, + "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.", + ) + service = await _get_service_for_plug(plug, db) if control.action == "on": @@ -376,6 +571,13 @@ async def control_smart_plug( plug.last_checked = datetime.utcnow() await db.commit() + # Trigger associated scripts if this is a main (non-script) plug + is_main_plug = not ( + plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + ) + if is_main_plug and plug.printer_id and expected_state: + await trigger_associated_scripts(plug.printer_id, expected_state, db) + # MQTT relay - publish smart plug state change if expected_state: try: @@ -401,6 +603,37 @@ async def control_smart_plug( return {"success": True, "action": control.action} +async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession): + """Trigger scripts linked to a printer based on main plug state change. + + When the main plug turns ON, triggers scripts with auto_on=True. + When the main plug turns OFF, triggers scripts with auto_off=True. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Find scripts that should be triggered + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + continue + + should_trigger = False + if plug_state == "ON" and plug.auto_on: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-on") + elif plug_state == "OFF" and plug.auto_off: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-off") + + if should_trigger: + try: + service = await _get_service_for_plug(plug, db) + await service.turn_on(plug) # Scripts are triggered by calling turn_on + except Exception as e: + logger.error(f"Failed to trigger script '{plug.name}': {e}") + + @router.get("/{plug_id}/status", response_model=SmartPlugStatus) async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): """Get current plug status from device including energy data.""" @@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): if not plug: raise HTTPException(404, "Smart plug not found") + # Handle MQTT plugs - get data from subscription service + if plug.plug_type == "mqtt": + data = mqtt_relay.smart_plug_service.get_plug_data(plug_id) + is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id) + + if data: + # Update last state in database + if is_reachable and data.state: + plug.last_state = data.state + plug.last_checked = datetime.utcnow() + await db.commit() + + energy_data = None + if data.power is not None or data.energy is not None: + energy_data = SmartPlugEnergy( + power=data.power, + today=data.energy, + ) + # Check power alerts + if data.power is not None: + await check_power_alerts(plug, data.power, db) + + return SmartPlugStatus( + state=data.state, + reachable=is_reachable, + device_name=None, + energy=energy_data, + ) + + # No data received yet + return SmartPlugStatus( + state=None, + reachable=False, + device_name=None, + energy=None, + ) + + # Handle Tasmota/HomeAssistant plugs service = await _get_service_for_plug(plug, db) status = await service.get_status(plug) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 4b2506d76..2f74cd8c6 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -760,6 +760,140 @@ async def run_migrations(conn): except Exception: pass + # Migration: Remove UNIQUE constraint from smart_plugs.printer_id + # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control) + # SQLite requires table recreation to drop constraints + try: + # Check if we need to migrate (if UNIQUE constraint exists) + result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'")) + row = result.fetchone() + if row and "printer_id INTEGER UNIQUE" in (row[0] or ""): + # Create new table without UNIQUE constraint on printer_id + await conn.execute( + text(""" + CREATE TABLE smart_plugs_temp ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL, + ip_address VARCHAR(45), + plug_type VARCHAR(20) DEFAULT 'tasmota', + ha_entity_id VARCHAR(100), + ha_power_entity VARCHAR(100), + ha_energy_today_entity VARCHAR(100), + ha_energy_total_entity VARCHAR(100), + printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + auto_on BOOLEAN NOT NULL DEFAULT 1, + auto_off BOOLEAN NOT NULL DEFAULT 1, + off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time', + off_delay_minutes INTEGER NOT NULL DEFAULT 5, + off_temp_threshold INTEGER NOT NULL DEFAULT 70, + username VARCHAR(50), + password VARCHAR(100), + power_alert_enabled BOOLEAN NOT NULL DEFAULT 0, + power_alert_high FLOAT, + power_alert_low FLOAT, + power_alert_last_triggered DATETIME, + schedule_enabled BOOLEAN NOT NULL DEFAULT 0, + schedule_on_time VARCHAR(5), + schedule_off_time VARCHAR(5), + show_in_switchbar BOOLEAN DEFAULT 0, + last_state VARCHAR(10), + last_checked DATETIME, + auto_off_executed BOOLEAN NOT NULL DEFAULT 0, + auto_off_pending BOOLEAN DEFAULT 0, + auto_off_pending_since DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + ) + # Copy data + await conn.execute( + text(""" + INSERT INTO smart_plugs_temp + SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity, + ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled, + auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold, + username, password, power_alert_enabled, power_alert_high, power_alert_low, + power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time, + show_in_switchbar, last_state, last_checked, auto_off_executed, + auto_off_pending, auto_off_pending_since, created_at, updated_at + FROM smart_plugs + """) + ) + # Drop old table and rename new one + await conn.execute(text("DROP TABLE smart_plugs")) + await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs")) + except Exception: + pass + + # Migration: Add show_on_printer_card column to smart_plugs + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")) + except Exception: + pass + + # Migration: Add MQTT smart plug fields (legacy) + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + + # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers) + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")) + except Exception: + pass + + # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility + try: + await conn.execute( + text(""" + UPDATE smart_plugs + SET mqtt_power_topic = mqtt_topic, + mqtt_power_multiplier = mqtt_multiplier + WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL + """) + ) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/main.py b/backend/app/main.py index 40e49a34b..a60dd8c24 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -248,9 +248,10 @@ _notified_hms_errors: dict[int, set[str]] = {} async def _get_plug_energy(plug, db) -> dict | None: - """Get energy from plug regardless of type (Tasmota or Home Assistant). + """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT). For HA plugs, configures the service with current settings from DB. + For MQTT plugs, returns data from the subscription service. """ if plug.plug_type == "homeassistant": from backend.app.api.routes.settings import get_setting @@ -259,6 +260,17 @@ async def _get_plug_energy(plug, db) -> dict | None: ha_token = await get_setting(db, "ha_token") or "" homeassistant_service.configure(ha_url, ha_token) return await homeassistant_service.get_energy(plug) + elif plug.plug_type == "mqtt": + # MQTT plugs report "today" energy, not lifetime total + # For per-print tracking, we use "today" as the counter (resets at midnight) + mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id) + if mqtt_data: + return { + "power": mqtt_data.power, + "today": mqtt_data.energy, + "total": mqtt_data.energy, # Use today as total for per-print calculations + } + return None else: return await tasmota_service.get_energy(plug) @@ -1988,6 +2000,34 @@ async def on_print_complete(printer_id: int, data: dict): except Exception: pass # Don't fail if MQTT fails + # Check if queue is now empty and send notification + try: + from sqlalchemy import func + + # Count remaining pending items + count_result = await db.execute( + select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending") + ) + pending_count = count_result.scalar() or 0 + + if pending_count == 0: + # Count how many completed today (rough approximation) + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + completed_result = await db.execute( + select(func.count(PrintQueueItem.id)).where( + PrintQueueItem.status.in_(["completed", "failed", "skipped"]), + PrintQueueItem.completed_at >= today_start, + ) + ) + completed_count = completed_result.scalar() or 1 + + await notification_service.on_queue_completed( + completed_count=completed_count, + db=db, + ) + except Exception: + pass # Don't fail if notification fails + # Handle auto_off_after - power off printer if requested (after cooldown) if queue_item.auto_off_after: result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) @@ -2367,6 +2407,27 @@ async def lifespan(app: FastAPI): } await mqtt_relay.configure(mqtt_settings) + # Restore MQTT smart plug subscriptions + if mqtt_settings.get("mqtt_enabled"): + from sqlalchemy import select + + from backend.app.models.smart_plug import SmartPlug + + result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt")) + mqtt_plugs = result.scalars().all() + for plug in mqtt_plugs: + if plug.mqtt_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + topic=plug.mqtt_topic, + power_path=plug.mqtt_power_path, + energy_path=plug.mqtt_energy_path, + state_path=plug.mqtt_state_path, + multiplier=plug.mqtt_multiplier or 1.0, + ) + if mqtt_plugs: + logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions") + # Connect to all active printers async with async_session() as db: await init_printer_connections(db) diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 6e99ea491..01e230827 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -35,6 +35,9 @@ class PrintArchive(Base): bed_temperature: Mapped[int | None] = mapped_column(Integer) nozzle_temperature: Mapped[int | None] = mapped_column(Integer) + # Printer model this file was sliced for (extracted from 3MF metadata) + sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True) + # Print result status: Mapped[str] = mapped_column(String(20), default="completed") started_at: Mapped[datetime | None] = mapped_column(DateTime) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 4f49ed64b..c04b140d7 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -83,6 +83,15 @@ class NotificationProvider(Base): # Event triggers - Build plate detection on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print + # Event triggers - Print queue + on_queue_job_added = Column(Boolean, default=False) # Job added to queue + on_queue_job_assigned = Column(Boolean, default=False) # Model-based job assigned to printer + on_queue_job_started = Column(Boolean, default=False) # Queue job started printing + on_queue_job_waiting = Column(Boolean, default=True) # Job waiting for filament + on_queue_job_skipped = Column(Boolean, default=True) # Job skipped (previous print failed) + on_queue_job_failed = Column(Boolean, default=True) # Job failed to start + on_queue_completed = Column(Boolean, default=False) # All pending jobs finished + # Quiet hours (do not disturb) quiet_hours_enabled = Column(Boolean, default=False) quiet_hours_start = Column(String(5), nullable=True) # HH:MM format, e.g., "22:00" diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index 9dcb9ccbc..fb21b9456 100644 --- a/backend/app/models/notification_template.py +++ b/backend/app/models/notification_template.py @@ -103,4 +103,47 @@ DEFAULT_TEMPLATES = [ "title_template": "Bambuddy Test", "body_template": "This is a test notification. If you see this, notifications are working!", }, + # Queue notifications + { + "event_type": "queue_job_added", + "name": "Queue Job Added", + "title_template": "Job Queued", + "body_template": "{job_name} added to queue for {target}", + }, + { + "event_type": "queue_job_assigned", + "name": "Queue Job Assigned", + "title_template": "Job Assigned", + "body_template": "{job_name} assigned to {printer} (from Any {target_model} queue)", + }, + { + "event_type": "queue_job_started", + "name": "Queue Job Started", + "title_template": "Queue Job Started", + "body_template": "{printer}: {job_name}\nEstimated: {estimated_time}", + }, + { + "event_type": "queue_job_waiting", + "name": "Queue Job Waiting", + "title_template": "Job Waiting for Filament", + "body_template": "{job_name} waiting for {target_model}\n{waiting_reason}", + }, + { + "event_type": "queue_job_skipped", + "name": "Queue Job Skipped", + "title_template": "Job Skipped", + "body_template": "{printer}: {job_name}\nReason: {reason}", + }, + { + "event_type": "queue_job_failed", + "name": "Queue Job Failed", + "title_template": "Job Failed to Start", + "body_template": "{printer}: {job_name}\nReason: {reason}", + }, + { + "event_type": "queue_completed", + "name": "Queue Completed", + "title_template": "Queue Complete", + "body_template": "All {completed_count} queued jobs have finished", + }, ] diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 64ccc4980..53c54cb93 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -15,6 +15,15 @@ class PrintQueueItem(Base): # Links printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True) + # Target printer model for model-based assignment (mutually exclusive with printer_id) + # When set, scheduler assigns to any idle printer of matching model + target_model: Mapped[str | None] = mapped_column(String(50), nullable=True) + # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]') + # Used by scheduler to validate printer has compatible filaments loaded + required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True) + # Waiting reason - explains why a model-based job hasn't started yet + # Set by scheduler when no matching printer is available + waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True) # Either archive_id OR library_file_id must be set (archive created at print start from library file) archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True) library_file_id: Mapped[int | None] = mapped_column( diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index 2f13ebc38..1d9160ef9 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -7,7 +7,7 @@ from backend.app.core.database import Base class SmartPlug(Base): - """Smart plug for printer power control (Tasmota or Home Assistant).""" + """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT).""" __tablename__ = "smart_plugs" @@ -15,7 +15,7 @@ class SmartPlug(Base): name: Mapped[str] = mapped_column(String(100)) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv4/IPv6 (required for Tasmota) - # Plug type: "tasmota" (default) or "homeassistant" + # Plug type: "tasmota" (default), "homeassistant", or "mqtt" plug_type: Mapped[str] = mapped_column(String(20), default="tasmota") # Home Assistant entity ID (e.g., "switch.printer_plug") ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True) @@ -24,6 +24,32 @@ class SmartPlug(Base): ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total + # MQTT plug fields (required when plug_type="mqtt") + # Legacy field - kept for backward compatibility, now use mqtt_power_topic + mqtt_topic: Mapped[str | None] = mapped_column( + String(200), nullable=True + ) # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic) + + # Power monitoring + mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for power data + mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "power_l1" or "data.power" + mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for power + + # Energy monitoring + mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for energy data + mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "energy_l1" + mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for energy + + # State monitoring + mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for state data + mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "state_l1" for ON/OFF + mqtt_state_on_value: Mapped[str | None] = mapped_column( + String(50), nullable=True + ) # What value means "ON" (e.g., "ON", "true", "1") + + # Legacy multiplier - kept for backward compatibility + mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier + # Link to printer (1:1) printer_id: Mapped[int | None] = mapped_column( ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True @@ -57,6 +83,9 @@ class SmartPlug(Base): # Switchbar visibility show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False) + # Printer card visibility (for scripts) + show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True) + # Status tracking last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF" last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index f968fca72..42e005007 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -63,6 +63,8 @@ class ArchiveResponse(BaseModel): bed_temperature: int | None nozzle_temperature: int | None + sliced_for_model: str | None = None # Printer model this file was sliced for + status: str started_at: datetime | None completed_at: datetime | None diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index dacbcc735..4926af0f3 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel): folders_created: int files: list[ZipExtractResult] errors: list[ZipExtractError] + + +# ============ STL Thumbnail Generation ============ + + +class BatchThumbnailRequest(BaseModel): + """Schema for batch STL thumbnail generation request.""" + + file_ids: list[int] | None = None + folder_id: int | None = None + all_missing: bool = False + + +class BatchThumbnailResult(BaseModel): + """Result for a single file thumbnail generation.""" + + file_id: int + filename: str + success: bool + error: str | None = None + + +class BatchThumbnailResponse(BaseModel): + """Schema for batch thumbnail generation response.""" + + processed: int + succeeded: int + failed: int + results: list[BatchThumbnailResult] diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index e0285f464..ca8ff5b41 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -53,6 +53,15 @@ class NotificationProviderBase(BaseModel): # Event triggers - Build plate detection on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print") + # Event triggers - Print queue + on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue") + on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer") + on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing") + on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament") + on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped") + on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start") + on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish") + # Quiet hours quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours") quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format") @@ -120,6 +129,15 @@ class NotificationProviderUpdate(BaseModel): # Event triggers - Build plate detection on_plate_not_empty: bool | None = None + # Event triggers - Print queue + on_queue_job_added: bool | None = None + on_queue_job_assigned: bool | None = None + on_queue_job_started: bool | None = None + on_queue_job_waiting: bool | None = None + on_queue_job_skipped: bool | None = None + on_queue_job_failed: bool | None = None + on_queue_completed: bool | None = None + # Quiet hours quiet_hours_enabled: bool | None = None quiet_hours_start: str | None = None diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py index c484e0586..544a3c57e 100644 --- a/backend/app/schemas/notification_template.py +++ b/backend/app/schemas/notification_template.py @@ -45,6 +45,14 @@ EVENT_VARIABLES: dict[str, list[str]] = { "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"], "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"], "test": ["app_name", "timestamp"], + # Queue notifications + "queue_job_added": ["job_name", "target", "timestamp", "app_name"], + "queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"], + "queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"], + "queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"], + "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"], + "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"], + "queue_completed": ["completed_count", "timestamp", "app_name"], } # Sample data for previewing templates @@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = { "app_name": "Bambuddy", "timestamp": "2024-01-15 14:30", }, + # Queue notifications + "queue_job_added": { + "job_name": "Benchy.3mf", + "target": "Bambu X1C", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "queue_job_assigned": { + "job_name": "Benchy.3mf", + "printer": "Bambu X1C #1", + "target_model": "X1C", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "queue_job_started": { + "printer": "Bambu X1C", + "job_name": "Benchy.3mf", + "estimated_time": "1h 23m", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "queue_job_waiting": { + "job_name": "Benchy.3mf", + "target_model": "X1C", + "waiting_reason": "Printer1 (needs PLA)", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "queue_job_skipped": { + "printer": "Bambu X1C", + "job_name": "Benchy.3mf", + "reason": "Previous print failed", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "queue_job_failed": { + "printer": "Bambu X1C", + "job_name": "Benchy.3mf", + "reason": "Upload failed: connection timeout", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "queue_completed": { + "completed_count": "5", + "timestamp": "2024-01-15 18:30", + "app_name": "Bambuddy", + }, } diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index d5bdbc780..785145126 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -17,6 +17,8 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime) class PrintQueueItemCreate(BaseModel): printer_id: int | None = None # None = unassigned, user assigns later + target_model: str | None = None # Target printer model (mutually exclusive with printer_id) + required_filament_types: list[str] | None = None # Required filament types for model-based assignment # Either archive_id OR library_file_id must be provided archive_id: int | None = None library_file_id: int | None = None @@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel): class PrintQueueItemUpdate(BaseModel): printer_id: int | None = None + target_model: str | None = None # Target printer model (mutually exclusive with printer_id) position: int | None = None scheduled_time: datetime | None = None require_previous_success: bool | None = None @@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel): class PrintQueueItemResponse(BaseModel): id: int printer_id: int | None # None = unassigned + target_model: str | None = None # Target printer model for model-based assignment + required_filament_types: list[str] | None = None # Required filament types for model-based assignment + waiting_reason: str | None = None # Why a model-based job hasn't started yet archive_id: int | None # None if library_file_id is set (archive created at print start) library_file_id: int | None # For queue items from library files position: int diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index d60fac854..6f0960cb2 100644 --- a/backend/app/schemas/smart_plug.py +++ b/backend/app/schemas/smart_plug.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator class SmartPlugBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) - plug_type: Literal["tasmota", "homeassistant"] = "tasmota" + plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota" # Tasmota fields (required when plug_type="tasmota") ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") @@ -14,12 +14,36 @@ class SmartPlugBase(BaseModel): password: str | None = None # Home Assistant fields (required when plug_type="homeassistant") - ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$") + ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$") # Home Assistant energy sensor entities (optional, for separate energy sensors) ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") + # MQTT fields (required when plug_type="mqtt") + # Legacy field - kept for backward compatibility + mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic + + # Power monitoring + mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data + mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power" + mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for power + + # Energy monitoring + mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data + mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1" + mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for energy + + # State monitoring + mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data + mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF + mqtt_state_on_value: str | None = Field( + default=None, max_length=50 + ) # What value means "ON" (e.g., "ON", "true", "1") + + # Legacy multiplier - kept for backward compatibility + mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier + printer_id: int | None = None enabled: bool = True auto_on: bool = True @@ -35,8 +59,9 @@ class SmartPlugBase(BaseModel): schedule_enabled: bool = False schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format - # Switchbar visibility + # Visibility options show_in_switchbar: bool = False + show_on_printer_card: bool = True # For scripts: show on printer card @model_validator(mode="after") def validate_plug_type_fields(self) -> "SmartPlugBase": @@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel): raise ValueError("ip_address is required for Tasmota plugs") if self.plug_type == "homeassistant" and not self.ha_entity_id: raise ValueError("ha_entity_id is required for Home Assistant plugs") + if self.plug_type == "mqtt": + # Determine the effective power topic (new field takes priority, fall back to legacy) + power_topic = self.mqtt_power_topic or self.mqtt_topic + # Path is optional - if not set, raw MQTT payload value will be used + has_power = bool(power_topic) + has_energy = bool(self.mqtt_energy_topic) + has_state = bool(self.mqtt_state_topic) + + # At least one data source must be configured (path is optional) + if not has_power and not has_energy and not has_state: + raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring") return self @@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase): class SmartPlugUpdate(BaseModel): name: str | None = None - plug_type: Literal["tasmota", "homeassistant"] | None = None + plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None ip_address: str | None = None ha_entity_id: str | None = None # Home Assistant energy sensor entities (optional) ha_power_entity: str | None = None ha_energy_today_entity: str | None = None ha_energy_total_entity: str | None = None + # MQTT fields (legacy) + mqtt_topic: str | None = None + mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT power fields + mqtt_power_topic: str | None = None + mqtt_power_path: str | None = None + mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT energy fields + mqtt_energy_topic: str | None = None + mqtt_energy_path: str | None = None + mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT state fields + mqtt_state_topic: str | None = None + mqtt_state_path: str | None = None + mqtt_state_on_value: str | None = None printer_id: int | None = None enabled: bool | None = None auto_on: bool | None = None @@ -77,8 +128,9 @@ class SmartPlugUpdate(BaseModel): schedule_enabled: bool | None = None schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") - # Switchbar visibility + # Visibility options show_in_switchbar: bool | None = None + show_on_printer_card: bool | None = None class SmartPlugResponse(SmartPlugBase): @@ -147,7 +199,7 @@ class HAEntity(BaseModel): entity_id: str friendly_name: str state: str | None = None - domain: str # "switch", "light", "input_boolean" + domain: str # "switch", "light", "input_boolean", "script" class HASensorEntity(BaseModel): diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index ba28004ec..d20906f81 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -67,6 +67,19 @@ class ThreeMFParser: content = zf.read("Metadata/slice_info.config").decode() root = ET.fromstring(content) + # Extract printer_model_id from plate metadata + # Format: + for meta in root.findall(".//metadata"): + key = meta.get("key") + value = meta.get("value") + if key == "printer_model_id" and value: + from backend.app.utils.printer_models import normalize_printer_model_id + + normalized = normalize_printer_model_id(value) + if normalized: + self.metadata["sliced_for_model"] = normalized + break + # Find the plate element (single-plate exports only have one plate) plate = root.find(".//plate") @@ -156,7 +169,7 @@ class ThreeMFParser: pass def _parse_gcode_header(self, zf: zipfile.ZipFile): - """Parse G-code file header for total layer count.""" + """Parse G-code file header for total layer count and printer model.""" import re try: @@ -165,15 +178,25 @@ class ThreeMFParser: if not gcode_files: return - # Read first 2KB of G-code (header contains the layer count) + # Read first 4KB of G-code (header contains metadata) gcode_path = gcode_files[0] with zf.open(gcode_path) as f: - header = f.read(2048).decode("utf-8", errors="ignore") + header = f.read(4096).decode("utf-8", errors="ignore") # Look for "; total layer number: XX" pattern match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE) if match: self.metadata["total_layers"] = int(match.group(1)) + + # Look for printer_model in gcode header (fallback if not found in slice_info) + # Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C" + if "sliced_for_model" not in self.metadata: + match = re.search(r";\s*printer_model\s*=\s*(.+)", header, re.IGNORECASE) + if match: + from backend.app.utils.printer_models import normalize_printer_model + + raw_model = match.group(1).strip() + self.metadata["sliced_for_model"] = normalize_printer_model(raw_model) except Exception: pass @@ -256,6 +279,12 @@ class ThreeMFParser: elif isinstance(val, (int, float, str)): self.metadata["nozzle_temperature"] = int(float(val)) break + + # Printer model (extract and normalize) + if "printer_model" in data: + from backend.app.utils.printer_models import normalize_printer_model + + self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"]) except Exception: pass @@ -877,6 +906,7 @@ class ArchiveService: nozzle_diameter=metadata.get("nozzle_diameter"), bed_temperature=metadata.get("bed_temperature"), nozzle_temperature=metadata.get("nozzle_temperature"), + sliced_for_model=metadata.get("sliced_for_model"), makerworld_url=metadata.get("makerworld_url"), designer=metadata.get("designer"), status=status, diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 38fca794b..45ba70920 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -228,7 +228,7 @@ class HomeAssistantService: - domain: str """ # Default domains for smart plug control - default_domains = {"switch", "light", "input_boolean"} + default_domains = {"switch", "light", "input_boolean", "script"} try: async with httpx.AsyncClient(timeout=self.timeout) as client: diff --git a/backend/app/services/mqtt_relay.py b/backend/app/services/mqtt_relay.py index 7bfa18366..f55657125 100644 --- a/backend/app/services/mqtt_relay.py +++ b/backend/app/services/mqtt_relay.py @@ -34,6 +34,8 @@ class MQTTRelayService: self._broker = "" self._port = 1883 self._last_printer_status: dict[int, float] = {} # printer_id -> last publish timestamp + self._smart_plug_service = None # Lazy import to avoid circular dependency + self._settings: dict = {} # Store settings for smart plug service async def configure(self, settings: dict) -> bool: """Configure MQTT connection from settings. @@ -41,9 +43,12 @@ class MQTTRelayService: Returns True if connection was successful or MQTT is disabled. """ self.enabled = settings.get("mqtt_enabled", False) + self._settings = settings # Store for smart plug service if not self.enabled: await self.disconnect() + # Also configure smart plug service (will disable it) + await self._configure_smart_plug_service(settings) logger.info("MQTT relay disabled") return True @@ -67,7 +72,33 @@ class MQTTRelayService: await self.disconnect() # Create and connect client - return await self._connect(broker, port, username, password, use_tls) + result = await self._connect(broker, port, username, password, use_tls) + + # Configure smart plug service with same settings + await self._configure_smart_plug_service(settings) + + return result + + async def _configure_smart_plug_service(self, settings: dict): + """Configure the MQTT smart plug service with the same broker settings.""" + try: + if self._smart_plug_service is None: + from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service + + self._smart_plug_service = mqtt_smart_plug_service + + await self._smart_plug_service.configure(settings) + except Exception as e: + logger.error(f"Failed to configure MQTT smart plug service: {e}") + + @property + def smart_plug_service(self): + """Get the MQTT smart plug service instance.""" + if self._smart_plug_service is None: + from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service + + self._smart_plug_service = mqtt_smart_plug_service + return self._smart_plug_service async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool: """Establish MQTT connection.""" diff --git a/backend/app/services/mqtt_smart_plug.py b/backend/app/services/mqtt_smart_plug.py new file mode 100644 index 000000000..2ed44ce33 --- /dev/null +++ b/backend/app/services/mqtt_smart_plug.py @@ -0,0 +1,488 @@ +"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data. + +This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices. +""" + +import json +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +import paho.mqtt.client as mqtt + +logger = logging.getLogger(__name__) + + +@dataclass +class SmartPlugMQTTData: + """Latest data received from an MQTT smart plug.""" + + plug_id: int + power: float | None = None # Current power in watts + energy: float | None = None # Energy in kWh (today) + state: str | None = None # "ON" or "OFF" + last_seen: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class MQTTDataSourceConfig: + """Configuration for a single MQTT data source (power, energy, or state).""" + + topic: str + path: str + multiplier: float = 1.0 # For power/energy + on_value: str | None = None # For state (what value means "ON") + + +class MQTTSmartPlugService: + """Subscribes to MQTT topics for smart plug energy monitoring.""" + + # Consider plug unreachable if no message received in this time + REACHABLE_TIMEOUT_MINUTES = 5 + + def __init__(self): + self.client: mqtt.Client | None = None + self.connected = False + self._lock = threading.Lock() + # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state" + self.subscriptions: dict[str, list[tuple[int, str]]] = {} + # plug_id -> {data_type: MQTTDataSourceConfig} + self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {} + # plug_id -> latest data + self.plug_data: dict[int, SmartPlugMQTTData] = {} + self._configured = False + self._broker = "" + self._port = 1883 + self._username = "" + self._password = "" + self._use_tls = False + + def is_configured(self) -> bool: + """Check if the MQTT service is configured and connected.""" + return self._configured and self.connected + + def has_broker_settings(self) -> bool: + """Check if broker settings are available (even if not connected yet).""" + return bool(self._broker) + + async def configure(self, settings: dict) -> bool: + """Configure MQTT connection from settings. + + Uses the same broker settings as the MQTT relay service. + Returns True if connection was successful or MQTT is disabled. + """ + enabled = settings.get("mqtt_enabled", False) + + if not enabled: + await self.disconnect() + self._configured = False + logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)") + return True + + broker = settings.get("mqtt_broker", "") + port = settings.get("mqtt_port", 1883) + username = settings.get("mqtt_username", "") + password = settings.get("mqtt_password", "") + use_tls = settings.get("mqtt_use_tls", False) + + if not broker: + logger.warning("MQTT smart plug service: no broker configured") + self._configured = False + return False + + # Check if settings changed + settings_changed = ( + self._broker != broker + or self._port != port + or self._username != username + or self._password != password + or self._use_tls != use_tls + ) + + self._broker = broker + self._port = port + self._username = username + self._password = password + self._use_tls = use_tls + self._configured = True + + # Disconnect and reconnect if settings changed + if settings_changed and self.client: + await self.disconnect() + + # Connect if not already connected + if not self.client or not self.connected: + return await self._connect() + + return True + + async def _connect(self) -> bool: + """Establish MQTT connection.""" + import asyncio + import ssl + + try: + # Create client with callback API version 2 + self.client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"bambuddy-smartplug-{id(self)}", + protocol=mqtt.MQTTv311, + ) + + # Set up callbacks + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self.client.on_message = self._on_message + + # Configure authentication + if self._username: + self.client.username_pw_set(self._username, self._password) + + # Configure TLS + if self._use_tls: + self.client.tls_set(cert_reqs=ssl.CERT_NONE) + self.client.tls_insecure_set(True) + + # Connect with timeout + try: + await asyncio.wait_for( + asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60), + timeout=3.0, + ) + except TimeoutError: + logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out") + return False + + self.client.loop_start() + + # Wait briefly for connection + await asyncio.sleep(1.0) + + if self.connected: + logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}") + # Resubscribe to all topics + self._resubscribe_all() + return True + else: + logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}") + return True # Connection is async + + except Exception as e: + logger.error(f"MQTT smart plug connection failed: {e}") + self.connected = False + return False + + def _on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: dict, + reason_code: int | mqtt.ReasonCode, + properties: mqtt.Properties | None = None, + ): + """Callback when connected to broker.""" + rc = reason_code if isinstance(reason_code, int) else reason_code.value + if rc == 0: + self.connected = True + logger.info("MQTT smart plug service connected successfully") + # Resubscribe to all topics + self._resubscribe_all() + else: + self.connected = False + logger.error(f"MQTT smart plug connection failed: {reason_code}") + + def _on_disconnect( + self, + client: mqtt.Client, + userdata: Any, + flags_or_rc: dict | int | mqtt.ReasonCode, + reason_code: int | mqtt.ReasonCode | None = None, + properties: mqtt.Properties | None = None, + ): + """Callback when disconnected from broker.""" + self.connected = False + rc = reason_code if reason_code is not None else flags_or_rc + rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0) + if rc_val != 0: + logger.warning(f"MQTT smart plug service disconnected: {rc}") + else: + logger.info("MQTT smart plug service disconnected cleanly") + + def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage): + """Handle incoming MQTT message, extract data using JSON path.""" + topic = msg.topic + + with self._lock: + subscriptions = self.subscriptions.get(topic, []) + if not subscriptions: + return + + # Parse JSON payload (or treat as raw value) + try: + payload = json.loads(msg.payload.decode("utf-8")) + is_json = True + except (json.JSONDecodeError, UnicodeDecodeError): + # Not JSON - treat the whole payload as a raw value + payload = msg.payload.decode("utf-8").strip() + is_json = False + + # Process for each subscribed (plug_id, data_type) + for plug_id, data_type in subscriptions: + configs = self.plug_configs.get(plug_id, {}) + config = configs.get(data_type) + if not config: + continue + + # Extract value using path (or use raw payload if no path) + if is_json and config.path: + raw_value = self._extract_json_path(payload, config.path) + elif is_json and not config.path: + # JSON but no path - if it's a simple value use it, otherwise skip + if isinstance(payload, (int, float, str, bool)): + raw_value = payload + else: + # Can't use a dict/list as a value + logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured") + continue + else: + # Raw value (non-JSON) + raw_value = payload + + if raw_value is None: + continue + + # Initialize plug data if needed + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + data = self.plug_data[plug_id] + data.last_seen = datetime.utcnow() + + # Process based on data type + if data_type == "power": + try: + data.power = float(raw_value) * config.multiplier + logger.debug(f"MQTT smart plug {plug_id}: power={data.power}") + except (ValueError, TypeError): + pass + + elif data_type == "energy": + try: + data.energy = float(raw_value) * config.multiplier + logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}") + except (ValueError, TypeError): + pass + + elif data_type == "state": + state_str = str(raw_value) + # Check against configured ON value if set + if config.on_value: + # Case-insensitive comparison + if state_str.lower() == config.on_value.lower(): + data.state = "ON" + else: + data.state = "OFF" + else: + # Default behavior: normalize common values + upper_state = state_str.upper() + if upper_state in ("ON", "1", "TRUE"): + data.state = "ON" + elif upper_state in ("OFF", "0", "FALSE"): + data.state = "OFF" + else: + data.state = state_str + logger.debug(f"MQTT smart plug {plug_id}: state={data.state}") + + def _extract_json_path(self, data: dict, path: str) -> Any: + """Extract value using dot notation (e.g., 'power_l1' or 'data.power'). + + Supports simple dot notation for nested objects. + """ + if not path: + return None + + parts = path.split(".") + current = data + + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + + return current + + def _resubscribe_all(self): + """Resubscribe to all registered topics after reconnection.""" + if not self.client or not self.connected: + return + + with self._lock: + for topic in self.subscriptions: + if self.subscriptions[topic]: # Only if there are subscribers + try: + self.client.subscribe(topic, qos=1) + logger.debug(f"MQTT smart plug: resubscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}") + + def subscribe( + self, + plug_id: int, + # Power source + power_topic: str | None = None, + power_path: str | None = None, + power_multiplier: float = 1.0, + # Energy source + energy_topic: str | None = None, + energy_path: str | None = None, + energy_multiplier: float = 1.0, + # State source + state_topic: str | None = None, + state_path: str | None = None, + state_on_value: str | None = None, + # Legacy: single topic/path/multiplier (for backward compatibility) + topic: str | None = None, + multiplier: float = 1.0, + ): + """Subscribe to MQTT topics for a plug. + + Each data type (power, energy, state) can have its own topic. + For backward compatibility, if power_topic is not set but topic is, + topic will be used for all data types that have paths configured. + """ + with self._lock: + # Initialize config for this plug + self.plug_configs[plug_id] = {} + + # Determine topics (new fields take priority, fall back to legacy) + effective_power_topic = power_topic or topic + effective_energy_topic = energy_topic or topic + effective_state_topic = state_topic or topic + + # Use new multipliers or fall back to legacy + effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier + effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier + + # Configure power subscription (path is optional - empty means use raw payload) + if effective_power_topic: + config = MQTTDataSourceConfig( + topic=effective_power_topic, + path=power_path or "", + multiplier=effective_power_mult, + ) + self.plug_configs[plug_id]["power"] = config + self._add_subscription(plug_id, effective_power_topic, "power") + + # Configure energy subscription (path is optional - empty means use raw payload) + if effective_energy_topic: + config = MQTTDataSourceConfig( + topic=effective_energy_topic, + path=energy_path or "", + multiplier=effective_energy_mult, + ) + self.plug_configs[plug_id]["energy"] = config + self._add_subscription(plug_id, effective_energy_topic, "energy") + + # Configure state subscription (path is optional - empty means use raw payload) + if effective_state_topic: + config = MQTTDataSourceConfig( + topic=effective_state_topic, + path=state_path or "", + on_value=state_on_value, + ) + self.plug_configs[plug_id]["state"] = config + self._add_subscription(plug_id, effective_state_topic, "state") + + # Initialize data entry + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + logger.info( + f"MQTT smart plug {plug_id}: configured with " + f"power={effective_power_topic if power_path else None}, " + f"energy={effective_energy_topic if energy_path else None}, " + f"state={effective_state_topic if state_path else None}" + ) + + def _add_subscription(self, plug_id: int, topic: str, data_type: str): + """Add a subscription for a plug/data_type to a topic.""" + if topic not in self.subscriptions: + self.subscriptions[topic] = [] + # Actually subscribe if connected + if self.client and self.connected: + try: + self.client.subscribe(topic, qos=1) + logger.info(f"MQTT smart plug: subscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}") + + entry = (plug_id, data_type) + if entry not in self.subscriptions[topic]: + self.subscriptions[topic].append(entry) + + def unsubscribe(self, plug_id: int): + """Unsubscribe when plug is deleted/updated.""" + with self._lock: + # Get all configs for this plug + configs = self.plug_configs.pop(plug_id, {}) + if not configs: + # Still clean up any stray subscriptions + pass + + # Collect all topics this plug was subscribed to + topics_to_check = set() + for _data_type, config in configs.items(): + topics_to_check.add(config.topic) + + # Also scan subscriptions to remove any entries for this plug + for topic in list(self.subscriptions.keys()): + # Remove all entries for this plug_id + self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id] + topics_to_check.add(topic) + + # Unsubscribe from topics with no more subscribers + for topic in topics_to_check: + if topic in self.subscriptions and not self.subscriptions[topic]: + del self.subscriptions[topic] + if self.client and self.connected: + try: + self.client.unsubscribe(topic) + logger.info(f"MQTT smart plug: unsubscribed from {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}") + + # Remove data + self.plug_data.pop(plug_id, None) + + def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None: + """Get latest data for a plug (called by status endpoint).""" + with self._lock: + return self.plug_data.get(plug_id) + + def is_reachable(self, plug_id: int) -> bool: + """Check if a plug has received data recently.""" + data = self.get_plug_data(plug_id) + if not data: + return False + + timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES) + return datetime.utcnow() - data.last_seen < timeout + + async def disconnect(self): + """Disconnect from MQTT broker.""" + if self.client: + try: + self.client.loop_stop() + self.client.disconnect() + except Exception as e: + logger.debug(f"MQTT smart plug disconnect error (ignored): {e}") + finally: + self.client = None + self.connected = False + + +# Global instance +mqtt_smart_plug_service = MQTTSmartPlugService() diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 2c666ff02..92a51cd14 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -945,6 +945,156 @@ class NotificationService: """Clear the template cache. Call this when templates are updated.""" self._template_cache.clear() + # ==================== Queue Notifications ==================== + + async def on_queue_job_added( + self, + job_name: str, + target: str, + db: AsyncSession, + printer_id: int | None = None, + printer_name: str | None = None, + ): + """Handle queue job added event.""" + providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id) + if not providers: + return + + variables = { + "job_name": job_name, + "target": target, # e.g., "Printer1" or "Any X1C" + "printer": printer_name or target, + } + + title, message = await self._build_message_from_template(db, "queue_job_added", variables) + await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name) + + async def on_queue_job_assigned( + self, + job_name: str, + printer_id: int, + printer_name: str, + target_model: str, + db: AsyncSession, + ): + """Handle model-based job assigned to printer event.""" + providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id) + if not providers: + return + + variables = { + "job_name": job_name, + "printer": printer_name, + "target_model": target_model, + } + + title, message = await self._build_message_from_template(db, "queue_job_assigned", variables) + await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name) + + async def on_queue_job_started( + self, + job_name: str, + printer_id: int, + printer_name: str, + db: AsyncSession, + estimated_time: int | None = None, + ): + """Handle queue job started printing event.""" + providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id) + if not providers: + return + + variables = { + "job_name": job_name, + "printer": printer_name, + "estimated_time": self._format_duration(estimated_time), + } + + title, message = await self._build_message_from_template(db, "queue_job_started", variables) + await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name) + + async def on_queue_job_waiting( + self, + job_name: str, + target_model: str, + waiting_reason: str, + db: AsyncSession, + ): + """Handle job waiting for filament event.""" + providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None) + if not providers: + return + + variables = { + "job_name": job_name, + "target_model": target_model, + "waiting_reason": waiting_reason, + } + + title, message = await self._build_message_from_template(db, "queue_job_waiting", variables) + await self._send_to_providers(providers, title, message, db, "queue_job_waiting") + + async def on_queue_job_skipped( + self, + job_name: str, + printer_id: int, + printer_name: str, + reason: str, + db: AsyncSession, + ): + """Handle job skipped event (e.g., previous print failed).""" + providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id) + if not providers: + return + + variables = { + "job_name": job_name, + "printer": printer_name, + "reason": reason, + } + + title, message = await self._build_message_from_template(db, "queue_job_skipped", variables) + await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name) + + async def on_queue_job_failed( + self, + job_name: str, + printer_id: int | None, + printer_name: str | None, + reason: str, + db: AsyncSession, + ): + """Handle job failed to start event (upload error, etc.).""" + providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id) + if not providers: + return + + variables = { + "job_name": job_name, + "printer": printer_name or "Unknown", + "reason": reason, + } + + title, message = await self._build_message_from_template(db, "queue_job_failed", variables) + await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name) + + async def on_queue_completed( + self, + completed_count: int, + db: AsyncSession, + ): + """Handle all queue jobs completed event.""" + providers = await self._get_providers_for_event(db, "on_queue_completed", None) + if not providers: + return + + variables = { + "completed_count": str(completed_count), + } + + title, message = await self._build_message_from_template(db, "queue_completed", variables) + await self._send_to_providers(providers, title, message, db, "queue_completed") + async def _queue_for_digest( self, provider: NotificationProvider, diff --git a/backend/app/services/plate_detection.py b/backend/app/services/plate_detection.py index cd231e38f..b3fb81d02 100644 --- a/backend/app/services/plate_detection.py +++ b/backend/app/services/plate_detection.py @@ -5,6 +5,8 @@ Uses calibration-based difference detection - compares current frame to a reference image of the empty plate. """ +from __future__ import annotations + import logging from pathlib import Path diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 603499d79..d96d09883 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -4,7 +4,7 @@ import asyncio import logging from datetime import datetime -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.config import settings @@ -15,8 +15,10 @@ from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer from backend.app.models.smart_plug import SmartPlug from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry +from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import printer_manager -from backend.app.services.tasmota import tasmota_service +from backend.app.services.smart_plug_manager import smart_plug_manager +from backend.app.utils.printer_models import normalize_printer_model logger = logging.getLogger(__name__) @@ -62,13 +64,10 @@ class PrintScheduler: if not items: return - # Group by printer - only process first item per printer - processed_printers = set() + # Track busy printers to avoid assigning multiple items to same printer + busy_printers: set[int] = set() for item in items: - if item.printer_id in processed_printers: - continue - # Check scheduled time first (scheduled_time is stored in UTC from ISO string) if item.scheduled_time and item.scheduled_time > datetime.utcnow(): continue @@ -77,46 +76,254 @@ class PrintScheduler: if item.manual_start: continue - # Check if printer is idle - printer_idle = self._is_printer_idle(item.printer_id) - printer_connected = printer_manager.is_connected(item.printer_id) - - # If printer not connected, try to power on via smart plug - if not printer_connected: - plug = await self._get_smart_plug(db, item.printer_id) - if plug and plug.auto_on and plug.enabled: - logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug") - powered_on = await self._power_on_and_wait(plug, item.printer_id, db) - if powered_on: - printer_connected = True - printer_idle = self._is_printer_idle(item.printer_id) - else: - logger.warning(f"Could not power on printer {item.printer_id} via smart plug") - processed_printers.add(item.printer_id) - continue - else: - # No plug or auto_on disabled - processed_printers.add(item.printer_id) + if item.printer_id: + # Specific printer assignment (existing behavior) + if item.printer_id in busy_printers: continue - # Check if printer is idle (busy with another print) - if not printer_idle: - processed_printers.add(item.printer_id) + # Check if printer is idle + printer_idle = self._is_printer_idle(item.printer_id) + printer_connected = printer_manager.is_connected(item.printer_id) + + # If printer not connected, try to power on via smart plug + if not printer_connected: + plug = await self._get_smart_plug(db, item.printer_id) + if plug and plug.auto_on and plug.enabled: + logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug") + powered_on = await self._power_on_and_wait(plug, item.printer_id, db) + if powered_on: + printer_connected = True + printer_idle = self._is_printer_idle(item.printer_id) + else: + logger.warning(f"Could not power on printer {item.printer_id} via smart plug") + busy_printers.add(item.printer_id) + continue + else: + # No plug or auto_on disabled + busy_printers.add(item.printer_id) + continue + + # Check if printer is idle (busy with another print) + if not printer_idle: + busy_printers.add(item.printer_id) + continue + + # Check condition (previous print success) + if item.require_previous_success: + if not await self._check_previous_success(db, item): + item.status = "skipped" + item.error_message = "Previous print failed or was aborted" + item.completed_at = datetime.now() + await db.commit() + logger.info(f"Skipped queue item {item.id} - previous print failed") + + # Send notification + job_name = await self._get_job_name(db, item) + printer = await self._get_printer(db, item.printer_id) + await notification_service.on_queue_job_skipped( + job_name=job_name, + printer_id=item.printer_id, + printer_name=printer.name if printer else "Unknown", + reason="Previous print failed or was aborted", + db=db, + ) + continue + + # Start the print + await self._start_print(db, item) + busy_printers.add(item.printer_id) + + elif item.target_model: + # Model-based assignment - find any idle printer of matching model + # Parse required filament types if present + required_types = None + if item.required_filament_types: + try: + import json + + required_types = json.loads(item.required_filament_types) + except json.JSONDecodeError: + pass + + printer_id, waiting_reason = await self._find_idle_printer_for_model( + db, item.target_model, busy_printers, required_types + ) + + # Update waiting_reason if changed and send notification when first waiting + if item.waiting_reason != waiting_reason: + was_waiting = item.waiting_reason is not None + item.waiting_reason = waiting_reason + await db.commit() + + # Send waiting notification only when transitioning to waiting state + if waiting_reason and not was_waiting: + job_name = await self._get_job_name(db, item) + await notification_service.on_queue_job_waiting( + job_name=job_name, + target_model=item.target_model, + waiting_reason=waiting_reason, + db=db, + ) + + if printer_id: + # Check condition (previous print success) before assigning + if item.require_previous_success: + if not await self._check_previous_success(db, item): + item.status = "skipped" + item.error_message = "Previous print failed or was aborted" + item.completed_at = datetime.now() + await db.commit() + logger.info(f"Skipped queue item {item.id} - previous print failed") + + # Send notification + job_name = await self._get_job_name(db, item) + printer = await self._get_printer(db, printer_id) + await notification_service.on_queue_job_skipped( + job_name=job_name, + printer_id=printer_id, + printer_name=printer.name if printer else "Unknown", + reason="Previous print failed or was aborted", + db=db, + ) + continue + + # Assign printer and start - clear waiting reason + item.printer_id = printer_id + item.waiting_reason = None + logger.info(f"Model-based assignment: queue item {item.id} assigned to printer {printer_id}") + + # Send assignment notification + job_name = await self._get_job_name(db, item) + printer = await self._get_printer(db, printer_id) + await notification_service.on_queue_job_assigned( + job_name=job_name, + printer_id=printer_id, + printer_name=printer.name if printer else "Unknown", + target_model=item.target_model, + db=db, + ) + + await self._start_print(db, item) + busy_printers.add(printer_id) + + async def _find_idle_printer_for_model( + self, + db: AsyncSession, + model: str, + exclude_ids: set[int], + required_filament_types: list[str] | None = None, + ) -> tuple[int | None, str | None]: + """Find an idle, connected printer matching the model with compatible filaments. + + Args: + db: Database session + model: Printer model to match (e.g., "X1C", "P1S") + exclude_ids: Printer IDs to exclude (already busy) + required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"]) + If provided, only printers with all required types loaded will match. + + Returns: + Tuple of (printer_id, waiting_reason): + - (printer_id, None) if a matching printer was found + - (None, reason) if no printer is available, with explanation + """ + # Normalize model name and use case-insensitive matching + normalized_model = normalize_printer_model(model) or model + result = await db.execute( + select(Printer) + .where(func.lower(Printer.model) == normalized_model.lower()) + .where(Printer.is_active == True) # noqa: E712 + ) + printers = list(result.scalars().all()) + + if not printers: + return None, f"No active {normalized_model} printers configured" + + # Track reasons for skipping printers + printers_busy = [] + printers_offline = [] + printers_missing_filament = [] + + for printer in printers: + if printer.id in exclude_ids: + printers_busy.append(printer.name) + continue + + is_connected = printer_manager.is_connected(printer.id) + is_idle = self._is_printer_idle(printer.id) if is_connected else False + + if not is_connected: + printers_offline.append(printer.name) + continue + + if not is_idle: + printers_busy.append(printer.name) + continue + + # Validate filament compatibility if required types are specified + if required_filament_types: + missing = self._get_missing_filament_types(printer.id, required_filament_types) + if missing: + printers_missing_filament.append((printer.name, missing)) + logger.debug(f"Skipping printer {printer.id} ({printer.name}) - missing filaments: {missing}") continue - # Check condition (previous print success) - if item.require_previous_success: - if not await self._check_previous_success(db, item): - item.status = "skipped" - item.error_message = "Previous print failed or was aborted" - item.completed_at = datetime.now() - await db.commit() - logger.info(f"Skipped queue item {item.id} - previous print failed") - continue + # Found a matching printer - clear waiting reason + return printer.id, None - # Start the print - await self._start_print(db, item) - processed_printers.add(item.printer_id) + # Build waiting reason from what we found + reasons = [] + if printers_missing_filament: + # Filament mismatch is most actionable - show first + names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament] + reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}") + if printers_busy: + reasons.append(f"Busy: {', '.join(printers_busy)}") + if printers_offline: + reasons.append(f"Offline: {', '.join(printers_offline)}") + + return None, " | ".join(reasons) if reasons else f"No available {model} printers" + + def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]: + """Get the list of required filament types that are not loaded on the printer. + + Args: + printer_id: The printer ID + required_types: List of filament types needed (e.g., ["PLA", "PETG"]) + + Returns: + List of missing filament types (empty if all are loaded) + """ + status = printer_manager.get_status(printer_id) + if not status: + return required_types # Can't determine, assume all missing + + # Collect all filament types loaded on this printer (AMS units + external spool) + loaded_types: set[str] = set() + + # Check AMS units (stored in raw_data["ams"]) + ams_data = status.raw_data.get("ams", []) + if ams_data: + for ams_unit in ams_data: + for tray in ams_unit.get("tray", []): + tray_type = tray.get("tray_type") + if tray_type: + loaded_types.add(tray_type.upper()) + + # Check external spool (virtual tray, stored in raw_data["vt_tray"]) + vt_tray = status.raw_data.get("vt_tray") + if vt_tray: + vt_type = vt_tray.get("tray_type") + if vt_type: + loaded_types.add(vt_type.upper()) + + # Find which required types are missing (case-insensitive comparison) + missing = [] + for req_type in required_types: + if req_type.upper() not in loaded_types: + missing.append(req_type) + + return missing def _is_printer_idle(self, printer_id: int) -> bool: """Check if a printer is connected and idle.""" @@ -141,15 +348,18 @@ class PrintScheduler: Returns True if printer connected successfully within timeout. """ + # Get the appropriate service for the plug type (Tasmota or Home Assistant) + service = await smart_plug_manager.get_service_for_plug(plug, db) + # Check current plug state - status = await tasmota_service.get_status(plug) + status = await service.get_status(plug) if not status.get("reachable"): logger.warning(f"Smart plug '{plug.name}' is not reachable") return False # Turn on if not already on if status.get("state") != "ON": - success = await tasmota_service.turn_on(plug) + success = await service.turn_on(plug) if not success: logger.warning(f"Failed to turn on smart plug '{plug.name}'") return False @@ -218,7 +428,27 @@ class PrintScheduler: # Wait for cooldown (up to 10 minutes) await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600) logger.info(f"Auto-off: Powering off printer {item.printer_id}") - await tasmota_service.turn_off(plug) + service = await smart_plug_manager.get_service_for_plug(plug, db) + await service.turn_off(plug) + + async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str: + """Get a human-readable name for a queue item.""" + if item.archive_id: + result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id)) + archive = result.scalar_one_or_none() + if archive: + return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "") + if item.library_file_id: + result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id)) + library_file = result.scalar_one_or_none() + if library_file: + return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "") + return f"Job #{item.id}" + + async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None: + """Get printer by ID.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + return result.scalar_one_or_none() async def _start_print(self, db: AsyncSession, item: PrintQueueItem): """Upload file and start print for a queue item. @@ -371,6 +601,16 @@ class PrintScheduler: item.completed_at = datetime.utcnow() await db.commit() logger.error(f"Queue item {item.id}: FTP upload failed") + + # Send failure notification + await notification_service.on_queue_job_failed( + job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""), + printer_id=printer.id, + printer_name=printer.name, + reason="Failed to upload file to printer", + db=db, + ) + await self._power_off_if_needed(db, item) return @@ -411,6 +651,22 @@ class PrintScheduler: await db.commit() logger.info(f"Queue item {item.id}: Print started - {filename}") + # Get estimated time for notification + estimated_time = None + if archive and archive.print_time_seconds: + estimated_time = archive.print_time_seconds + elif library_file and library_file.print_time_seconds: + estimated_time = library_file.print_time_seconds + + # Send job started notification + await notification_service.on_queue_job_started( + job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""), + printer_id=printer.id, + printer_name=printer.name, + db=db, + estimated_time=estimated_time, + ) + # MQTT relay - publish queue job started try: from backend.app.services.mqtt_relay import mqtt_relay @@ -430,6 +686,16 @@ class PrintScheduler: item.completed_at = datetime.utcnow() await db.commit() logger.error(f"Queue item {item.id}: Failed to start print") + + # Send failure notification + await notification_service.on_queue_job_failed( + job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""), + printer_id=printer.id, + printer_name=printer.name, + reason="Failed to send print command", + db=db, + ) + await self._power_off_if_needed(db, item) diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 39366d7da..e788068a9 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -27,7 +27,7 @@ class SmartPlugManager: self._scheduler_task: asyncio.Task | None = None self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed - async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None): + async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None): """Get the appropriate service for the plug type. For HA plugs, configures the service with current settings from DB. @@ -110,7 +110,7 @@ class SmartPlugManager: plugs = result.scalars().all() for plug in plugs: - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) # Check if we should turn on if plug.schedule_on_time == current_time: @@ -166,7 +166,7 @@ class SmartPlugManager: # Turn on the plug logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'") - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) success = await service.turn_on(plug) if success: @@ -195,6 +195,11 @@ class SmartPlugManager: logger.debug(f"Smart plug '{plug.name}' auto_off is disabled") return + # Skip auto-off for HA script entities (scripts can only be triggered, not turned off) + if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."): + logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off") + return + # Only auto-off on successful completion, not on failures # This allows the user to investigate errors before power-off if status != "completed": @@ -261,7 +266,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" plug_info = PlugInfo() - service = await self._get_service_for_plug(plug_info) + service = await self.get_service_for_plug(plug_info) success = await service.turn_off(plug_info) logger.info(f"Turned off plug {plug_id} after time delay") @@ -353,7 +358,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" plug_info = PlugInfo() - service = await self._get_service_for_plug(plug_info) + service = await self.get_service_for_plug(plug_info) success = await service.turn_off(plug_info) logger.info( f"Turned off plug {plug_id} after nozzle temp dropped to " @@ -474,7 +479,7 @@ class SmartPlugManager: # For time mode, just turn off immediately since delay already passed logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now") - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) success = await service.turn_off(plug) if success: await self._mark_auto_off_executed(plug.id) diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 3be407df1..bf95ae8ca 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -541,10 +541,15 @@ class SpoolmanClient: # Need valid color to create filament tray_color = tray_data.get("tray_color", "") - if not tray_color or tray_color in ("", "00000000"): - logger.debug(f"Skipping tray with invalid color: {tray_color}") + if not tray_color or tray_color.strip() == "": + logger.debug("Skipping tray with empty color") return None + # Handle transparent/natural filament (RRGGBBAA with alpha=00) + # Replace with cream color that represents how natural PLA actually looks + if tray_color == "00000000": + tray_color = "F5E6D3FF" # Light cream/natural color + # Get sub_brands, falling back to tray_type tray_sub_brands = tray_data.get("tray_sub_brands", "") if not tray_sub_brands or tray_sub_brands.strip() == "": diff --git a/backend/app/services/stl_thumbnail.py b/backend/app/services/stl_thumbnail.py new file mode 100644 index 000000000..83b2a12a5 --- /dev/null +++ b/backend/app/services/stl_thumbnail.py @@ -0,0 +1,140 @@ +"""STL Thumbnail Generation Service. + +Generates thumbnail images from STL files using trimesh and matplotlib. +""" + +import logging +import uuid +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Bambu green color for rendering +BAMBU_GREEN = "#00AE42" +BACKGROUND_COLOR = "#1a1a1a" + +# Maximum vertices before simplification +MAX_VERTICES = 100000 + + +def generate_stl_thumbnail( + stl_path: Path, + thumbnails_dir: Path, + size: int = 256, +) -> str | None: + """Generate a thumbnail image from an STL file. + + Args: + stl_path: Path to the STL file + thumbnails_dir: Directory to save the thumbnail + size: Thumbnail size in pixels (default 256x256) + + Returns: + Path to the generated thumbnail, or None on failure + """ + try: + import matplotlib + import trimesh + + # Use Agg backend for headless rendering + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d import Axes3D # noqa: F401 + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + # Load the STL file + mesh = trimesh.load(str(stl_path), force="mesh") + + if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0: + logger.warning(f"Failed to load STL or empty mesh: {stl_path}") + return None + + # Simplify large meshes for performance + if len(mesh.vertices) > MAX_VERTICES: + logger.info(f"Simplifying mesh from {len(mesh.vertices)} vertices") + try: + # Calculate reduction ratio (0-1 range) + # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20% + keep_ratio = MAX_VERTICES / len(mesh.vertices) + target_reduction = 1.0 - keep_ratio + # Clamp to valid range (0.01 to 0.99) + target_reduction = max(0.01, min(0.99, target_reduction)) + mesh = mesh.simplify_quadric_decimation(target_reduction) + logger.info(f"Simplified mesh to {len(mesh.vertices)} vertices") + except Exception as e: + logger.warning(f"Mesh simplification failed, using original: {e}") + + # Get mesh bounds and center it + vertices = mesh.vertices + bounds_min = vertices.min(axis=0) + bounds_max = vertices.max(axis=0) + center = (bounds_min + bounds_max) / 2 + vertices_centered = vertices - center + + # Scale to fit in view + max_extent = (bounds_max - bounds_min).max() + if max_extent > 0: + scale = 1.0 / max_extent + vertices_scaled = vertices_centered * scale + else: + vertices_scaled = vertices_centered + + # Create figure with dark background + fig = plt.figure(figsize=(size / 100, size / 100), dpi=100) + fig.patch.set_facecolor(BACKGROUND_COLOR) + + ax = fig.add_subplot(111, projection="3d") + ax.set_facecolor(BACKGROUND_COLOR) + + # Create polygon collection from mesh faces + faces = mesh.faces + poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces] + + collection = Poly3DCollection( + poly3d, + facecolors=BAMBU_GREEN, + edgecolors=BAMBU_GREEN, + linewidths=0.1, + alpha=0.9, + ) + ax.add_collection3d(collection) + + # Set axis limits + ax.set_xlim(-0.6, 0.6) + ax.set_ylim(-0.6, 0.6) + ax.set_zlim(-0.6, 0.6) + + # Set view angle (isometric-ish) + ax.view_init(elev=25, azim=45) + + # Remove axes and grid + ax.set_axis_off() + ax.grid(False) + + # Remove margins + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + + # Save thumbnail + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + + fig.savefig( + thumb_path, + format="png", + facecolor=BACKGROUND_COLOR, + edgecolor="none", + bbox_inches="tight", + pad_inches=0.05, + dpi=100, + ) + plt.close(fig) + + logger.info(f"Generated STL thumbnail: {thumb_path}") + return str(thumb_path) + + except ImportError as e: + logger.warning(f"STL thumbnail generation unavailable (missing dependencies): {e}") + return None + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail for {stl_path}: {e}") + return None diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py new file mode 100644 index 000000000..f412bf474 --- /dev/null +++ b/backend/app/utils/printer_models.py @@ -0,0 +1,86 @@ +"""Printer model normalization utilities. + +Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to +normalized short names (e.g., "X1C") that match database storage. +""" + +# Map from 3MF printer_model strings to normalized short names +PRINTER_MODEL_MAP = { + "Bambu Lab X1 Carbon": "X1C", + "Bambu Lab X1": "X1", + "Bambu Lab X1E": "X1E", + "Bambu Lab P1S": "P1S", + "Bambu Lab P1P": "P1P", + "Bambu Lab P2S": "P2S", + "Bambu Lab A1": "A1", + "Bambu Lab A1 Mini": "A1 Mini", + "Bambu Lab A1 mini": "A1 Mini", + "Bambu Lab H2D": "H2D", + "Bambu Lab H2D Pro": "H2D Pro", +} + +# Map from printer_model_id (internal codes in slice_info.config) to short names +# These are the codes Bambu Studio uses internally +PRINTER_MODEL_ID_MAP = { + # X1 series + "C11": "X1C", + "C12": "X1", + "C13": "X1E", + # P1 series + "P1P": "P1P", + "P1S": "P1S", + # P2 series + "P2S": "P2S", + # A1 series + "A11": "A1", + "A12": "A1 Mini", + "N1": "A1", + "N2S": "A1 Mini", + "A04": "A1 Mini", + # H2D series (Office/H series) + "O1D": "H2D", + "O2D": "H2D Pro", +} + + +def normalize_printer_model_id(model_id: str | None) -> str | None: + """Convert printer_model_id (internal code) to normalized short name. + + Args: + model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D") + + Returns: + Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown. + """ + if not model_id: + return None + + # Check known mappings + if model_id in PRINTER_MODEL_ID_MAP: + return PRINTER_MODEL_ID_MAP[model_id] + + # Return original if unknown (might already be a short name) + return model_id + + +def normalize_printer_model(raw_model: str | None) -> str | None: + """Convert 3MF printer_model to normalized short name. + + Args: + raw_model: The printer_model string from 3MF metadata + (e.g., "Bambu Lab X1 Carbon") + + Returns: + Normalized short name (e.g., "X1C") or None if input is empty. + Unknown models have "Bambu Lab " prefix stripped. + """ + if not raw_model: + return None + + # Check known mappings first + if raw_model in PRINTER_MODEL_MAP: + return PRINTER_MODEL_MAP[raw_model] + + # Strip "Bambu Lab " prefix for unknown models + stripped = raw_model.replace("Bambu Lab ", "").strip() + return stripped or None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0cd7046cc..1b4bb262b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -216,6 +216,24 @@ def mock_mqtt_client(): yield mock +@pytest.fixture +def mock_mqtt_smart_plug_service(): + """Mock the MQTT smart plug service for MQTT plug tests.""" + with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock: + # Create a mock smart_plug_service + mock_service = MagicMock() + mock_service.is_configured = MagicMock(return_value=True) + mock_service.has_broker_settings = MagicMock(return_value=True) + mock_service.configure = AsyncMock(return_value=True) + mock_service.subscribe = MagicMock() + mock_service.unsubscribe = MagicMock() + mock_service.get_plug_data = MagicMock(return_value=None) + mock_service.is_reachable = MagicMock(return_value=False) + + mock.smart_plug_service = mock_service + yield mock + + @pytest.fixture def mock_ftp_client(): """Mock the FTP client for file transfer tests.""" @@ -296,6 +314,22 @@ def smart_plug_factory(db_session): if plug_type == "homeassistant": defaults["ha_entity_id"] = "switch.test" defaults["ip_address"] = None + elif plug_type == "mqtt": + # Legacy fields (for backward compatibility tests) + defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic") + defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0) + # New separate topic/path/multiplier fields + defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic") + defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power") + defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0) + defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic") + defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path") + defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0) + defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic") + defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path") + defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value") + defaults["ip_address"] = None + defaults["ha_entity_id"] = None else: defaults["ip_address"] = "192.168.1.100" defaults["ha_entity_id"] = None diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 6d8510f17..59c8a30d4 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints: """Verify filament-requirements with plate_id returns 404 for non-existent archive.""" response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1") assert response.status_code == 404 + + # ======================================================================== + # Tag Management endpoints (Issue #183) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_empty(self, async_client: AsyncClient): + """Verify empty list when no tags exist.""" + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify tags are returned with counts.""" + printer = await printer_factory() + await archive_factory(printer.id, print_name="Archive 1", tags="functional, test") + await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration") + await archive_factory(printer.id, print_name="Archive 3", tags="test") + + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + # Convert to dict for easier lookup + tags_dict = {t["name"]: t["count"] for t in data} + assert tags_dict.get("functional") == 2 + assert tags_dict.get("test") == 2 + assert tags_dict.get("calibration") == 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_sorted_by_count( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify tags are sorted by count descending, then by name.""" + printer = await printer_factory() + await archive_factory(printer.id, tags="alpha") + await archive_factory(printer.id, tags="beta, alpha") + await archive_factory(printer.id, tags="gamma, beta, alpha") + + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + + # alpha=3, beta=2, gamma=1 + assert data[0]["name"] == "alpha" + assert data[0]["count"] == 3 + assert data[1]["name"] == "beta" + assert data[1]["count"] == 2 + assert data[2]["name"] == "gamma" + assert data[2]["count"] == 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify renaming a tag updates all archives.""" + printer = await printer_factory() + a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other") + a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag") + await archive_factory(printer.id, print_name="Archive 3", tags="different") + + response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"}) + assert response.status_code == 200 + data = response.json() + assert data["affected"] == 2 + + # Verify the archives were updated + response = await async_client.get(f"/api/v1/archives/{a1.id}") + assert "new-tag" in response.json()["tags"] + assert "old-tag" not in response.json()["tags"] + + response = await async_client.get(f"/api/v1/archives/{a2.id}") + assert response.json()["tags"] == "new-tag" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag_no_change(self, async_client: AsyncClient): + """Verify renaming to same name returns 0 affected.""" + response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"}) + assert response.status_code == 200 + assert response.json()["affected"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag_empty_name_error(self, async_client: AsyncClient): + """Verify renaming to empty name returns error.""" + response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""}) + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify deleting a tag removes it from all archives.""" + printer = await printer_factory() + a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep") + a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me") + await archive_factory(printer.id, print_name="Archive 3", tags="different") + + response = await async_client.delete("/api/v1/archives/tags/delete-me") + assert response.status_code == 200 + data = response.json() + assert data["affected"] == 2 + + # Verify the archives were updated + response = await async_client.get(f"/api/v1/archives/{a1.id}") + assert response.json()["tags"] == "keep" + + response = await async_client.get(f"/api/v1/archives/{a2.id}") + # Should be None or empty when last tag is removed + assert response.json()["tags"] is None or response.json()["tags"] == "" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_tag_not_found(self, async_client: AsyncClient): + """Verify deleting non-existent tag returns 0 affected.""" + response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag") + assert response.status_code == 200 + assert response.json()["affected"] == 0 diff --git a/backend/tests/integration/test_camera_api.py b/backend/tests/integration/test_camera_api.py index 9fbf2224c..f6d31e3b4 100644 --- a/backend/tests/integration/test_camera_api.py +++ b/backend/tests/integration/test_camera_api.py @@ -285,6 +285,7 @@ class TestCameraAPI: mock_result.difference_percent = 0.5 mock_result.message = "Plate appears empty" mock_result.needs_calibration = False + mock_result.debug_image = None mock_result.to_dict.return_value = { "is_empty": True, "confidence": 0.95, @@ -294,7 +295,16 @@ class TestCameraAPI: "needs_calibration": False, } - with patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check: + # Mock PlateDetector for reference count + mock_detector = MagicMock() + mock_detector.get_calibration_count.return_value = 0 + mock_detector.MAX_REFERENCES = 5 + + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check, + patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector), + ): mock_check.return_value = mock_result response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate") @@ -319,7 +329,10 @@ class TestCameraAPI: printer = await printer_factory() # Mock calibrate_plate at the source module to avoid camera timeout - with patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate: + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate, + ): mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0) response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate") @@ -342,7 +355,8 @@ class TestCameraAPI: """Verify delete calibration returns proper structure.""" printer = await printer_factory() - response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate") + with patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True): + response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate") assert response.status_code == 200 result = response.json() @@ -374,8 +388,16 @@ class TestCameraAPI: """Verify get references returns proper structure.""" printer = await printer_factory() - # OpenCV is available in test environment, just check the response structure - response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references") + # Mock OpenCV availability and PlateDetector + mock_detector = MagicMock() + mock_detector.get_references.return_value = [] + mock_detector.MAX_REFERENCES = 5 + + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector), + ): + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references") assert response.status_code == 200 result = response.json() diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index 9a7c2d347..3225eb0c0 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -1,5 +1,10 @@ """Integration tests for Library API endpoints.""" +import io +import tempfile +import zipfile +from pathlib import Path + import pytest from httpx import AsyncClient @@ -479,3 +484,234 @@ class TestLibraryZipExtractAPI: assert folder_response.status_code == 200 folder = folder_response.json() assert folder["name"] == "MyProject" + + +class TestLibraryStlThumbnailAPI: + """Integration tests for STL thumbnail generation endpoints.""" + + @pytest.fixture + async def file_factory(self, db_session): + """Factory to create test files.""" + _counter = [0] + + async def _create_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "filename": f"test_model_{counter}.stl", + "file_path": f"/test/path/test_model_{counter}.stl", + "file_size": 1024, + "file_type": "stl", + } + defaults.update(kwargs) + + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_file + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session): + """Verify batch thumbnail generation with no files.""" + data = {"all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 0 + assert result["succeeded"] == 0 + assert result["failed"] == 0 + assert result["results"] == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session): + """Verify batch thumbnail generation with no criteria returns empty.""" + data = {} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_file_not_on_disk( + self, async_client: AsyncClient, file_factory, db_session + ): + """Verify batch thumbnail generation handles missing files gracefully.""" + # Create a file in DB but not on disk + stl_file = await file_factory( + filename="missing.stl", + file_path="/nonexistent/path/missing.stl", + thumbnail_path=None, + ) + + data = {"file_ids": [stl_file.id]} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 1 + assert result["succeeded"] == 0 + assert result["failed"] == 1 + assert result["results"][0]["success"] is False + assert "not found" in result["results"][0]["error"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session): + """Verify batch thumbnail generation with a real STL file.""" + from backend.app.models.library import LibraryFile + + # Create a simple ASCII STL cube + stl_content = """solid cube +facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 1 0 + endloop +endfacet +facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 1 0 1 + endloop +endfacet +endsolid cube""" + + with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f: + f.write(stl_content) + stl_path = f.name + + try: + # Create file in DB pointing to real STL + lib_file = LibraryFile( + filename="test_cube.stl", + file_path=stl_path, + file_size=len(stl_content), + file_type="stl", + thumbnail_path=None, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + data = {"file_ids": [lib_file.id]} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 1 + # Result depends on whether trimesh/matplotlib are installed + # Either succeeds or fails gracefully + assert result["succeeded"] + result["failed"] == 1 + finally: + import os + + if os.path.exists(stl_path): + os.unlink(stl_path) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session): + """Verify file upload accepts generate_stl_thumbnails parameter.""" + # Create a simple STL file + stl_content = b"solid test\nendsolid test" + + files = {"file": ("test.stl", stl_content, "application/octet-stream")} + params = {"generate_stl_thumbnails": "false"} + response = await async_client.post("/api/v1/library/files", files=files, params=params) + assert response.status_code == 200 + result = response.json() + assert result["filename"] == "test.stl" + assert result["file_type"] == "stl" + # No thumbnail should be generated when disabled + assert result["thumbnail_path"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session): + """Verify ZIP extraction accepts generate_stl_thumbnails parameter.""" + # Create a ZIP file containing an STL + stl_content = b"solid test\nendsolid test" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("model.stl", stl_content) + zip_buffer.seek(0) + + files = {"file": ("test.zip", zip_buffer.read(), "application/zip")} + params = {"generate_stl_thumbnails": "false"} + response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params) + assert response.status_code == 200 + result = response.json() + assert result["extracted"] == 1 + assert result["files"][0]["filename"] == "model.stl" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session): + """Verify batch thumbnail generation can filter by folder.""" + from backend.app.models.library import LibraryFolder + + # Create a folder + folder = LibraryFolder(name="STL Folder") + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + + # Create STL file in folder (no thumbnail) + stl_in_folder = await file_factory( + filename="in_folder.stl", + folder_id=folder.id, + thumbnail_path=None, + ) + + # Create STL file at root (no thumbnail) + _stl_at_root = await file_factory( + filename="at_root.stl", + folder_id=None, + thumbnail_path=None, + ) + + # Request thumbnails only for files in folder + data = {"folder_id": folder.id, "all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + # Should only process the file in the folder + assert result["processed"] == 1 + assert result["results"][0]["file_id"] == stl_in_folder.id + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session): + """Verify batch thumbnail generation finds all STL files missing thumbnails.""" + # Create files with and without thumbnails + _stl_with_thumb = await file_factory( + filename="with_thumb.stl", + thumbnail_path="/some/path/thumb.png", + ) + stl_without_thumb1 = await file_factory( + filename="without_thumb1.stl", + thumbnail_path=None, + ) + stl_without_thumb2 = await file_factory( + filename="without_thumb2.stl", + thumbnail_path=None, + ) + + data = {"all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + # Should only process files without thumbnails + assert result["processed"] == 2 + file_ids = {r["file_id"] for r in result["results"]} + assert stl_without_thumb1.id in file_ids + assert stl_without_thumb2.id in file_ids diff --git a/backend/tests/integration/test_smart_plugs_api.py b/backend/tests/integration/test_smart_plugs_api.py index e6ad7b573..c5e917e10 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -573,3 +573,255 @@ class TestSmartPlugsAPI: assert response.status_code == 400 assert "not configured" in response.json()["detail"].lower() + + # ======================================================================== + # MQTT Integration tests + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with topic and JSON paths.""" + data = { + "name": "MQTT Energy Monitor", + "plug_type": "mqtt", + "mqtt_topic": "zigbee2mqtt/shelly-working-room", + "mqtt_power_path": "power_l1", + "mqtt_energy_path": "energy_l1", + "mqtt_state_path": "state_l1", + "mqtt_multiplier": 1.0, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "MQTT Energy Monitor" + assert result["plug_type"] == "mqtt" + assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room" + assert result["mqtt_power_path"] == "power_l1" + assert result["mqtt_energy_path"] == "energy_l1" + assert result["mqtt_state_path"] == "state_l1" + assert result["mqtt_multiplier"] == 1.0 + assert result["ip_address"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient): + """Verify creating MQTT plug without topic fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + # Missing mqtt_topic + "mqtt_power_path": "power", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient): + """Verify creating MQTT plug without any topic fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + # No topic configured at all + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can use multiplier for unit conversion.""" + data = { + "name": "MQTT mW to W", + "plug_type": "mqtt", + "mqtt_topic": "sensors/power", + "mqtt_power_path": "power_mw", + "mqtt_multiplier": 0.001, # Convert mW to W + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_multiplier"] == 0.001 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify MQTT plugs cannot be controlled (monitor-only).""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_topic="test/topic", + mqtt_power_path="power", + ) + + response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"}) + + assert response.status_code == 400 + assert "monitor-only" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify MQTT plug topic can be updated.""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_topic="old/topic", + mqtt_power_path="power", + ) + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "mqtt_topic": "new/topic", + "mqtt_power_path": "new_power", + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_topic"] == "new/topic" + assert result["mqtt_power_path"] == "new_power" + + # ======================================================================== + # Enhanced MQTT Integration tests (separate topics per data type) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with separate topics for power, energy, and state.""" + data = { + "name": "MQTT Separate Topics", + "plug_type": "mqtt", + "mqtt_power_topic": "zigbee/power", + "mqtt_power_path": "power_l1", + "mqtt_power_multiplier": 0.001, + "mqtt_energy_topic": "zigbee/energy", + "mqtt_energy_path": "energy_total", + "mqtt_energy_multiplier": 1.0, + "mqtt_state_topic": "zigbee/state", + "mqtt_state_path": "state", + "mqtt_state_on_value": "ON", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "MQTT Separate Topics" + assert result["plug_type"] == "mqtt" + # Power fields + assert result["mqtt_power_topic"] == "zigbee/power" + assert result["mqtt_power_path"] == "power_l1" + assert result["mqtt_power_multiplier"] == 0.001 + # Energy fields + assert result["mqtt_energy_topic"] == "zigbee/energy" + assert result["mqtt_energy_path"] == "energy_total" + assert result["mqtt_energy_multiplier"] == 1.0 + # State fields + assert result["mqtt_state_topic"] == "zigbee/state" + assert result["mqtt_state_path"] == "state" + assert result["mqtt_state_on_value"] == "ON" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with only energy monitoring.""" + data = { + "name": "Energy Only Monitor", + "plug_type": "mqtt", + "mqtt_energy_topic": "sensors/energy", + "mqtt_energy_path": "kwh", + "mqtt_energy_multiplier": 0.001, # Wh to kWh + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_energy_topic"] == "sensors/energy" + assert result["mqtt_energy_path"] == "kwh" + assert result["mqtt_energy_multiplier"] == 0.001 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with only state monitoring.""" + data = { + "name": "State Only Monitor", + "plug_type": "mqtt", + "mqtt_state_topic": "switches/outlet", + "mqtt_state_path": "state", + "mqtt_state_on_value": "true", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_state_topic"] == "switches/outlet" + assert result["mqtt_state_path"] == "state" + assert result["mqtt_state_on_value"] == "true" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify creating MQTT plug with topic only (no path) succeeds for raw values.""" + data = { + "name": "Raw MQTT Plug", + "plug_type": "mqtt", + # Topic only, no path - valid for raw numeric MQTT values + "mqtt_power_topic": "zigbee/power", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 # Should succeed + result = response.json() + assert result["mqtt_power_topic"] == "zigbee/power" + assert result["mqtt_power_path"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mqtt_plug_separate_multipliers( + self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service + ): + """Verify MQTT plug multipliers can be updated separately.""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_power_topic="test/power", + mqtt_power_path="power", + mqtt_power_multiplier=1.0, + mqtt_energy_topic="test/energy", + mqtt_energy_path="energy", + mqtt_energy_multiplier=1.0, + ) + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "mqtt_power_multiplier": 0.001, # Change power multiplier only + "mqtt_energy_multiplier": 0.001, # Change energy multiplier only + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_power_multiplier"] == 0.001 + assert result["mqtt_energy_multiplier"] == 0.001 diff --git a/backend/tests/unit/services/test_stl_thumbnail.py b/backend/tests/unit/services/test_stl_thumbnail.py new file mode 100644 index 000000000..31b6a0a19 --- /dev/null +++ b/backend/tests/unit/services/test_stl_thumbnail.py @@ -0,0 +1,204 @@ +"""Unit tests for the STL thumbnail service.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +def _check_trimesh_available(): + """Check if trimesh is available for import.""" + try: + import trimesh + + return True + except ImportError: + return False + + +class TestStlThumbnailService: + """Tests for STL thumbnail generation service.""" + + def test_generate_stl_thumbnail_imports_available(self): + """Test that required imports are available.""" + try: + import matplotlib + import trimesh + + assert trimesh is not None + assert matplotlib is not None + except ImportError as e: + pytest.skip(f"Required dependencies not installed: {e}") + + def test_generate_stl_thumbnail_returns_none_on_missing_deps(self): + """Test graceful degradation when dependencies are missing.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "test.stl" + thumbnails_dir = Path(tmpdir) + + # Create a dummy STL file (will fail to parse) + stl_path.write_text("invalid stl content") + + # Should return None on failure, not raise + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + assert result is None + + @pytest.mark.skipif( + not _check_trimesh_available(), + reason="trimesh not installed", + ) + def test_generate_stl_thumbnail_with_simple_cube(self): + """Test thumbnail generation with a simple cube STL.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "cube.stl" + thumbnails_dir = Path(tmpdir) + + # Create a simple ASCII STL cube + stl_content = """solid cube +facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 1 0 + endloop +endfacet +facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 1 0 + vertex 0 1 0 + endloop +endfacet +facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 1 0 1 + endloop +endfacet +facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 0 1 1 + vertex 1 1 1 + endloop +endfacet +facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 1 0 1 + vertex 1 0 0 + endloop +endfacet +facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 0 0 1 + vertex 1 0 1 + endloop +endfacet +facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 0 1 + vertex 1 1 1 + endloop +endfacet +facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 1 1 + vertex 1 1 0 + endloop +endfacet +facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 0 + vertex 1 1 1 + endloop +endfacet +facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 1 + vertex 0 1 1 + endloop +endfacet +facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 0 + vertex 0 1 1 + endloop +endfacet +facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 1 + vertex 0 0 1 + endloop +endfacet +endsolid cube""" + stl_path.write_text(stl_content) + + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + + # Should return a path to the generated thumbnail + if result: + assert Path(result).exists() + assert Path(result).suffix == ".png" + # If result is None, dependencies might not be fully functional + # which is acceptable + + def test_generate_stl_thumbnail_nonexistent_file(self): + """Test thumbnail generation with nonexistent file.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "nonexistent.stl" + thumbnails_dir = Path(tmpdir) + + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + assert result is None + + def test_generate_stl_thumbnail_empty_file(self): + """Test thumbnail generation with empty file.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "empty.stl" + thumbnails_dir = Path(tmpdir) + + # Create empty file + stl_path.write_bytes(b"") + + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + assert result is None + + +class TestStlThumbnailConstants: + """Tests for STL thumbnail service constants.""" + + def test_bambu_green_color(self): + """Test that Bambu green color is defined.""" + from backend.app.services.stl_thumbnail import BAMBU_GREEN + + assert BAMBU_GREEN == "#00AE42" + + def test_background_color(self): + """Test that background color is defined.""" + from backend.app.services.stl_thumbnail import BACKGROUND_COLOR + + assert BACKGROUND_COLOR == "#1a1a1a" + + def test_max_vertices_threshold(self): + """Test that max vertices threshold is defined.""" + from backend.app.services.stl_thumbnail import MAX_VERTICES + + assert MAX_VERTICES == 100000 diff --git a/demo-video/.gitignore b/demo-video/.gitignore deleted file mode 100644 index 290171b2c..000000000 --- a/demo-video/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -output/ -*.webm -*.mp4 diff --git a/demo-video/README.md b/demo-video/README.md deleted file mode 100644 index bd6899410..000000000 --- a/demo-video/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Bambuddy Demo Video Recorder - -Automated demo video recording using Playwright. - -## Setup - -```bash -cd demo-video -npm install -npm run install-browsers -``` - -## Recording - -### Record with visible browser (recommended for debugging) -```bash -npm run record -``` - -### Record headless (faster, no window) -```bash -npm run record:headless -``` - -### Custom URL -```bash -DEMO_URL=https://your-bambuddy.example.com npm run record -``` - -## Output - -Videos are saved to `output/` as `.webm` files. - -### Convert to MP4 -```bash -ffmpeg -i output/video.webm -c:v libx264 -crf 23 demo.mp4 -``` - -### Convert with better quality -```bash -ffmpeg -i output/video.webm -c:v libx264 -crf 18 -preset slow demo.mp4 -``` - -## Customization - -Edit `record-demo.ts` to: -- Adjust timing (TIMING constants) -- Add/remove page demonstrations -- Customize interactions per page -- Change viewport resolution (CONFIG) diff --git a/demo-video/package-lock.json b/demo-video/package-lock.json deleted file mode 100644 index aab2cdc45..000000000 --- a/demo-video/package-lock.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "name": "bambuddy-demo-video", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bambuddy-demo-video", - "version": "1.0.0", - "dependencies": { - "playwright": "^1.40.0", - "tsx": "^4.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dependencies": { - "playwright-core": "1.57.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - } - } -} diff --git a/demo-video/package.json b/demo-video/package.json deleted file mode 100644 index aca4f4a2e..000000000 --- a/demo-video/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "bambuddy-demo-video", - "version": "1.0.0", - "description": "Automated demo video recording for Bambuddy", - "type": "module", - "scripts": { - "record": "npx tsx record-demo.ts", - "record:headless": "HEADLESS=true npx tsx record-demo.ts", - "install-browsers": "npx playwright install chromium" - }, - "dependencies": { - "playwright": "^1.40.0", - "tsx": "^4.7.0" - } -} diff --git a/demo-video/record-demo.ts b/demo-video/record-demo.ts deleted file mode 100644 index 4b41c77bf..000000000 --- a/demo-video/record-demo.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { chromium, Page, Browser, BrowserContext } from 'playwright'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Configuration -const CONFIG = { - baseUrl: process.env.DEMO_URL || 'http://localhost:8000', - headless: process.env.HEADLESS === 'true', - slowMo: 50, // Slow down actions for visibility - viewportWidth: 1920, - viewportHeight: 1080, - outputDir: path.join(__dirname, 'output'), -}; - -// Timing helpers (in ms) -const TIMING = { - pageLoad: 1500, // Wait after page navigation - shortPause: 500, // Brief pause between actions - mediumPause: 1000, // Standard pause for visibility - longPause: 2000, // Longer pause for important features - modalOpen: 800, // Wait for modal animations - scrollPause: 600, // Pause after scrolling -}; - -async function wait(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function scrollDown(page: Page, pixels: number = 300): Promise { - await page.mouse.wheel(0, pixels); - await wait(TIMING.scrollPause); -} - -async function scrollToTop(page: Page): Promise { - await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' })); - await wait(TIMING.scrollPause); -} - -async function hoverElement(page: Page, selector: string): Promise { - const element = page.locator(selector).first(); - if (await element.isVisible()) { - await element.hover(); - await wait(TIMING.shortPause); - } -} - -async function clickIfVisible(page: Page, selector: string): Promise { - const element = page.locator(selector).first(); - if (await element.isVisible()) { - await element.click(); - return true; - } - return false; -} - -async function closeModalIfOpen(page: Page): Promise { - // Try to close any open modal by pressing Escape - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); -} - -async function blurSensitiveContent(page: Page): Promise { - // Use JavaScript to find and blur email addresses - await page.evaluate(() => { - // Find all spans and check for email patterns - document.querySelectorAll('span').forEach(el => { - const text = el.textContent || ''; - // Check if this specific element (not children) contains an email - if (el.childNodes.length === 1 && el.childNodes[0].nodeType === Node.TEXT_NODE) { - if (text.includes('@') && text.includes('.')) { - (el as HTMLElement).style.filter = 'blur(6px)'; - (el as HTMLElement).style.userSelect = 'none'; - } - } - }); - - // Also find "Connected as" text and blur the next sibling span - document.querySelectorAll('span').forEach(el => { - if (el.textContent?.includes('Connected as')) { - const emailSpan = el.querySelector('span'); - if (emailSpan) { - (emailSpan as HTMLElement).style.filter = 'blur(6px)'; - (emailSpan as HTMLElement).style.userSelect = 'none'; - } - } - }); - }); -} - -// ============================================================================ -// Page Scenarios -// ============================================================================ - -async function demoPrintersPage(page: Page): Promise { - console.log('📷 Demonstrating Printers page...'); - await page.goto(CONFIG.baseUrl); - await wait(TIMING.pageLoad); - - // Hover over printer cards to show interactions - const printerCards = page.locator('.group').filter({ has: page.locator('img') }); - const cardCount = await printerCards.count(); - console.log(` Found ${cardCount} printer cards`); - - for (let i = 0; i < Math.min(cardCount, 2); i++) { - const card = printerCards.nth(i); - if (await card.isVisible()) { - await card.hover(); - await wait(TIMING.mediumPause); - - // Try clicking on card to expand/show details - await card.click(); - await wait(TIMING.mediumPause); - } - } - - // Look for AMS section and hover over slots - const amsSlots = page.locator('[class*="ams"], [class*="AMS"]').first(); - if (await amsSlots.isVisible()) { - await amsSlots.hover(); - await wait(TIMING.mediumPause); - } - - // Try to open camera modal - const cameraIcon = page.locator('svg[class*="lucide-video"], button:has(svg)').first(); - if (await cameraIcon.isVisible()) { - await cameraIcon.click(); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Try to open MQTT debug modal - const debugButton = page.locator('button:has-text("Debug"), button:has-text("MQTT")').first(); - if (await debugButton.isVisible()) { - await debugButton.click(); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Scroll to show more printers - await scrollDown(page, 400); - await wait(TIMING.mediumPause); - await scrollToTop(page); -} - -async function demoArchivesPage(page: Page): Promise { - console.log('📁 Demonstrating Archives page...'); - await page.goto(`${CONFIG.baseUrl}/archives`); - await wait(TIMING.pageLoad); - - // Show view mode toggle (grid/list/calendar) - const viewToggle = page.locator('button:has(svg[class*="grid"]), button:has(svg[class*="list"])'); - if (await viewToggle.first().isVisible()) { - await viewToggle.first().click(); - await wait(TIMING.mediumPause); - await viewToggle.first().click(); // Toggle back - await wait(TIMING.shortPause); - } - - // Use search - const searchInput = page.locator('input[placeholder*="Search"], input[type="search"]').first(); - if (await searchInput.isVisible()) { - await searchInput.click(); - await searchInput.fill('engine'); - await wait(TIMING.longPause); - await searchInput.clear(); - await wait(TIMING.shortPause); - } - - // Show filter dropdowns - const filterButtons = page.locator('button:has-text("Printer"), button:has-text("Material"), button:has-text("Filter")'); - if (await filterButtons.first().isVisible()) { - await filterButtons.first().click(); - await wait(TIMING.mediumPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Right-click to show context menu - const archiveCard = page.locator('.group').filter({ has: page.locator('img') }).first(); - if (await archiveCard.isVisible()) { - await archiveCard.click({ button: 'right' }); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Click on archive to open edit modal - if (await archiveCard.isVisible()) { - await archiveCard.dblclick(); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Scroll to show more archives - await scrollDown(page, 500); - await wait(TIMING.mediumPause); - await scrollToTop(page); -} - -async function demoQueuePage(page: Page): Promise { - console.log('📋 Demonstrating Queue page...'); - await page.goto(`${CONFIG.baseUrl}/queue`); - await wait(TIMING.pageLoad); - - // Show filter dropdowns - const printerFilter = page.locator('button:has-text("Printer"), select').first(); - if (await printerFilter.isVisible()) { - await printerFilter.click(); - await wait(TIMING.mediumPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Show sort controls - const sortButton = page.locator('button:has-text("Sort"), button:has(svg[class*="arrow"])').first(); - if (await sortButton.isVisible()) { - await sortButton.click(); - await wait(TIMING.mediumPause); - } - - // Hover over queue items to show drag handles - const queueItems = page.locator('[draggable="true"], .group').first(); - if (await queueItems.isVisible()) { - await queueItems.hover(); - await wait(TIMING.mediumPause); - } - - // Scroll through queue - await scrollDown(page, 300); - await wait(TIMING.mediumPause); - await scrollToTop(page); -} - -async function demoStatsPage(page: Page): Promise { - console.log('📊 Demonstrating Stats page...'); - await page.goto(`${CONFIG.baseUrl}/stats`); - await wait(TIMING.pageLoad); - - // Let charts animate - await wait(TIMING.longPause); - - // Show export dropdown - const exportButton = page.locator('button:has-text("Export"), button:has(svg[class*="download"])').first(); - if (await exportButton.isVisible()) { - await exportButton.click(); - await wait(TIMING.mediumPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Scroll through stats widgets - await scrollDown(page, 400); - await wait(TIMING.mediumPause); - await scrollDown(page, 400); - await wait(TIMING.mediumPause); - await scrollDown(page, 400); - await wait(TIMING.mediumPause); - await scrollToTop(page); -} - -async function demoProfilesPage(page: Page): Promise { - console.log('⚙️ Demonstrating Profiles page...'); - - // Start blur loop BEFORE navigating - let blurring = true; - const blurLoop = async () => { - while (blurring) { - try { - await page.evaluate(() => { - document.querySelectorAll('span').forEach(el => { - if (el.textContent?.includes('Connected as')) { - const emailSpan = el.querySelector('span'); - if (emailSpan) { - (emailSpan as HTMLElement).style.filter = 'blur(6px)'; - } - } - }); - }); - } catch { /* page might be navigating */ } - await new Promise(r => setTimeout(r, 30)); - } - }; - - // Start blur loop in background - const blurPromise = blurLoop(); - - await page.goto(`${CONFIG.baseUrl}/profiles`); - await wait(TIMING.pageLoad); - - // Show Cloud Profiles section - await wait(TIMING.mediumPause); - - // Click on K-Profiles tab if available - try { - const kProfilesTab = page.locator('button:has-text("K-Profile"), button:has-text("K Profile")').first(); - if (await kProfilesTab.isVisible({ timeout: 1000 })) { - await kProfilesTab.click({ timeout: 2000 }); - await wait(TIMING.mediumPause); - await scrollDown(page, 300); - await wait(TIMING.shortPause); - await scrollToTop(page); - } - } catch { /* skip */ } - - // Click back to Cloud Profiles - try { - const cloudTab = page.locator('button:has-text("Cloud")').first(); - if (await cloudTab.isVisible({ timeout: 1000 })) { - await cloudTab.click({ timeout: 2000 }); - await wait(TIMING.mediumPause); - } - } catch { /* skip */ } - - // Show preset filter types (if visible) - use force to bypass overlays - const presetFilters = page.locator('button:has-text("Filament"), button:has-text("Process"), button:has-text("Machine")'); - for (let i = 0; i < 3; i++) { - try { - const filter = presetFilters.nth(i); - if (await filter.isVisible({ timeout: 1000 })) { - await filter.click({ force: true, timeout: 2000 }); - await wait(TIMING.shortPause); - } - } catch { /* skip if not visible or blocked */ } - } - - await scrollDown(page, 300); - await wait(TIMING.shortPause); - await scrollToTop(page); - - // Stop blur loop - blurring = false; - await blurPromise; -} - -async function demoMaintenancePage(page: Page): Promise { - console.log('🔧 Demonstrating Maintenance page...'); - await page.goto(`${CONFIG.baseUrl}/maintenance`); - await wait(TIMING.pageLoad); - - // Show status tab (default) - await wait(TIMING.mediumPause); - - // Expand a printer section if available - const expandButton = page.locator('button:has(svg[class*="chevron"])').first(); - if (await expandButton.isVisible()) { - await expandButton.click(); - await wait(TIMING.mediumPause); - } - - // Scroll through status - await scrollDown(page, 300); - await wait(TIMING.shortPause); - await scrollToTop(page); - - // Click Settings tab - const settingsTab = page.locator('button:has-text("Settings"), [role="tab"]:has-text("Settings")').first(); - if (await settingsTab.isVisible()) { - await settingsTab.click(); - await wait(TIMING.mediumPause); - - // Scroll through settings - await scrollDown(page, 300); - await wait(TIMING.shortPause); - await scrollToTop(page); - } - - // Go back to Status tab - const statusTab = page.locator('button:has-text("Status"), [role="tab"]:has-text("Status")').first(); - if (await statusTab.isVisible()) { - await statusTab.click(); - await wait(TIMING.shortPause); - } -} - -async function demoProjectsPage(page: Page): Promise { - console.log('📂 Demonstrating Projects page...'); - await page.goto(`${CONFIG.baseUrl}/projects`); - await wait(TIMING.pageLoad); - - // Click through status filter buttons - const statusFilters = ['Active', 'Completed', 'Archived', 'All']; - for (const status of statusFilters) { - const filterBtn = page.locator(`button:has-text("${status}")`).first(); - if (await filterBtn.isVisible()) { - await filterBtn.click(); - await wait(TIMING.shortPause); - } - } - - // Click on a project to go to detail page - const projectCard = page.locator('.group, [class*="project"]').filter({ has: page.locator('h3, h2') }).first(); - if (await projectCard.isVisible()) { - await projectCard.click(); - await wait(TIMING.pageLoad); - - // Scroll through project detail - await scrollDown(page, 300); - await wait(TIMING.mediumPause); - - // Look for tabs in project detail (BOM, Attachments, Prints) - const detailTabs = ['BOM', 'Attachments', 'Prints', 'Notes']; - for (const tabName of detailTabs) { - const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first(); - if (await tab.isVisible()) { - await tab.click(); - await wait(TIMING.mediumPause); - } - } - - await scrollToTop(page); - } -} - -async function demoSettingsPage(page: Page): Promise { - console.log('⚙️ Demonstrating Settings page...'); - await page.goto(`${CONFIG.baseUrl}/settings`); - await wait(TIMING.pageLoad); - - // Define the 6 tabs to click through - const tabs = ['General', 'Plugs', 'Notifications', 'Filament', 'API', 'Virtual']; - - for (const tabName of tabs) { - const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first(); - if (await tab.isVisible()) { - await tab.click(); - await wait(TIMING.mediumPause); - - // Scroll through tab content - await scrollDown(page, 300); - await wait(TIMING.shortPause); - await scrollToTop(page); - } - } - - // Go back to General tab and show a modal - const generalTab = page.locator('button:has-text("General")').first(); - if (await generalTab.isVisible()) { - await generalTab.click(); - await wait(TIMING.shortPause); - } - - // Try to open backup modal - const backupButton = page.locator('button:has-text("Backup")').first(); - if (await backupButton.isVisible()) { - await backupButton.click(); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - - // Go to Plugs tab and show add modal - const plugsTab = page.locator('button:has-text("Plugs")').first(); - if (await plugsTab.isVisible()) { - await plugsTab.click(); - await wait(TIMING.shortPause); - - const addPlugButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first(); - if (await addPlugButton.isVisible()) { - await addPlugButton.click(); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - } - - // Go to Notifications tab and show add modal - const notifTab = page.locator('button:has-text("Notifications")').first(); - if (await notifTab.isVisible()) { - await notifTab.click(); - await wait(TIMING.shortPause); - - const addNotifButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first(); - if (await addNotifButton.isVisible()) { - await addNotifButton.click(); - await wait(TIMING.longPause); - await page.keyboard.press('Escape'); - await wait(TIMING.shortPause); - } - } - - await scrollToTop(page); -} - -async function demoSystemPage(page: Page): Promise { - console.log('💻 Demonstrating System page...'); - await page.goto(`${CONFIG.baseUrl}/system`); - await wait(TIMING.pageLoad); - - // Show system info - await wait(TIMING.mediumPause); - await scrollDown(page, 300); - await wait(TIMING.shortPause); - await scrollToTop(page); -} - -// ============================================================================ -// Main Recording Function -// ============================================================================ - -async function recordDemo(): Promise { - console.log('🎬 Starting Bambuddy demo recording...'); - console.log(` URL: ${CONFIG.baseUrl}`); - console.log(` Resolution: ${CONFIG.viewportWidth}x${CONFIG.viewportHeight}`); - console.log(` Headless: ${CONFIG.headless}`); - console.log(''); - - const browser: Browser = await chromium.launch({ - headless: CONFIG.headless, - slowMo: CONFIG.slowMo, - }); - - const context: BrowserContext = await browser.newContext({ - viewport: { - width: CONFIG.viewportWidth, - height: CONFIG.viewportHeight, - }, - recordVideo: { - dir: CONFIG.outputDir, - size: { - width: CONFIG.viewportWidth, - height: CONFIG.viewportHeight, - }, - }, - }); - - const page: Page = await context.newPage(); - - try { - // Run through all page demos - await demoPrintersPage(page); - await demoArchivesPage(page); - await demoQueuePage(page); - await demoStatsPage(page); - await demoProfilesPage(page); - await demoMaintenancePage(page); - await demoProjectsPage(page); - await demoSettingsPage(page); - await demoSystemPage(page); - - // Return to home page for closing shot - console.log('🏠 Returning to home page...'); - await page.goto(CONFIG.baseUrl); - await wait(TIMING.longPause); - - console.log('✅ Demo recording completed!'); - } catch (error) { - console.error('❌ Error during recording:', error); - throw error; - } finally { - await page.close(); - await context.close(); - await browser.close(); - } - - console.log(`\n📹 Video saved to: ${CONFIG.outputDir}/`); - console.log(' (Playwright saves as .webm, convert with ffmpeg if needed)'); - console.log(' Example: ffmpeg -i video.webm -c:v libx264 demo.mp4'); -} - -// Run the recording -recordDemo().catch(console.error); diff --git a/deploy/bambuddy.service b/deploy/bambuddy.service index b096b3d29..93c779466 100644 --- a/deploy/bambuddy.service +++ b/deploy/bambuddy.service @@ -1,31 +1,61 @@ +# BamBuddy Systemd Service Template +# +# INSTALLATION: +# 1. Copy this file to /etc/systemd/system/bambuddy.service +# 2. Replace placeholders: +# - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy) +# - SERVICE_USER: User to run as (e.g., bambuddy) +# - DATA_DIR: Data directory (e.g., /opt/bambuddy/data) +# - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs) +# 3. Run: sudo systemctl daemon-reload +# 4. Run: sudo systemctl enable bambuddy +# 5. Run: sudo systemctl start bambuddy +# +# Or use the install script: ./install/install.sh +# + [Unit] -Description=BamBuddy Print Archive +Description=BamBuddy - Bambu Lab Print Management +Documentation=https://github.com/maziggy/bambuddy After=network.target [Service] Type=simple -User=claude -Group=claude -WorkingDirectory=

/bambuddy -Environment="PATH=/bambuddy/logs -ExecStartPre=+/bin/mkdir -p /bambuddy/archive -ExecStartPre=+/bin/chown -R : /bambuddy/logs -ExecStartPre=+/bin/chown -R : /bambuddy/archive +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=bambuddy -ExecStart=/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 -Restart=always -RestartSec=10 +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH [Install] WantedBy=multi-user.target diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4f353f6ae..2cea7df7b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'coverage']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/frontend/src/__tests__/api/githubBackupApi.test.ts b/frontend/src/__tests__/api/githubBackupApi.test.ts index 851ba074b..d134ae3bd 100644 --- a/frontend/src/__tests__/api/githubBackupApi.test.ts +++ b/frontend/src/__tests__/api/githubBackupApi.test.ts @@ -2,7 +2,7 @@ * Tests for the GitHub Backup API client functions. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import type { diff --git a/frontend/src/__tests__/components/EditArchiveModal.test.tsx b/frontend/src/__tests__/components/EditArchiveModal.test.tsx index d852bcf66..73718efca 100644 --- a/frontend/src/__tests__/components/EditArchiveModal.test.tsx +++ b/frontend/src/__tests__/components/EditArchiveModal.test.tsx @@ -38,7 +38,11 @@ describe('EditArchiveModal', () => { return HttpResponse.json(mockProjects); }), http.get('/api/v1/archives/tags', () => { - return HttpResponse.json(['test', 'calibration', 'functional']); + return HttpResponse.json([ + { name: 'test', count: 2 }, + { name: 'calibration', count: 1 }, + { name: 'functional', count: 3 }, + ]); }), http.patch('/api/v1/archives/:id', async ({ request }) => { const body = await request.json(); diff --git a/frontend/src/__tests__/components/NotificationProviderCard.test.tsx b/frontend/src/__tests__/components/NotificationProviderCard.test.tsx index ffed64ee1..00d768c05 100644 --- a/frontend/src/__tests__/components/NotificationProviderCard.test.tsx +++ b/frontend/src/__tests__/components/NotificationProviderCard.test.tsx @@ -63,6 +63,14 @@ const createMockProvider = ( on_ams_temperature_high: false, on_ams_ht_humidity_high: false, on_ams_ht_temperature_high: false, + on_plate_not_empty: true, + on_queue_job_added: false, + on_queue_job_assigned: false, + on_queue_job_started: false, + on_queue_job_waiting: true, + on_queue_job_skipped: true, + on_queue_job_failed: true, + on_queue_completed: false, quiet_hours_enabled: false, quiet_hours_start: null, quiet_hours_end: null, @@ -282,3 +290,83 @@ describe('NotificationProviderCard AMS toggles', () => { }); }); }); + +describe('NotificationProviderCard Queue notifications', () => { + describe('queue job notifications', () => { + it('includes on_queue_job_added in provider data', () => { + const provider = createMockProvider({ on_queue_job_added: true }); + expect(provider.on_queue_job_added).toBe(true); + }); + + it('includes on_queue_job_assigned in provider data', () => { + const provider = createMockProvider({ on_queue_job_assigned: true }); + expect(provider.on_queue_job_assigned).toBe(true); + }); + + it('includes on_queue_job_started in provider data', () => { + const provider = createMockProvider({ on_queue_job_started: true }); + expect(provider.on_queue_job_started).toBe(true); + }); + + it('includes on_queue_job_waiting in provider data', () => { + const provider = createMockProvider({ on_queue_job_waiting: true }); + expect(provider.on_queue_job_waiting).toBe(true); + }); + + it('includes on_queue_job_skipped in provider data', () => { + const provider = createMockProvider({ on_queue_job_skipped: true }); + expect(provider.on_queue_job_skipped).toBe(true); + }); + + it('includes on_queue_job_failed in provider data', () => { + const provider = createMockProvider({ on_queue_job_failed: true }); + expect(provider.on_queue_job_failed).toBe(true); + }); + + it('includes on_queue_completed in provider data', () => { + const provider = createMockProvider({ on_queue_completed: true }); + expect(provider.on_queue_completed).toBe(true); + }); + }); + + describe('queue notification defaults', () => { + it('defaults actionable notifications to true', () => { + const provider = createMockProvider(); + // These should default to true (actionable - user needs to do something) + expect(provider.on_queue_job_waiting).toBe(true); + expect(provider.on_queue_job_skipped).toBe(true); + expect(provider.on_queue_job_failed).toBe(true); + }); + + it('defaults informational notifications to false', () => { + const provider = createMockProvider(); + // These should default to false (informational only) + expect(provider.on_queue_job_added).toBe(false); + expect(provider.on_queue_job_assigned).toBe(false); + expect(provider.on_queue_job_started).toBe(false); + expect(provider.on_queue_completed).toBe(false); + }); + }); + + describe('queue notification combinations', () => { + it('supports all queue toggles independently', () => { + const provider = createMockProvider({ + on_queue_job_added: true, + on_queue_job_assigned: false, + on_queue_job_started: true, + on_queue_job_waiting: false, + on_queue_job_skipped: true, + on_queue_job_failed: false, + on_queue_completed: true, + }); + + expect(provider.on_queue_job_added).toBe(true); + expect(provider.on_queue_job_assigned).toBe(false); + expect(provider.on_queue_job_started).toBe(true); + expect(provider.on_queue_job_waiting).toBe(false); + expect(provider.on_queue_job_skipped).toBe(true); + expect(provider.on_queue_job_failed).toBe(false); + expect(provider.on_queue_completed).toBe(true); + }); + }); +}); diff --git a/frontend/src/__tests__/components/SmartPlugCard.test.tsx b/frontend/src/__tests__/components/SmartPlugCard.test.tsx index 561d7cc0b..51d43f5e3 100644 --- a/frontend/src/__tests__/components/SmartPlugCard.test.tsx +++ b/frontend/src/__tests__/components/SmartPlugCard.test.tsx @@ -21,6 +21,24 @@ const createMockPlug = (overrides: Partial = {}): SmartPlug => ({ plug_type: 'tasmota', ip_address: '192.168.1.100', ha_entity_id: null, + ha_power_entity: null, + ha_energy_today_entity: null, + ha_energy_total_entity: null, + // MQTT fields (legacy) + mqtt_topic: null, + mqtt_multiplier: 1.0, + // MQTT power fields + mqtt_power_topic: null, + mqtt_power_path: null, + mqtt_power_multiplier: 1.0, + // MQTT energy fields + mqtt_energy_topic: null, + mqtt_energy_path: null, + mqtt_energy_multiplier: 1.0, + // MQTT state fields + mqtt_state_topic: null, + mqtt_state_path: null, + mqtt_state_on_value: null, printer_id: 1, enabled: true, auto_on: true, @@ -272,4 +290,73 @@ describe('SmartPlugCard', () => { expect(buttons.length).toBeGreaterThan(0); }); }); + + describe('MQTT plugs', () => { + it('renders MQTT plug with topic instead of IP', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'zigbee2mqtt/shelly-power', + mqtt_power_path: 'power_l1', + }); + render(); + + // Should show topic, not IP + expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument(); + expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument(); + }); + + it('renders MQTT plug name correctly', () => { + const plug = createMockPlug({ + name: 'MQTT Energy Monitor', + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'sensors/power', + mqtt_power_path: 'power', + }); + render(); + + expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument(); + }); + + it('shows Monitor Only badge for MQTT plug', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + expect(screen.getByText('Monitor Only')).toBeInTheDocument(); + }); + + it('does not show power control buttons for MQTT plug', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + // On/Off buttons should not be present for monitor-only plugs + expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument(); + }); + + it('shows Settings instead of Automation Settings for MQTT plug', async () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + // Should show "Settings" not "Automation Settings" + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/components/TagManagementModal.test.tsx b/frontend/src/__tests__/components/TagManagementModal.test.tsx new file mode 100644 index 000000000..db064d193 --- /dev/null +++ b/frontend/src/__tests__/components/TagManagementModal.test.tsx @@ -0,0 +1,300 @@ +/** + * Tests for the TagManagementModal component. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { TagManagementModal } from '../../components/TagManagementModal'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const mockTags = [ + { name: 'functional', count: 5 }, + { name: 'calibration', count: 3 }, + { name: 'test', count: 2 }, + { name: 'art', count: 1 }, +]; + +describe('TagManagementModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.get('/api/v1/archives/tags', () => { + return HttpResponse.json(mockTags); + }), + http.put('/api/v1/archives/tags/:tagName', async () => { + return HttpResponse.json({ affected: 2 }); + }), + http.delete('/api/v1/archives/tags/:tagName', () => { + return HttpResponse.json({ affected: 1 }); + }) + ); + }); + + describe('rendering', () => { + it('renders the modal title', async () => { + render(); + + expect(screen.getByText('Manage Tags')).toBeInTheDocument(); + }); + + it('shows loading state initially', () => { + render(); + + // Should show loading spinner before data loads + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + + it('displays tags with counts', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('calibration')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + }); + + it('shows total tag count and usage', async () => { + render(); + + await waitFor(() => { + // 4 tags, 11 total usages + expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument(); + }); + }); + }); + + describe('search functionality', () => { + it('filters tags by search input', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search tags...'); + await user.type(searchInput, 'cal'); + + await waitFor(() => { + expect(screen.getByText('calibration')).toBeInTheDocument(); + expect(screen.queryByText('functional')).not.toBeInTheDocument(); + expect(screen.queryByText('art')).not.toBeInTheDocument(); + }); + }); + + it('shows no results message when search has no matches', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search tags...'); + await user.type(searchInput, 'nonexistent'); + + await waitFor(() => { + expect(screen.getByText('No tags match your search')).toBeInTheDocument(); + }); + }); + }); + + describe('sorting', () => { + it('sorts by count by default', async () => { + render(); + + await waitFor(() => { + const tagElements = screen.getAllByText(/functional|calibration|test|art/); + // First should be functional (count 5) + expect(tagElements[0]).toHaveTextContent('functional'); + }); + }); + + it('can sort by name', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const sortSelect = screen.getByDisplayValue('Sort by Count'); + await user.selectOptions(sortSelect, 'name'); + + await waitFor(() => { + const tagElements = screen.getAllByText(/functional|calibration|test|art/); + // First should be 'art' alphabetically + expect(tagElements[0]).toHaveTextContent('art'); + }); + }); + }); + + describe('rename functionality', () => { + it('enters edit mode when clicking edit button', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + // Find the tag row and click its edit button + const tagRow = screen.getByText('functional').closest('div'); + const editButton = within(tagRow!).getByTitle('Rename tag'); + await user.click(editButton); + + // Should show input with current value + await waitFor(() => { + expect(screen.getByDisplayValue('functional')).toBeInTheDocument(); + }); + }); + + it('submits rename on Enter key', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const editButton = within(tagRow!).getByTitle('Rename tag'); + await user.click(editButton); + + const input = screen.getByDisplayValue('functional'); + await user.clear(input); + await user.type(input, 'new-name{Enter}'); + + // Should show success (mutation called) + await waitFor(() => { + // After successful rename, edit mode should close + expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument(); + }); + }); + + it('cancels edit on Escape key', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const editButton = within(tagRow!).getByTitle('Rename tag'); + await user.click(editButton); + + const input = screen.getByDisplayValue('functional'); + await user.type(input, '-modified{Escape}'); + + // Should exit edit mode without saving + await waitFor(() => { + expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument(); + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + }); + }); + + describe('delete functionality', () => { + it('shows delete confirmation when clicking delete button', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const deleteButton = within(tagRow!).getByTitle('Delete tag'); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument(); + }); + }); + + it('cancels delete confirmation on X button', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const deleteButton = within(tagRow!).getByTitle('Delete tag'); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument(); + }); + + // Find the confirmation row and click the cancel (X) button within it + const confirmationText = screen.getByText(/Delete "functional"/i); + const confirmationRow = confirmationText.closest('div'); + // The X button is the last button in the confirmation row + const buttons = within(confirmationRow!.parentElement!).getAllByRole('button'); + const cancelButton = buttons[buttons.length - 1]; // X button is last + await user.click(cancelButton); + + await waitFor(() => { + // Should return to normal display - the tag name should be visible again + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + }); + }); + + describe('modal behavior', () => { + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Manage Tags')).toBeInTheDocument(); + }); + + // Find close button in header (X icon) + const headerCloseButton = screen.getAllByRole('button')[0]; + await user.click(headerCloseButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Manage Tags')).toBeInTheDocument(); + }); + + const closeButton = screen.getByRole('button', { name: /close/i }); + await user.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty state', () => { + it('shows empty message when no tags exist', async () => { + server.use( + http.get('/api/v1/archives/tags', () => { + return HttpResponse.json([]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('No tags found')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/__tests__/components/UploadModal.test.tsx b/frontend/src/__tests__/components/UploadModal.test.tsx index 424961348..70cd09e1e 100644 --- a/frontend/src/__tests__/components/UploadModal.test.tsx +++ b/frontend/src/__tests__/components/UploadModal.test.tsx @@ -10,20 +10,12 @@ import { UploadModal } from '../../components/UploadModal'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; -const mockPrinters = [ - { id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' }, - { id: 2, name: 'P1S', model: 'P1S', serial_number: '456' }, -]; - describe('UploadModal', () => { const mockOnClose = vi.fn(); beforeEach(() => { vi.clearAllMocks(); server.use( - http.get('/api/v1/printers/', () => { - return HttpResponse.json(mockPrinters); - }), http.post('/api/v1/archives/upload-bulk', async () => { return HttpResponse.json({ uploaded: 1, @@ -54,17 +46,6 @@ describe('UploadModal', () => { expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument(); }); - it('renders printer selection dropdown', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument(); - }); - - const select = screen.getByRole('combobox'); - expect(select).toBeInTheDocument(); - }); - it('renders Cancel button', () => { render(); @@ -79,32 +60,6 @@ describe('UploadModal', () => { }); }); - describe('printer selection', () => { - it('shows available printers in dropdown', async () => { - render(); - - await waitFor(() => { - // Check for printer options in the select - expect(screen.getByRole('option', { name: 'No printer' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'P1S' })).toBeInTheDocument(); - }); - }); - - it('allows selecting a printer', async () => { - render(); - - await waitFor(() => { - expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument(); - }); - - const select = screen.getByRole('combobox'); - fireEvent.change(select, { target: { value: '1' } }); - - expect(select).toHaveValue('1'); - }); - }); - describe('file handling with initialFiles', () => { it('shows initial files when provided', () => { const initialFiles = [ diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx index d6951be14..b6cb6f7ae 100644 --- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx +++ b/frontend/src/__tests__/pages/FileManagerPage.test.tsx @@ -347,8 +347,8 @@ describe('FileManagerPage', () => { render(); await waitFor(() => { - // Sort dropdown should show Date as default option - expect(screen.getByDisplayValue('Date')).toBeInTheDocument(); + // Sort dropdown should show Name as default option (persisted to localStorage) + expect(screen.getByDisplayValue('Name')).toBeInTheDocument(); }); }); }); @@ -476,4 +476,200 @@ describe('FileManagerPage', () => { }); }); }); + + describe('STL thumbnail generation', () => { + it('shows Generate Thumbnails button', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument(); + }); + }); + + it('Generate Thumbnails button has correct title', async () => { + render(); + + await waitFor(() => { + const button = screen.getByTitle('Generate thumbnails for STL files missing them'); + expect(button).toBeInTheDocument(); + }); + }); + + it('can click Generate Thumbnails button', async () => { + const user = userEvent.setup(); + + server.use( + http.post('/api/v1/library/generate-stl-thumbnails', () => { + return HttpResponse.json({ + processed: 1, + succeeded: 1, + failed: 0, + results: [{ file_id: 2, success: true }], + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument(); + }); + + const button = screen.getByText('Generate Thumbnails'); + await user.click(button); + + // Button should work without error + await waitFor(() => { + expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument(); + }); + }); + + it('shows STL file without thumbnail in file list', async () => { + render(); + + await waitFor(() => { + // bracket.stl has no thumbnail_path + expect(screen.getByText('bracket.stl')).toBeInTheDocument(); + expect(screen.getAllByText('STL').length).toBeGreaterThan(0); + }); + }); + }); + + describe('upload modal STL options', () => { + it('opens upload modal', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Upload')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Upload')); + + await waitFor(() => { + expect(screen.getByText('Upload Files')).toBeInTheDocument(); + expect(screen.getByText(/Drag & drop/)).toBeInTheDocument(); + }); + }); + + it('shows STL thumbnail option when STL file is added', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Upload')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Upload')); + + await waitFor(() => { + expect(screen.getByText('Upload Files')).toBeInTheDocument(); + }); + + // Create a mock STL file + const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' }); + + // Get the hidden file input + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(fileInput).toBeInTheDocument(); + + // Simulate file selection + await user.upload(fileInput, stlFile); + + // STL thumbnail option should appear + await waitFor(() => { + expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument(); + expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument(); + }); + }); + + it('STL thumbnail checkbox is checked by default', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Upload')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Upload')); + + await waitFor(() => { + expect(screen.getByText('Upload Files')).toBeInTheDocument(); + }); + + // Add an STL file + const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, stlFile); + + await waitFor(() => { + expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument(); + }); + + // Checkbox should be checked by default + const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i }); + expect(checkbox).toBeChecked(); + }); + + it('can toggle STL thumbnail checkbox', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Upload')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Upload')); + + await waitFor(() => { + expect(screen.getByText('Upload Files')).toBeInTheDocument(); + }); + + // Add an STL file + const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, stlFile); + + await waitFor(() => { + expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument(); + }); + + const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i }); + expect(checkbox).toBeChecked(); + + // Toggle off + await user.click(checkbox); + expect(checkbox).not.toBeChecked(); + + // Toggle back on + await user.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + it('shows STL thumbnail option for ZIP files', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Upload')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Upload')); + + await waitFor(() => { + expect(screen.getByText('Upload Files')).toBeInTheDocument(); + }); + + // Create a mock ZIP file + const zipFile = new File(['PK'], 'models.zip', { type: 'application/zip' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, zipFile); + + // STL thumbnail option should appear for ZIP files (may contain STLs) + await waitFor(() => { + expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument(); + expect(screen.getByText(/ZIP files may contain STL files/)).toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 75eea481c..c29d1d1e9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -300,6 +300,7 @@ export interface Archive { nozzle_diameter: number | null; bed_temperature: number | null; nozzle_temperature: number | null; + sliced_for_model: string | null; // Printer model this file was sliced for status: string; started_at: string | null; completed_at: string | null; @@ -334,6 +335,11 @@ export interface ArchiveStats { total_energy_cost: number; } +export interface TagInfo { + name: string; + count: number; +} + export interface FailureAnalysis { period_days: number; total_prints: number; @@ -840,13 +846,29 @@ export interface CloudDevice { export interface SmartPlug { id: number; name: string; - plug_type: 'tasmota' | 'homeassistant'; + plug_type: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address: string | null; // Required for Tasmota - ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug") + ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer") // Home Assistant energy sensor entities (optional) ha_power_entity: string | null; ha_energy_today_entity: string | null; ha_energy_total_entity: string | null; + // MQTT fields (required when plug_type="mqtt") + // Legacy field - kept for backward compatibility + mqtt_topic: string | null; // Deprecated, use mqtt_power_topic + mqtt_multiplier: number; // Deprecated, use mqtt_power_multiplier + // Power monitoring + mqtt_power_topic: string | null; // Topic for power data + mqtt_power_path: string | null; // e.g., "power_l1" or "data.power" + mqtt_power_multiplier: number; // Unit conversion for power + // Energy monitoring + mqtt_energy_topic: string | null; // Topic for energy data + mqtt_energy_path: string | null; // e.g., "energy_l1" + mqtt_energy_multiplier: number; // Unit conversion for energy + // State monitoring + mqtt_state_topic: string | null; // Topic for state data + mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF + mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1") printer_id: number | null; enabled: boolean; auto_on: boolean; @@ -865,8 +887,9 @@ export interface SmartPlug { schedule_enabled: boolean; schedule_on_time: string | null; schedule_off_time: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar: boolean; + show_on_printer_card: boolean; // For scripts: show on printer card // Status last_state: string | null; last_checked: string | null; @@ -877,13 +900,29 @@ export interface SmartPlug { export interface SmartPlugCreate { name: string; - plug_type?: 'tasmota' | 'homeassistant'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address?: string | null; // Required for Tasmota ha_entity_id?: string | null; // Required for Home Assistant // Home Assistant energy sensor entities (optional) ha_power_entity?: string | null; ha_energy_today_entity?: string | null; ha_energy_total_entity?: string | null; + // MQTT fields (required when plug_type="mqtt") + // Legacy fields - kept for backward compatibility + mqtt_topic?: string | null; + mqtt_multiplier?: number; + // Power monitoring + mqtt_power_topic?: string | null; + mqtt_power_path?: string | null; + mqtt_power_multiplier?: number; + // Energy monitoring + mqtt_energy_topic?: string | null; + mqtt_energy_path?: string | null; + mqtt_energy_multiplier?: number; + // State monitoring + mqtt_state_topic?: string | null; + mqtt_state_path?: string | null; + mqtt_state_on_value?: string | null; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -901,19 +940,35 @@ export interface SmartPlugCreate { schedule_enabled?: boolean; schedule_on_time?: string | null; schedule_off_time?: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar?: boolean; + show_on_printer_card?: boolean; } export interface SmartPlugUpdate { name?: string; - plug_type?: 'tasmota' | 'homeassistant'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address?: string | null; ha_entity_id?: string | null; // Home Assistant energy sensor entities (optional) ha_power_entity?: string | null; ha_energy_today_entity?: string | null; ha_energy_total_entity?: string | null; + // MQTT fields (legacy) + mqtt_topic?: string | null; + mqtt_multiplier?: number; + // MQTT power fields + mqtt_power_topic?: string | null; + mqtt_power_path?: string | null; + mqtt_power_multiplier?: number; + // MQTT energy fields + mqtt_energy_topic?: string | null; + mqtt_energy_path?: string | null; + mqtt_energy_multiplier?: number; + // MQTT state fields + mqtt_state_topic?: string | null; + mqtt_state_path?: string | null; + mqtt_state_on_value?: string | null; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -931,8 +986,9 @@ export interface SmartPlugUpdate { schedule_enabled?: boolean; schedule_on_time?: string | null; schedule_off_time?: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar?: boolean; + show_on_printer_card?: boolean; } // Home Assistant entity for smart plug selection @@ -940,7 +996,7 @@ export interface HAEntity { entity_id: string; friendly_name: string; state: string | null; - domain: string; // "switch", "light", "input_boolean" + domain: string; // "switch", "light", "input_boolean", "script" } // Home Assistant sensor entity for energy monitoring @@ -1001,6 +1057,9 @@ export interface DiscoveredTasmotaDevice { export interface PrintQueueItem { id: number; printer_id: number | null; // null = unassigned + target_model: string | null; // Target printer model for model-based assignment + required_filament_types: string[] | null; // Required filament types for model-based assignment + waiting_reason: string | null; // Why a model-based job hasn't started yet // Either archive_id OR library_file_id must be set (archive created at print start) archive_id: number | null; library_file_id: number | null; @@ -1033,6 +1092,7 @@ export interface PrintQueueItem { export interface PrintQueueItemCreate { printer_id?: number | null; // null = unassigned + target_model?: string | null; // Target printer model (mutually exclusive with printer_id) // Either archive_id OR library_file_id must be provided archive_id?: number | null; library_file_id?: number | null; @@ -1053,6 +1113,7 @@ export interface PrintQueueItemCreate { export interface PrintQueueItemUpdate { printer_id?: number | null; // null = unassign + target_model?: string | null; // Target printer model (mutually exclusive with printer_id) position?: number; scheduled_time?: string | null; require_previous_success?: boolean; @@ -1212,6 +1273,14 @@ export interface NotificationProvider { on_ams_ht_temperature_high: boolean; // Build plate detection on_plate_not_empty: boolean; + // Print queue events + on_queue_job_added: boolean; + on_queue_job_assigned: boolean; + on_queue_job_started: boolean; + on_queue_job_waiting: boolean; + on_queue_job_skipped: boolean; + on_queue_job_failed: boolean; + on_queue_completed: boolean; // Quiet hours quiet_hours_enabled: boolean; quiet_hours_start: string | null; @@ -1254,6 +1323,14 @@ export interface NotificationProviderCreate { on_ams_ht_temperature_high?: boolean; // Build plate detection on_plate_not_empty?: boolean; + // Print queue events + on_queue_job_added?: boolean; + on_queue_job_assigned?: boolean; + on_queue_job_started?: boolean; + on_queue_job_waiting?: boolean; + on_queue_job_skipped?: boolean; + on_queue_job_failed?: boolean; + on_queue_completed?: boolean; // Quiet hours quiet_hours_enabled?: boolean; quiet_hours_start?: string | null; @@ -1289,6 +1366,14 @@ export interface NotificationProviderUpdate { on_ams_ht_temperature_high?: boolean; // Build plate detection on_plate_not_empty?: boolean; + // Print queue events + on_queue_job_added?: boolean; + on_queue_job_assigned?: boolean; + on_queue_job_started?: boolean; + on_queue_job_waiting?: boolean; + on_queue_job_skipped?: boolean; + on_queue_job_failed?: boolean; + on_queue_completed?: boolean; // Quiet hours quiet_hours_enabled?: boolean; quiet_hours_start?: string | null; @@ -1901,6 +1986,17 @@ export const api = { deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), getArchiveStats: () => request('/archives/stats'), + // Tag management + getTags: () => request('/archives/tags'), + renameTag: (oldName: string, newName: string) => + request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, { + method: 'PUT', + body: JSON.stringify({ new_name: newName }), + }), + deleteTag: (name: string) => + request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, { + method: 'DELETE', + }), recalculateCosts: () => request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }), getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => { @@ -2394,6 +2490,7 @@ export const api = { getSmartPlugs: () => request('/smart-plugs/'), getSmartPlug: (id: number) => request(`/smart-plugs/${id}`), getSmartPlugByPrinter: (printerId: number) => request(`/smart-plugs/by-printer/${printerId}`), + getScriptPlugsByPrinter: (printerId: number) => request(`/smart-plugs/by-printer/${printerId}/scripts`), createSmartPlug: (data: SmartPlugCreate) => request('/smart-plugs/', { method: 'POST', @@ -2999,11 +3096,17 @@ export const api = { return request(`/library/files?${params}`); }, getLibraryFile: (id: number) => request(`/library/files/${id}`), - uploadLibraryFile: async (file: File, folderId?: number | null): Promise => { + uploadLibraryFile: async ( + file: File, + folderId?: number | null, + generateStlThumbnails: boolean = true + ): Promise => { const formData = new FormData(); formData.append('file', file); - const params = folderId ? `?folder_id=${folderId}` : ''; - const response = await fetch(`${API_BASE}/library/files${params}`, { + const params = new URLSearchParams(); + if (folderId) params.set('folder_id', String(folderId)); + params.set('generate_stl_thumbnails', String(generateStlThumbnails)); + const response = await fetch(`${API_BASE}/library/files?${params}`, { method: 'POST', body: formData, }); @@ -3017,7 +3120,8 @@ export const api = { file: File, folderId?: number | null, preserveStructure: boolean = true, - createFolderFromZip: boolean = false + createFolderFromZip: boolean = false, + generateStlThumbnails: boolean = true ): Promise => { const formData = new FormData(); formData.append('file', file); @@ -3025,6 +3129,7 @@ export const api = { if (folderId) params.set('folder_id', String(folderId)); params.set('preserve_structure', String(preserveStructure)); params.set('create_folder_from_zip', String(createFolderFromZip)); + params.set('generate_stl_thumbnails', String(generateStlThumbnails)); const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, { method: 'POST', body: formData, @@ -3058,6 +3163,15 @@ export const api = { body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }), }), getLibraryStats: () => request('/library/stats'), + batchGenerateStlThumbnails: (options: { + file_ids?: number[]; + folder_id?: number; + all_missing?: boolean; + }) => + request('/library/generate-stl-thumbnails', { + method: 'POST', + body: JSON.stringify(options), + }), addLibraryFilesToQueue: (fileIds: number[]) => request('/library/files/add-to-queue', { method: 'POST', @@ -3382,6 +3496,21 @@ export interface ZipExtractResponse { errors: ZipExtractError[]; } +// STL Thumbnail Generation types +export interface BatchThumbnailResult { + file_id: number; + filename: string; + success: boolean; + error?: string | null; +} + +export interface BatchThumbnailResponse { + processed: number; + succeeded: number; + failed: number; + results: BatchThumbnailResult[]; +} + // Library Queue types export interface AddToQueueResult { file_id: number; diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx index bba6c11d0..f5436ff35 100644 --- a/frontend/src/components/AddSmartPlugModal.tsx +++ b/frontend/src/components/AddSmartPlugModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react'; +import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client'; import { Button } from './Button'; @@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const isEditing = !!plug; // Plug type selection - const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota'); + const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota'); const [name, setName] = useState(plug?.name || ''); // Tasmota fields @@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const [password, setPassword] = useState(plug?.password || ''); // Home Assistant fields const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || ''); + // MQTT fields - Power + const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || ''); + const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || ''); + const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState( + (plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString() + ); + // MQTT fields - Energy + const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || ''); + const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || ''); + const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState( + (plug?.mqtt_energy_multiplier ?? 1).toString() + ); + // MQTT fields - State + const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || ''); + const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || ''); + const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || ''); // HA energy sensor entities (optional) const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || ''); const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || ''); @@ -279,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { return; } + if (plugType === 'mqtt') { + // Check that at least one topic is configured (path is optional) + const hasPower = mqttPowerTopic.trim(); + const hasEnergy = mqttEnergyTopic.trim(); + const hasState = mqttStateTopic.trim(); + + if (!hasPower && !hasEnergy && !hasState) { + setError('At least one MQTT topic must be configured for power, energy, or state monitoring'); + return; + } + } + const data = { name: name.trim(), plug_type: plugType, @@ -288,6 +316,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null, ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null, ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null, + // MQTT power fields + mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null, + mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null, + mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1, + // MQTT energy fields + mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null, + mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null, + mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1, + // MQTT state fields + mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null, + mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null, + mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null, username: plugType === 'tasmota' ? (username.trim() || null) : null, password: plugType === 'tasmota' ? (password.trim() || null) : null, printer_id: printerId, @@ -352,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { setTestResult(null); setError(null); }} - className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${ plugType === 'tasmota' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary' @@ -368,14 +408,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { setTestResult(null); setError(null); }} - className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${ plugType === 'homeassistant' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary' }`} > - Home Assistant + HA + + )} @@ -857,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} + {/* MQTT Configuration - only show when MQTT is selected */} + {plugType === 'mqtt' && ( +
+ {/* MQTT broker not configured */} + {!settings?.mqtt_broker && ( +
+ MQTT broker not configured. Set broker address in{' '} + Settings → Network → MQTT Publishing + {' '}(you don't need to enable publishing, just fill in the broker details). +
+ )} + + {/* MQTT broker configured - show fields */} + {settings?.mqtt_broker && ( + <> +
+

Monitor Only

+

+ MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system. +

+
+ + {/* Power Section */} +
+

Power Monitoring

+
+ + setMqttPowerTopic(e.target.value)} + placeholder="zigbee2mqtt/shelly-working-room" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+
+ + setMqttPowerPath(e.target.value)} + placeholder="power_l1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setMqttPowerMultiplier(e.target.value)} + placeholder="1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+

+ JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.
+ Use multiplier 0.001 for mW→W, 1000 for kW→W. +

+
+ + {/* Energy Section */} +
+

Energy Monitoring (optional)

+
+ + setMqttEnergyTopic(e.target.value)} + placeholder="Same as power topic, or different" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+
+ + setMqttEnergyPath(e.target.value)} + placeholder="energy_l1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setMqttEnergyMultiplier(e.target.value)} + placeholder="1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+

+ JSON path extracts value from JSON payload. Leave empty for raw values.
+ Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh. +

+
+ + {/* State Section */} +
+

State Monitoring (optional)

+
+ + setMqttStateTopic(e.target.value)} + placeholder="Same as power topic, or different" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+
+ + setMqttStatePath(e.target.value)} + placeholder="state_l1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setMqttStateOnValue(e.target.value)} + placeholder="ON, true, 1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+

+ JSON path extracts value from JSON payload. Leave empty for raw values.
+ ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1). +

+
+ + )} +
+ )} + {/* IP Address - only show for Tasmota */} {plugType === 'tasmota' && (
@@ -959,25 +1164,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} - {/* Link to Printer */} -
- - -

- Linking enables automatic on/off when prints start/complete -

-
+ {/* Link to Printer - not shown for MQTT plugs (monitor-only) */} + {plugType !== 'mqtt' && ( +
+ + +

+ Linking enables automatic on/off when prints start/complete +

+
+ )} {/* Power Alerts */}
@@ -1031,51 +1238,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )}
- {/* Schedule */} -
-
-
- - Daily Schedule -
- -
- {scheduleEnabled && ( -
-
-
- - setScheduleOnTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
-
- - setScheduleOffTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
+ {/* Schedule - not shown for MQTT plugs (monitor-only) */} + {plugType !== 'mqtt' && ( +
+
+
+ + Daily Schedule
-

- Automatically turn the plug on/off at these times daily. Leave empty to skip that action. -

+
- )} -
+ {scheduleEnabled && ( +
+
+
+ + setScheduleOnTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+ + setScheduleOffTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+

+ Automatically turn the plug on/off at these times daily. Leave empty to skip that action. +

+
+ )} +
+ )} {/* Switchbar Visibility */}
diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 2de8e3ac7..22f6ddc88 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr queryFn: () => api.getProjects(), }); - // Get all archives to extract existing tags if not provided - const { data: archives } = useQuery({ - queryKey: ['archives'], - queryFn: () => api.getArchives(undefined, 1000, 0), + // Fetch all tags using the dedicated API + const { data: tagsData } = useQuery({ + queryKey: ['tags'], + queryFn: api.getTags, enabled: existingTags.length === 0, }); - // Extract unique tags from all archives + // Use existing tags prop if provided, otherwise use fetched tags const allTags = existingTags.length > 0 ? existingTags - : [...new Set( - archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || [] - )].sort(); + : (tagsData?.map(t => t.name) || []); // Get current tags as array const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean); - // Filter suggestions based on what's not already added - const tagSuggestions = allTags.filter(t => !currentTags.includes(t)); + // Get the text being typed after the last comma (for autocomplete filtering) + const currentInput = tags.includes(',') + ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase() + : tags.trim().toLowerCase(); - // Add a tag + // Filter suggestions: not already added AND matches current input (if any) + const tagSuggestions = allTags.filter(t => + !currentTags.includes(t) && + (currentInput === '' || t.toLowerCase().includes(currentInput)) + ); + + // Add a tag (replaces any partial input with the selected tag) const addTag = (tag: string) => { - if (!currentTags.includes(tag)) { - const newTags = [...currentTags, tag].join(', '); + // If there's partial input being typed, replace it with the selected tag + // Otherwise, just append the tag + let baseTags: string[]; + if (currentInput && !allTags.includes(currentInput)) { + // User is typing a partial tag - replace it with the selected one + baseTags = tags.includes(',') + ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean) + : []; + } else { + // No partial input or input is already a complete tag - append + baseTags = currentTags; + } + + if (!baseTags.includes(tag)) { + const newTags = [...baseTags, tag].join(', '); setTags(newTags); } // Clear any pending blur timeout to prevent hiding suggestions @@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr {showTagSuggestions && tagSuggestions.length > 0 && (
- Existing tags (click to add) + {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
{tagSuggestions.map((tag) => ( diff --git a/frontend/src/components/NotificationProviderCard.tsx b/frontend/src/components/NotificationProviderCard.tsx index 15159cb60..01a7e96b5 100644 --- a/frontend/src/components/NotificationProviderCard.tsx +++ b/frontend/src/components/NotificationProviderCard.tsx @@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
+ {/* Print Queue Events */} +
+

Print Queue

+ +
+
+

Job Added

+

Job added to queue

+
+ updateMutation.mutate({ on_queue_job_added: checked })} + /> +
+ +
+
+

Job Assigned

+

Model-based job assigned to printer

+
+ updateMutation.mutate({ on_queue_job_assigned: checked })} + /> +
+ +
+
+

Job Started

+

Queue job started printing

+
+ updateMutation.mutate({ on_queue_job_started: checked })} + /> +
+ +
+
+

Job Waiting

+

Job waiting for filament

+
+ updateMutation.mutate({ on_queue_job_waiting: checked })} + /> +
+ +
+
+

Job Skipped

+

Job skipped (previous failed)

+
+ updateMutation.mutate({ on_queue_job_skipped: checked })} + /> +
+ +
+
+

Job Failed

+

Job failed to start

+
+ updateMutation.mutate({ on_queue_job_failed: checked })} + /> +
+ +
+
+

Queue Complete

+

All queue jobs finished

+
+ updateMutation.mutate({ on_queue_completed: checked })} + /> +
+
+ {/* Quiet Hours */}
diff --git a/frontend/src/components/PrintModal/PrinterSelector.tsx b/frontend/src/components/PrintModal/PrinterSelector.tsx index d363e1763..9b618c5b7 100644 --- a/frontend/src/components/PrintModal/PrinterSelector.tsx +++ b/frontend/src/components/PrintModal/PrinterSelector.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Printer as PrinterIcon, @@ -9,6 +9,7 @@ import { Circle, RefreshCw, Wand2, + Users, } from 'lucide-react'; import { api } from '../../api/client'; import { getColorName } from '../../utils/colors'; @@ -16,7 +17,7 @@ import { normalizeColorForCompare, colorsAreSimilar, } from '../../utils/amsHelpers'; -import type { PrinterSelectorProps } from './types'; +import type { PrinterSelectorProps, AssignmentMode } from './types'; import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping'; import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping'; @@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps { onAutoConfigurePrinter?: (printerId: number) => void; /** Callback to update printer config */ onUpdatePrinterConfig?: (printerId: number, config: Partial) => void; + /** Current assignment mode */ + assignmentMode?: AssignmentMode; + /** Handler for assignment mode change */ + onAssignmentModeChange?: (mode: AssignmentMode) => void; + /** Selected target model (when assignmentMode is 'model') */ + targetModel?: string | null; + /** Handler for target model change */ + onTargetModelChange?: (model: string | null) => void; + /** Suggested model from sliced file (for pre-selection) */ + slicedForModel?: string | null; } /** @@ -212,9 +223,42 @@ export function PrinterSelector({ filamentReqs, onAutoConfigurePrinter, onUpdatePrinterConfig, + assignmentMode = 'printer', + onAssignmentModeChange, + targetModel, + onTargetModelChange, + slicedForModel, }: PrinterSelectorWithMappingProps) { + // State for showing all printers vs only matching model + const [showAllPrinters, setShowAllPrinters] = useState(false); + // Filter printers based on showInactive flag - const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active); + const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active); + + // Filter by sliced model (only in printer mode, when slicedForModel is set) + const displayPrinters = useMemo(() => { + if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) { + return activePrinters; + } + // Filter to only show printers matching the sliced model + const matching = activePrinters.filter((p) => p.model === slicedForModel); + // If no matching printers, show all + return matching.length > 0 ? matching : activePrinters; + }, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]); + + // Check if there are hidden printers due to model filtering + const hiddenPrinterCount = activePrinters.length - displayPrinters.length; + + // Get unique models from available printers (for model-based assignment) + const uniqueModels = useMemo(() => { + const models = activePrinters + .map(p => p.model) + .filter((m): m is string => Boolean(m)); + return [...new Set(models)].sort(); + }, [activePrinters]); + + // Check if model-based assignment is available (need callbacks and multiple printers of same model) + const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0; const showMappingOptions = allowMultiple && selectedPrinterIds.length > 1 && @@ -285,8 +329,56 @@ export function PrinterSelector({ return (
- {/* Multi-select header */} - {allowMultiple && displayPrinters.length > 1 && ( + {/* Assignment mode toggle (model vs specific printer) */} + {modelAssignmentAvailable && ( +
+ + +
+ )} + + {/* Model info (when in model mode) */} + {assignmentMode === 'model' && modelAssignmentAvailable && targetModel && ( +

+ Scheduler will assign to first available idle {targetModel} printer +

+ )} + + {/* Multi-select header (only in printer mode) */} + {assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
{selectedCount === 0 @@ -316,7 +408,8 @@ export function PrinterSelector({
)} - {displayPrinters.map((printer) => { + {/* Printer list (only in printer mode) */} + {assignmentMode === 'printer' && displayPrinters.map((printer) => { const selected = isSelected(printer.id); const mappingResult = getPrinterMappingResult(printer.id); const hasOverride = mappingResult && !mappingResult.config.useDefault; @@ -430,13 +523,45 @@ export function PrinterSelector({ ); })} - {/* Warning when no printer selected */} - {selectedCount === 0 && ( + {/* Show hidden printers toggle */} + {assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && ( + + )} + + {/* Show matching only toggle */} + {assignmentMode === 'printer' && showAllPrinters && slicedForModel && ( + + )} + + {/* Warning when no printer selected (only in printer mode) */} + {assignmentMode === 'printer' && selectedCount === 0 && (

Select at least one printer

)} + + {/* Warning when no model selected (only in model mode) */} + {assignmentMode === 'model' && !targetModel && ( +

+ + Select a target printer model +

+ )}
); } diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 24c2908db..5096b5b65 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react'; +import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react'; import { api } from '../../api/client'; import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client'; import { Card, CardContent } from '../Card'; @@ -19,6 +19,7 @@ import type { PrintOptions, ScheduleOptions, ScheduleType, + AssignmentMode, } from './types'; import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types'; @@ -117,6 +118,23 @@ export function PrintModal({ // Per-printer override configs (for multi-printer selection) const [perPrinterConfigs, setPerPrinterConfigs] = useState>({}); + // Assignment mode: 'printer' (specific) or 'model' (any of model) + const [assignmentMode, setAssignmentMode] = useState(() => { + // Initialize from queue item if editing with target_model + if (mode === 'edit-queue-item' && queueItem?.target_model) { + return 'model'; + } + return 'printer'; + }); + + // Target model for model-based assignment + const [targetModel, setTargetModel] = useState(() => { + if (mode === 'edit-queue-item' && queueItem?.target_model) { + return queueItem.target_model; + } + return null; + }); + // Track initial values for clearing mappings on change (edit mode only) const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : [])); const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null)); @@ -145,6 +163,16 @@ export function PrintModal({ queryFn: api.getPrinters, }); + // Fetch archive details to get sliced_for_model + const { data: archiveDetails } = useQuery({ + queryKey: ['archive', archiveId], + queryFn: () => api.getArchive(archiveId!), + enabled: !!archiveId && !isLibraryFile, + }); + + // Get sliced_for_model from archive or library file + const slicedForModel = archiveDetails?.sliced_for_model || null; + // Fetch plates for archives const { data: archivePlatesData, isError: archivePlatesError } = useQuery({ queryKey: ['archive-plates', archiveId], @@ -304,14 +332,20 @@ export function PrintModal({ const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); - // Validate printer selection - if (selectedPrinters.length === 0) { + // Validate printer/model selection + if (assignmentMode === 'printer' && selectedPrinters.length === 0) { showToast('Please select at least one printer', 'error'); return; } + if (assignmentMode === 'model' && !targetModel) { + showToast('Please select a target printer model', 'error'); + return; + } setIsSubmitting(true); - setSubmitProgress({ current: 0, total: selectedPrinters.length }); + // For model-based assignment, we just make one API call + const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length; + setSubmitProgress({ current: 0, total: totalCount }); const results: { success: number; failed: number; errors: string[] } = { success: 0, @@ -332,15 +366,16 @@ export function PrintModal({ }; // Common queue data for add-to-queue and edit modes - const getQueueData = (printerId: number): PrintQueueItemCreate => ({ - printer_id: printerId, + const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({ + printer_id: assignmentMode === 'printer' ? printerId : null, + target_model: assignmentMode === 'model' ? targetModel : null, // Use library_file_id for library files, archive_id for archives archive_id: isLibraryFile ? undefined : archiveId, library_file_id: isLibraryFile ? libraryFileId : undefined, require_previous_success: scheduleOptions.requirePreviousSuccess, auto_off_after: scheduleOptions.autoOffAfter, manual_start: scheduleOptions.scheduleType === 'manual', - ams_mapping: getMappingForPrinter(printerId), + ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined, plate_id: selectedPlate, scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).toISOString() @@ -348,35 +383,24 @@ export function PrintModal({ ...printOptions, }); - for (let i = 0; i < selectedPrinters.length; i++) { - const printerId = selectedPrinters[i]; - setSubmitProgress({ current: i + 1, total: selectedPrinters.length }); - + // Model-based assignment: single API call + if (assignmentMode === 'model') { + setSubmitProgress({ current: 1, total: 1 }); try { if (mode === 'reprint') { - // Reprint mode - start print immediately - const printerMapping = getMappingForPrinter(printerId); - if (isLibraryFile) { - await api.printLibraryFile(libraryFileId!, printerId, { - ams_mapping: printerMapping, - ...printOptions, - }); - } else { - await api.reprintArchive(archiveId!, printerId, { - plate_id: selectedPlate ?? undefined, - ams_mapping: printerMapping, - ...printOptions, - }); - } - } else if (mode === 'edit-queue-item' && i === 0) { - // Edit mode - update the original queue item for the first printer - const printerMapping = getMappingForPrinter(printerId); + // Model-based reprint not supported (need specific printer for immediate print) + showToast('Model-based assignment only works with queue mode', 'error'); + setIsSubmitting(false); + return; + } else if (mode === 'edit-queue-item') { + // Edit mode - update with target_model const updateData: PrintQueueItemUpdate = { - printer_id: printerId, + printer_id: null, + target_model: targetModel, require_previous_success: scheduleOptions.requirePreviousSuccess, auto_off_after: scheduleOptions.autoOffAfter, manual_start: scheduleOptions.scheduleType === 'manual', - ams_mapping: printerMapping, + ams_mapping: undefined, plate_id: selectedPlate, scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).toISOString() @@ -385,14 +409,63 @@ export function PrintModal({ }; await updateQueueMutation.mutateAsync(updateData); } else { - // Add-to-queue mode OR edit mode with additional printers - await addToQueueMutation.mutateAsync(getQueueData(printerId)); + // Add-to-queue mode with model-based assignment + await addToQueueMutation.mutateAsync(getQueueData(null)); } results.success++; } catch (error) { results.failed++; - const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`; - results.errors.push(`${printerName}: ${(error as Error).message}`); + results.errors.push((error as Error).message); + } + } else { + // Printer-based assignment: loop through selected printers + for (let i = 0; i < selectedPrinters.length; i++) { + const printerId = selectedPrinters[i]; + setSubmitProgress({ current: i + 1, total: selectedPrinters.length }); + + try { + if (mode === 'reprint') { + // Reprint mode - start print immediately + const printerMapping = getMappingForPrinter(printerId); + if (isLibraryFile) { + await api.printLibraryFile(libraryFileId!, printerId, { + ams_mapping: printerMapping, + ...printOptions, + }); + } else { + await api.reprintArchive(archiveId!, printerId, { + plate_id: selectedPlate ?? undefined, + ams_mapping: printerMapping, + ...printOptions, + }); + } + } else if (mode === 'edit-queue-item' && i === 0) { + // Edit mode - update the original queue item for the first printer + const printerMapping = getMappingForPrinter(printerId); + const updateData: PrintQueueItemUpdate = { + printer_id: printerId, + target_model: null, + require_previous_success: scheduleOptions.requirePreviousSuccess, + auto_off_after: scheduleOptions.autoOffAfter, + manual_start: scheduleOptions.scheduleType === 'manual', + ams_mapping: printerMapping, + plate_id: selectedPlate, + scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime + ? new Date(scheduleOptions.scheduledTime).toISOString() + : null, + ...printOptions, + }; + await updateQueueMutation.mutateAsync(updateData); + } else { + // Add-to-queue mode OR edit mode with additional printers + await addToQueueMutation.mutateAsync(getQueueData(printerId)); + } + results.success++; + } catch (error) { + results.failed++; + const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`; + results.errors.push(`${printerName}: ${(error as Error).message}`); + } } } @@ -400,11 +473,15 @@ export function PrintModal({ // Show result toast if (results.failed === 0) { - const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for'); - if (results.success === 1) { - showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`); + if (assignmentMode === 'model') { + showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`); } else { - showToast(`Print ${action} ${results.success} printers`); + const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for'); + if (results.success === 1) { + showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`); + } else { + showToast(`Print ${action} ${results.success} printers`); + } } queryClient.invalidateQueries({ queryKey: ['queue'] }); onSuccess?.(); @@ -422,14 +499,18 @@ export function PrintModal({ const canSubmit = useMemo(() => { if (isPending) return false; - // Need at least one selected printer - if (selectedPrinters.length === 0) return false; + // Need valid printer/model selection + if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false; + if (assignmentMode === 'model' && !targetModel) return false; + + // Model-based assignment only works in queue modes (not immediate reprint) + if (assignmentMode === 'model' && mode === 'reprint') return false; // For multi-plate archive files, need a selected plate (library files skip this) if (!isLibraryFile && isMultiPlate && !selectedPlate) return false; return true; - }, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]); + }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]); // Modal title and action button text based on mode const getModalConfig = () => { @@ -541,8 +622,29 @@ export function PrintModal({ filamentReqs={effectiveFilamentReqs} onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter} onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig} + assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode} + onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined} + targetModel={targetModel} + onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined} + slicedForModel={slicedForModel} /> + {/* Compatibility warning when sliced model doesn't match selected printer */} + {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => { + const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]); + if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) { + return ( +
+ + + File was sliced for {slicedForModel}, but printing on {selectedPrinter.model} + +
+ ); + } + return null; + })()} + {/* Warning when archive data couldn't be loaded */} {archiveDataMissing && (
@@ -565,7 +667,7 @@ export function PrintModal({ )} {/* Print options */} - {(mode === 'reprint' || effectivePrinterCount > 0) && ( + {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && ( )} diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts index b48caa41e..d3e702cb9 100644 --- a/frontend/src/components/PrintModal/types.ts +++ b/frontend/src/components/PrintModal/types.ts @@ -104,6 +104,13 @@ export interface PlatesResponse { plates: PlateInfo[]; } +/** + * Assignment mode for queue items. + * - 'printer': Assign to specific printer(s) + * - 'model': Assign to any printer of a specific model (load balancing) + */ +export type AssignmentMode = 'printer' | 'model'; + /** * Props for the PrinterSelector component. */ @@ -115,6 +122,16 @@ export interface PrinterSelectorProps { allowMultiple?: boolean; /** Show inactive printers (for edit mode where original assignment may be inactive) */ showInactive?: boolean; + /** Current assignment mode */ + assignmentMode?: AssignmentMode; + /** Handler for assignment mode change */ + onAssignmentModeChange?: (mode: AssignmentMode) => void; + /** Selected target model (when assignmentMode is 'model') */ + targetModel?: string | null; + /** Handler for target model change */ + onTargetModelChange?: (model: string | null) => void; + /** Suggested model from sliced file (for pre-selection) */ + slicedForModel?: string | null; } /** diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx index 4ec49c610..0895dd0a1 100644 --- a/frontend/src/components/SmartPlugCard.tsx +++ b/frontend/src/components/SmartPlugCard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react'; +import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugUpdate } from '../api/client'; import { Card, CardContent } from './Card'; @@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { }); const isOn = status?.state === 'ON'; - const isReachable = status?.reachable ?? false; + // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable) + const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined); + const isReachable = (status?.reachable ?? false) || hasMqttData; const isPending = controlMutation.isPending; // Generate admin URL with auto-login credentials (Tasmota only) @@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {/* Header Row */} -
-
-
- {plug.plug_type === 'homeassistant' ? ( +
+
+
+ {plug.plug_type === 'mqtt' ? ( + + ) : plug.plug_type === 'homeassistant' ? ( ) : ( )}
-
-

{plug.name}

-

- {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address} +

+

{plug.name}

+

+ {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}

{/* Status indicator */} -
+
{statusLoading ? ( + ) : plug.plug_type === 'mqtt' ? ( + /* MQTT plugs - show badge and checkmark when receiving data */ +
+ MQTT + {isReachable && ✓} +
+ ) : plug.plug_type === 'homeassistant' ? ( +
+ HA + + {isReachable ? (status?.state || '?') : 'Offline'} + +
) : isReachable ? (
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { )} {/* Feature Badges */} - {(plug.power_alert_enabled || plug.schedule_enabled) && ( + {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
+ {plug.plug_type === 'mqtt' && ( + + + Monitor Only + + )} {plug.power_alert_enabled && ( @@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)} - {/* Quick Controls */} -
- - -
+ {/* Quick Controls - hidden for MQTT plugs (monitor-only) */} + {plug.plug_type !== 'mqtt' && ( +
+ + +
+ )} + + {/* Energy display for MQTT plugs */} + {plug.plug_type === 'mqtt' && status?.energy && ( +
+ {status.energy.power !== null && status.energy.power !== undefined && ( +
+

{Math.round(status.energy.power)}W

+

Power

+
+ )} + {status.energy.today !== null && status.energy.today !== undefined && ( +
+

{status.energy.today.toFixed(2)}

+

kWh Today

+
+ )} +
+ )} {/* Toggle Settings Panel */} @@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
- {/* Enabled Toggle */} -
-
-

Enabled

-

Enable automation for this plug

-
- -
+ {/* Automation controls - only for controllable plugs (not MQTT) */} + {plug.plug_type !== 'mqtt' && ( + <> + {/* Enabled Toggle */} +
+
+

Enabled

+

Enable automation for this plug

+
+ +
- {/* Auto On */} -
-
-

Auto On

-

Turn on when print starts

-
- -
+ {/* Auto On */} +
+
+

Auto On

+

Turn on when print starts

+
+ +
- {/* Auto Off */} -
-
-

Auto Off

-

Turn off when print completes (one-shot)

+ {/* Auto Off */} +
+
+

Auto Off

+

Turn off when print completes (one-shot)