mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
Merge pull request #189 from maziggy/feature/printer_model_queue
Feature/printer model queue
This commit is contained in:
@@ -90,6 +90,24 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Bulk edit: printer assignment, print options, queue options
|
||||
- Bulk cancel selected items
|
||||
- Tri-state toggles: unchanged / on / off for each setting
|
||||
- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162):
|
||||
- Extract printer model from sliced 3MF files (e.g., "X1C", "P1S")
|
||||
- Display sliced-for model in archive view
|
||||
- New queue mode: assign to model instead of specific printer
|
||||
- Scheduler auto-assigns to first idle printer of matching model
|
||||
- Filament validation: only assign to printers with required filament types loaded
|
||||
- Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)")
|
||||
- "Waiting" status badge (purple) distinguishes from regular "Pending"
|
||||
- Compatibility warnings when file/printer model mismatch
|
||||
- **Queue Notifications** - Get notified about print queue events:
|
||||
- Job Added: When a job is added to the queue
|
||||
- Job Assigned: When a model-based job is assigned to a printer
|
||||
- Job Started: When a queue job starts printing
|
||||
- Job Waiting: When a job is waiting for filament (enabled by default)
|
||||
- Job Skipped: When a job is skipped due to previous failure (enabled by default)
|
||||
- Job Failed: When a job fails to start (enabled by default)
|
||||
- Queue Complete: When all queued jobs finish
|
||||
- New "Print Queue" section in notification provider settings
|
||||
|
||||
### Fixes
|
||||
- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
### ⏰ 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)
|
||||
@@ -116,6 +118,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
|
||||
@@ -506,10 +509,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
---
|
||||
|
||||
If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
@@ -524,6 +523,10 @@ MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ for the 3D printing community
|
||||
<br><br>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -1176,6 +1183,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 +1220,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"),
|
||||
|
||||
@@ -760,6 +760,46 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add sliced_for_model column to print_archives for printer model from 3MF
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add target_model column to print_queue for model-based assignment
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add required_filament_types column to print_queue for filament validation
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add waiting_reason column to print_queue for status feedback
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add queue notification event columns to notification_providers
|
||||
queue_notification_columns = [
|
||||
("on_queue_job_added", "BOOLEAN DEFAULT 0"),
|
||||
("on_queue_job_assigned", "BOOLEAN DEFAULT 0"),
|
||||
("on_queue_job_started", "BOOLEAN DEFAULT 0"),
|
||||
("on_queue_job_waiting", "BOOLEAN DEFAULT 1"),
|
||||
("on_queue_job_skipped", "BOOLEAN DEFAULT 1"),
|
||||
("on_queue_job_failed", "BOOLEAN DEFAULT 1"),
|
||||
("on_queue_completed", "BOOLEAN DEFAULT 0"),
|
||||
]
|
||||
for col_name, col_def in queue_notification_columns:
|
||||
try:
|
||||
await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
@@ -1988,6 +1988,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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <plate><metadata key="printer_model_id" value="C11" /></plate>
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.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."""
|
||||
@@ -220,6 +427,25 @@ class PrintScheduler:
|
||||
logger.info(f"Auto-off: Powering off printer {item.printer_id}")
|
||||
await tasmota_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 +597,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 +647,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 +682,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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<UploadModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Cancel button', () => {
|
||||
render(<UploadModal onClose={mockOnClose} />);
|
||||
|
||||
@@ -79,32 +60,6 @@ describe('UploadModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('printer selection', () => {
|
||||
it('shows available printers in dropdown', async () => {
|
||||
render(<UploadModal onClose={mockOnClose} />);
|
||||
|
||||
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(<UploadModal onClose={mockOnClose} />);
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -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;
|
||||
@@ -1001,6 +1002,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 +1037,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 +1058,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 +1218,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 +1268,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 +1311,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;
|
||||
|
||||
@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print Queue Events */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Queue</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Job Added</p>
|
||||
<p className="text-xs text-bambu-gray">Job added to queue</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_job_added ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_job_added: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Job Assigned</p>
|
||||
<p className="text-xs text-bambu-gray">Model-based job assigned to printer</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_job_assigned ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_job_assigned: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Job Started</p>
|
||||
<p className="text-xs text-bambu-gray">Queue job started printing</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_job_started ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_job_started: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Job Waiting</p>
|
||||
<p className="text-xs text-bambu-gray">Job waiting for filament</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_job_waiting ?? true}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_job_waiting: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Job Skipped</p>
|
||||
<p className="text-xs text-bambu-gray">Job skipped (previous failed)</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_job_skipped ?? true}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_job_skipped: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Job Failed</p>
|
||||
<p className="text-xs text-bambu-gray">Job failed to start</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_job_failed ?? true}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_job_failed: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Queue Complete</p>
|
||||
<p className="text-xs text-bambu-gray">All queue jobs finished</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_queue_completed ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Printer as PrinterIcon,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Circle,
|
||||
RefreshCw,
|
||||
Wand2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
} from '../../utils/amsHelpers';
|
||||
import type { PrinterSelectorProps } from './types';
|
||||
import type { PrinterSelectorProps, AssignmentMode } from './types';
|
||||
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
|
||||
|
||||
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
|
||||
onAutoConfigurePrinter?: (printerId: number) => void;
|
||||
/** Callback to update printer config */
|
||||
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
|
||||
/** Current assignment mode */
|
||||
assignmentMode?: AssignmentMode;
|
||||
/** Handler for assignment mode change */
|
||||
onAssignmentModeChange?: (mode: AssignmentMode) => void;
|
||||
/** Selected target model (when assignmentMode is 'model') */
|
||||
targetModel?: string | null;
|
||||
/** Handler for target model change */
|
||||
onTargetModelChange?: (model: string | null) => void;
|
||||
/** Suggested model from sliced file (for pre-selection) */
|
||||
slicedForModel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,9 +223,42 @@ export function PrinterSelector({
|
||||
filamentReqs,
|
||||
onAutoConfigurePrinter,
|
||||
onUpdatePrinterConfig,
|
||||
assignmentMode = 'printer',
|
||||
onAssignmentModeChange,
|
||||
targetModel,
|
||||
onTargetModelChange,
|
||||
slicedForModel,
|
||||
}: PrinterSelectorWithMappingProps) {
|
||||
// State for showing all printers vs only matching model
|
||||
const [showAllPrinters, setShowAllPrinters] = useState(false);
|
||||
|
||||
// Filter printers based on showInactive flag
|
||||
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
|
||||
const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
|
||||
|
||||
// Filter by sliced model (only in printer mode, when slicedForModel is set)
|
||||
const displayPrinters = useMemo(() => {
|
||||
if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
|
||||
return activePrinters;
|
||||
}
|
||||
// Filter to only show printers matching the sliced model
|
||||
const matching = activePrinters.filter((p) => p.model === slicedForModel);
|
||||
// If no matching printers, show all
|
||||
return matching.length > 0 ? matching : activePrinters;
|
||||
}, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
|
||||
|
||||
// Check if there are hidden printers due to model filtering
|
||||
const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
|
||||
|
||||
// Get unique models from available printers (for model-based assignment)
|
||||
const uniqueModels = useMemo(() => {
|
||||
const models = activePrinters
|
||||
.map(p => p.model)
|
||||
.filter((m): m is string => Boolean(m));
|
||||
return [...new Set(models)].sort();
|
||||
}, [activePrinters]);
|
||||
|
||||
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
|
||||
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
|
||||
|
||||
const showMappingOptions = allowMultiple &&
|
||||
selectedPrinterIds.length > 1 &&
|
||||
@@ -285,8 +329,56 @@ export function PrinterSelector({
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-6">
|
||||
{/* Multi-select header */}
|
||||
{allowMultiple && displayPrinters.length > 1 && (
|
||||
{/* Assignment mode toggle (model vs specific printer) */}
|
||||
{modelAssignmentAvailable && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAssignmentModeChange!('printer');
|
||||
onTargetModelChange!(null);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
assignmentMode === 'printer'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-white'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<PrinterIcon className="w-4 h-4" />
|
||||
<span className="text-sm">Specific Printer</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAssignmentModeChange!('model');
|
||||
onMultiSelect([]);
|
||||
// Pre-select the sliced-for model if available, otherwise first model
|
||||
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
|
||||
? slicedForModel
|
||||
: uniqueModels[0];
|
||||
onTargetModelChange!(defaultModel);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
assignmentMode === 'model'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-white'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model info (when in model mode) */}
|
||||
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
|
||||
<p className="text-xs text-bambu-gray mb-4">
|
||||
Scheduler will assign to first available idle {targetModel} printer
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Multi-select header (only in printer mode) */}
|
||||
{assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
|
||||
<span>
|
||||
{selectedCount === 0
|
||||
@@ -316,7 +408,8 @@ export function PrinterSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayPrinters.map((printer) => {
|
||||
{/* Printer list (only in printer mode) */}
|
||||
{assignmentMode === 'printer' && displayPrinters.map((printer) => {
|
||||
const selected = isSelected(printer.id);
|
||||
const mappingResult = getPrinterMappingResult(printer.id);
|
||||
const hasOverride = mappingResult && !mappingResult.config.useDefault;
|
||||
@@ -430,13 +523,45 @@ export function PrinterSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Warning when no printer selected */}
|
||||
{selectedCount === 0 && (
|
||||
{/* Show hidden printers toggle */}
|
||||
{assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllPrinters(true)}
|
||||
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-yellow-400" />
|
||||
{hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
|
||||
<span className="underline">show all</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show matching only toggle */}
|
||||
{assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllPrinters(false)}
|
||||
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
|
||||
>
|
||||
<span className="underline">Show only {slicedForModel} printers</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Warning when no printer selected (only in printer mode) */}
|
||||
{assignmentMode === 'printer' && selectedCount === 0 && (
|
||||
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Select at least one printer
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Warning when no model selected (only in model mode) */}
|
||||
{assignmentMode === 'model' && !targetModel && (
|
||||
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Select a target printer model
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
|
||||
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
|
||||
import { Card, CardContent } from '../Card';
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PrintOptions,
|
||||
ScheduleOptions,
|
||||
ScheduleType,
|
||||
AssignmentMode,
|
||||
} from './types';
|
||||
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
|
||||
|
||||
@@ -117,6 +118,23 @@ export function PrintModal({
|
||||
// Per-printer override configs (for multi-printer selection)
|
||||
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
|
||||
|
||||
// Assignment mode: 'printer' (specific) or 'model' (any of model)
|
||||
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
|
||||
// Initialize from queue item if editing with target_model
|
||||
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
||||
return 'model';
|
||||
}
|
||||
return 'printer';
|
||||
});
|
||||
|
||||
// Target model for model-based assignment
|
||||
const [targetModel, setTargetModel] = useState<string | null>(() => {
|
||||
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
||||
return queueItem.target_model;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Track initial values for clearing mappings on change (edit mode only)
|
||||
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
|
||||
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
|
||||
@@ -145,6 +163,16 @@ export function PrintModal({
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Fetch archive details to get sliced_for_model
|
||||
const { data: archiveDetails } = useQuery({
|
||||
queryKey: ['archive', archiveId],
|
||||
queryFn: () => api.getArchive(archiveId!),
|
||||
enabled: !!archiveId && !isLibraryFile,
|
||||
});
|
||||
|
||||
// Get sliced_for_model from archive or library file
|
||||
const slicedForModel = archiveDetails?.sliced_for_model || null;
|
||||
|
||||
// Fetch plates for archives
|
||||
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
|
||||
queryKey: ['archive-plates', archiveId],
|
||||
@@ -304,14 +332,20 @@ export function PrintModal({
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
// Validate printer selection
|
||||
if (selectedPrinters.length === 0) {
|
||||
// Validate printer/model selection
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
|
||||
showToast('Please select at least one printer', 'error');
|
||||
return;
|
||||
}
|
||||
if (assignmentMode === 'model' && !targetModel) {
|
||||
showToast('Please select a target printer model', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitProgress({ current: 0, total: selectedPrinters.length });
|
||||
// For model-based assignment, we just make one API call
|
||||
const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
|
||||
setSubmitProgress({ current: 0, total: totalCount });
|
||||
|
||||
const results: { success: number; failed: number; errors: string[] } = {
|
||||
success: 0,
|
||||
@@ -332,15 +366,16 @@ export function PrintModal({
|
||||
};
|
||||
|
||||
// Common queue data for add-to-queue and edit modes
|
||||
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
|
||||
printer_id: printerId,
|
||||
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
|
||||
printer_id: assignmentMode === 'printer' ? printerId : null,
|
||||
target_model: assignmentMode === 'model' ? targetModel : null,
|
||||
// Use library_file_id for library files, archive_id for archives
|
||||
archive_id: isLibraryFile ? undefined : archiveId,
|
||||
library_file_id: isLibraryFile ? libraryFileId : undefined,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: getMappingForPrinter(printerId),
|
||||
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
@@ -348,35 +383,24 @@ export function PrintModal({
|
||||
...printOptions,
|
||||
});
|
||||
|
||||
for (let i = 0; i < selectedPrinters.length; i++) {
|
||||
const printerId = selectedPrinters[i];
|
||||
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
|
||||
|
||||
// Model-based assignment: single API call
|
||||
if (assignmentMode === 'model') {
|
||||
setSubmitProgress({ current: 1, total: 1 });
|
||||
try {
|
||||
if (mode === 'reprint') {
|
||||
// Reprint mode - start print immediately
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
} else {
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
}
|
||||
} else if (mode === 'edit-queue-item' && i === 0) {
|
||||
// Edit mode - update the original queue item for the first printer
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
// Model-based reprint not supported (need specific printer for immediate print)
|
||||
showToast('Model-based assignment only works with queue mode', 'error');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
} else if (mode === 'edit-queue-item') {
|
||||
// Edit mode - update with target_model
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
printer_id: null,
|
||||
target_model: targetModel,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: printerMapping,
|
||||
ams_mapping: undefined,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
@@ -385,14 +409,63 @@ export function PrintModal({
|
||||
};
|
||||
await updateQueueMutation.mutateAsync(updateData);
|
||||
} else {
|
||||
// Add-to-queue mode OR edit mode with additional printers
|
||||
await addToQueueMutation.mutateAsync(getQueueData(printerId));
|
||||
// Add-to-queue mode with model-based assignment
|
||||
await addToQueueMutation.mutateAsync(getQueueData(null));
|
||||
}
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
|
||||
results.errors.push(`${printerName}: ${(error as Error).message}`);
|
||||
results.errors.push((error as Error).message);
|
||||
}
|
||||
} else {
|
||||
// Printer-based assignment: loop through selected printers
|
||||
for (let i = 0; i < selectedPrinters.length; i++) {
|
||||
const printerId = selectedPrinters[i];
|
||||
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
|
||||
|
||||
try {
|
||||
if (mode === 'reprint') {
|
||||
// Reprint mode - start print immediately
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
} else {
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
}
|
||||
} else if (mode === 'edit-queue-item' && i === 0) {
|
||||
// Edit mode - update the original queue item for the first printer
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
target_model: null,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: printerMapping,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
: null,
|
||||
...printOptions,
|
||||
};
|
||||
await updateQueueMutation.mutateAsync(updateData);
|
||||
} else {
|
||||
// Add-to-queue mode OR edit mode with additional printers
|
||||
await addToQueueMutation.mutateAsync(getQueueData(printerId));
|
||||
}
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
|
||||
results.errors.push(`${printerName}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,11 +473,15 @@ export function PrintModal({
|
||||
|
||||
// Show result toast
|
||||
if (results.failed === 0) {
|
||||
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
|
||||
if (results.success === 1) {
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
|
||||
if (assignmentMode === 'model') {
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
|
||||
} else {
|
||||
showToast(`Print ${action} ${results.success} printers`);
|
||||
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
|
||||
if (results.success === 1) {
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
|
||||
} else {
|
||||
showToast(`Print ${action} ${results.success} printers`);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
onSuccess?.();
|
||||
@@ -422,14 +499,18 @@ export function PrintModal({
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isPending) return false;
|
||||
|
||||
// Need at least one selected printer
|
||||
if (selectedPrinters.length === 0) return false;
|
||||
// Need valid printer/model selection
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
|
||||
if (assignmentMode === 'model' && !targetModel) return false;
|
||||
|
||||
// Model-based assignment only works in queue modes (not immediate reprint)
|
||||
if (assignmentMode === 'model' && mode === 'reprint') return false;
|
||||
|
||||
// For multi-plate archive files, need a selected plate (library files skip this)
|
||||
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
|
||||
|
||||
return true;
|
||||
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
|
||||
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
|
||||
|
||||
// Modal title and action button text based on mode
|
||||
const getModalConfig = () => {
|
||||
@@ -541,8 +622,29 @@ export function PrintModal({
|
||||
filamentReqs={effectiveFilamentReqs}
|
||||
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
|
||||
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
|
||||
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
|
||||
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
|
||||
targetModel={targetModel}
|
||||
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
|
||||
slicedForModel={slicedForModel}
|
||||
/>
|
||||
|
||||
{/* Compatibility warning when sliced model doesn't match selected printer */}
|
||||
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
|
||||
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
|
||||
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
|
||||
return (
|
||||
<div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-sm text-yellow-400">
|
||||
File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Warning when archive data couldn't be loaded */}
|
||||
{archiveDataMissing && (
|
||||
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
|
||||
@@ -565,7 +667,7 @@ export function PrintModal({
|
||||
)}
|
||||
|
||||
{/* Print options */}
|
||||
{(mode === 'reprint' || effectivePrinterCount > 0) && (
|
||||
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
|
||||
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -104,6 +104,13 @@ export interface PlatesResponse {
|
||||
plates: PlateInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment mode for queue items.
|
||||
* - 'printer': Assign to specific printer(s)
|
||||
* - 'model': Assign to any printer of a specific model (load balancing)
|
||||
*/
|
||||
export type AssignmentMode = 'printer' | 'model';
|
||||
|
||||
/**
|
||||
* Props for the PrinterSelector component.
|
||||
*/
|
||||
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
|
||||
allowMultiple?: boolean;
|
||||
/** Show inactive printers (for edit mode where original assignment may be inactive) */
|
||||
showInactive?: boolean;
|
||||
/** Current assignment mode */
|
||||
assignmentMode?: AssignmentMode;
|
||||
/** Handler for assignment mode change */
|
||||
onAssignmentModeChange?: (mode: AssignmentMode) => void;
|
||||
/** Selected target model (when assignmentMode is 'model') */
|
||||
targetModel?: string | null;
|
||||
/** Handler for target model change */
|
||||
onTargetModelChange?: (model: string | null) => void;
|
||||
/** Suggested model from sliced file (for pre-selection) */
|
||||
slicedForModel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { BulkUploadResult } from '../api/client';
|
||||
@@ -27,7 +27,6 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
|
||||
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
|
||||
|
||||
// Close on Escape key
|
||||
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (filesToUpload: File[]) =>
|
||||
api.uploadArchivesBulk(filesToUpload, selectedPrinter),
|
||||
api.uploadArchivesBulk(filesToUpload),
|
||||
onSuccess: (result) => {
|
||||
setUploadResult(result);
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Printer Selection */}
|
||||
{/* Info about printer model extraction */}
|
||||
<div className="px-4 pb-4">
|
||||
<label className="block text-sm text-bambu-gray mb-2">
|
||||
Associate with printer (optional)
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
value={selectedPrinter || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">No printer</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
The printer model will be automatically extracted from the 3MF file metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
|
||||
@@ -797,6 +797,12 @@ function ArchiveCard({
|
||||
{archive.object_count} object{archive.object_count > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{archive.sliced_for_model && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray" title={`Sliced for ${archive.sliced_for_model}`}>
|
||||
<Printer className="w-3 h-3" />
|
||||
{archive.sliced_for_model}
|
||||
</div>
|
||||
)}
|
||||
{archive.filament_type && (
|
||||
<div className="flex items-center gap-1.5 col-span-2">
|
||||
<span className="text-bambu-gray text-xs">{archive.filament_type}</span>
|
||||
@@ -1612,9 +1618,20 @@ function ArchiveListRow({
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{archive.filament_type && (
|
||||
{(archive.filament_type || archive.sliced_for_model) && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
|
||||
{archive.sliced_for_model && (
|
||||
<span className="text-xs text-bambu-gray flex items-center gap-1" title={`Sliced for ${archive.sliced_for_model}`}>
|
||||
<Printer className="w-2.5 h-2.5" />
|
||||
{archive.sliced_for_model}
|
||||
</span>
|
||||
)}
|
||||
{archive.sliced_for_model && archive.filament_type && (
|
||||
<span className="text-bambu-gray/50">·</span>
|
||||
)}
|
||||
{archive.filament_type && (
|
||||
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
|
||||
)}
|
||||
{archive.filament_color && (
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{archive.filament_color.split(',').map((color, i) => (
|
||||
@@ -2755,7 +2772,7 @@ export function ArchivesPage() {
|
||||
<ArchiveCard
|
||||
key={archive.id}
|
||||
archive={archive}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
|
||||
isSelected={selectedIds.has(archive.id)}
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
@@ -2782,7 +2799,7 @@ export function ArchivesPage() {
|
||||
<ArchiveListRow
|
||||
key={archive.id}
|
||||
archive={archive}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
|
||||
isSelected={selectedIds.has(archive.id)}
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
|
||||
@@ -77,7 +77,17 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
|
||||
return formatDateTime(dateString, timeFormat);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
|
||||
function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
|
||||
// Special case: pending with waiting_reason shows as "Waiting"
|
||||
if (status === 'pending' && waitingReason) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Waiting
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
|
||||
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
|
||||
@@ -399,9 +409,13 @@ function SortableQueueItem({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bambu-gray">
|
||||
<span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
|
||||
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
|
||||
<Printer className="w-3.5 h-3.5" />
|
||||
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
{item.target_model
|
||||
? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
|
||||
: item.printer_id === null
|
||||
? 'Unassigned'
|
||||
: (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
</span>
|
||||
{item.print_time_seconds && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -448,6 +462,14 @@ function SortableQueueItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting reason for model-based assignments */}
|
||||
{item.waiting_reason && item.status === 'pending' && (
|
||||
<p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
|
||||
<AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{item.waiting_reason}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{item.error_message && (
|
||||
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
|
||||
@@ -458,7 +480,7 @@ function SortableQueueItem({
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<StatusBadge status={item.status} />
|
||||
<StatusBadge status={item.status} waitingReason={item.waiting_reason} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user