Model-based queue assignment with filament validation and queue notifications (#162)

Model-based queue assignment:
- Extract printer_model from sliced 3MF files during upload
- Display sliced-for model in archive view
- New queue mode: assign to "Any [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"

Queue notifications (7 new events):
- Job Added: When a job is added to 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

Backend changes:
- New columns: print_queue.target_model, print_queue.required_filament_types, print_queue.waiting_reason
- New columns: notification_providers.on_queue_job_* (7 event triggers)
- Notification templates for all queue events
- Scheduler validates filament compatibility before model-based assignment
- Queue API extracts filament types from 3MF when adding model-based items
- Local backup/restore includes queue notification settings

Frontend changes:
- TypeScript interfaces updated for new fields
- Queue page shows waiting reason and "Waiting" badge
- Notification settings includes "Print Queue" section with 7 toggles

Closes #162
This commit is contained in:
maziggy
2026-01-28 13:39:08 +01:00
parent 2eb21e8b42
commit 7c5c4ee5bb
23 changed files with 943 additions and 105 deletions
+18
View File
@@ -82,6 +82,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
- **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):
+3
View File
@@ -74,6 +74,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)
@@ -115,6 +117,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
+8
View File
@@ -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,
+125 -4
View File
@@ -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,74 @@ from backend.app.schemas.print_queue import (
PrintQueueItemUpdate,
PrintQueueReorder,
)
from backend.app.services.notification_service import notification_service
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,11 +104,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,
@@ -143,18 +219,39 @@ async def add_to_queue(
if not result.scalars().first():
raise HTTPException(400, f"No active printers for model: {data.target_model}")
# Validate archive exists (if provided)
# 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")
# Extract filament types for model-based assignment (used by scheduler for validation)
required_filament_types = None
if data.target_model:
# 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(
@@ -174,6 +271,7 @@ async def add_to_queue(
item = PrintQueueItem(
printer_id=data.printer_id,
target_model=data.target_model,
required_filament_types=required_filament_types,
archive_id=data.archive_id,
library_file_id=data.library_file_id,
scheduled_time=data.scheduled_time,
@@ -215,6 +313,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 data.target_model 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)
+21
View File
@@ -310,6 +310,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,
@@ -1175,6 +1182,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")
@@ -1205,6 +1219,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"),
+28
View File
@@ -772,6 +772,34 @@ async def run_migrations(conn):
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."""
+28
View File
@@ -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))
+9
View File
@@ -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",
},
]
+6
View File
@@ -18,6 +18,12 @@ class PrintQueueItem(Base):
# 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(
+18
View File
@@ -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
+3
View File
@@ -18,6 +18,7 @@ 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
@@ -62,6 +63,8 @@ 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
@@ -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,
+223 -10
View File
@@ -15,6 +15,7 @@ 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
@@ -114,6 +115,17 @@ class PrintScheduler:
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
@@ -122,7 +134,36 @@ class PrintScheduler:
elif item.target_model:
# Model-based assignment - find any idle printer of matching model
printer_id = await self._find_idle_printer_for_model(db, item.target_model, busy_printers)
# 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:
@@ -132,33 +173,150 @@ class PrintScheduler:
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
# 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]) -> int | None:
"""Find an idle, connected printer matching the model.
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:
Printer ID if found, None otherwise
Tuple of (printer_id, waiting_reason):
- (printer_id, None) if a matching printer was found
- (None, reason) if no printer is available, with explanation
"""
result = await db.execute(
select(Printer).where(Printer.model == model).where(Printer.is_active == True) # noqa: E712
)
for printer in result.scalars().all():
if printer.id not in exclude_ids:
if self._is_printer_idle(printer.id) and printer_manager.is_connected(printer.id):
return printer.id
return None
printers = list(result.scalars().all())
if not printers:
return None, f"No active {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
# Found a matching printer - clear waiting reason
return printer.id, None
# 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
if status.ams_units:
for ams_unit in status.ams_units:
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)
if status.virtual_tray:
vt_type = status.virtual_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."""
@@ -262,6 +420,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.
@@ -413,6 +590,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
@@ -453,6 +640,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
@@ -472,6 +675,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)
@@ -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 = [
+26
View File
@@ -1002,6 +1002,8 @@ 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;
@@ -1215,6 +1217,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;
@@ -1257,6 +1267,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;
@@ -1292,6 +1310,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">
+21 -3
View File
@@ -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' },
@@ -394,7 +404,7 @@ function SortableQueueItem({
<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.target_model
? `Any ${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}`)}
@@ -444,6 +454,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">
@@ -454,7 +472,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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CeGzUNH5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nwJjDqT-.css">
<script type="module" crossorigin src="/assets/index-BvTstZiS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8OJBC-HQ.css">
</head>
<body>
<div id="root"></div>