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. Set broker address in{' '}
+ Settings → Network → MQTT Publishing
+ {' '}(you don't need to enable publishing, just fill in the broker details).
+
+ 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"
+ />
+
+ 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
+