diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d9c412d4..7beef5866 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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):
diff --git a/README.md b/README.md
index 289b5af18..230b20908 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py
index a6c925719..f94ce1431 100644
--- a/backend/app/api/routes/notifications.py
+++ b/backend/app/api/routes/notifications.py
@@ -53,6 +53,14 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
# Build plate detection
"on_plate_not_empty": provider.on_plate_not_empty,
+ # Print queue events
+ "on_queue_job_added": provider.on_queue_job_added,
+ "on_queue_job_assigned": provider.on_queue_job_assigned,
+ "on_queue_job_started": provider.on_queue_job_started,
+ "on_queue_job_waiting": provider.on_queue_job_waiting,
+ "on_queue_job_skipped": provider.on_queue_job_skipped,
+ "on_queue_job_failed": provider.on_queue_job_failed,
+ "on_queue_completed": provider.on_queue_completed,
# Quiet hours
"quiet_hours_enabled": provider.quiet_hours_enabled,
"quiet_hours_start": provider.quiet_hours_start,
diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py
index f74e21749..c3a625f5d 100644
--- a/backend/app/api/routes/print_queue.py
+++ b/backend/app/api/routes/print_queue.py
@@ -2,13 +2,17 @@
import json
import logging
+import xml.etree.ElementTree as ET
+import zipfile
from datetime import datetime
+from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
@@ -22,12 +26,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)
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index 50b416f0c..7ea8e7061 100644
--- a/backend/app/api/routes/settings.py
+++ b/backend/app/api/routes/settings.py
@@ -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"),
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index ca4fb74b8..5b3175ff4 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -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."""
diff --git a/backend/app/main.py b/backend/app/main.py
index 40e49a34b..3f124524d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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))
diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py
index 4f49ed64b..c04b140d7 100644
--- a/backend/app/models/notification.py
+++ b/backend/app/models/notification.py
@@ -83,6 +83,15 @@ class NotificationProvider(Base):
# Event triggers - Build plate detection
on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print
+ # Event triggers - Print queue
+ on_queue_job_added = Column(Boolean, default=False) # Job added to queue
+ on_queue_job_assigned = Column(Boolean, default=False) # Model-based job assigned to printer
+ on_queue_job_started = Column(Boolean, default=False) # Queue job started printing
+ on_queue_job_waiting = Column(Boolean, default=True) # Job waiting for filament
+ on_queue_job_skipped = Column(Boolean, default=True) # Job skipped (previous print failed)
+ on_queue_job_failed = Column(Boolean, default=True) # Job failed to start
+ on_queue_completed = Column(Boolean, default=False) # All pending jobs finished
+
# Quiet hours (do not disturb)
quiet_hours_enabled = Column(Boolean, default=False)
quiet_hours_start = Column(String(5), nullable=True) # HH:MM format, e.g., "22:00"
diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py
index 9dcb9ccbc..fb21b9456 100644
--- a/backend/app/models/notification_template.py
+++ b/backend/app/models/notification_template.py
@@ -103,4 +103,47 @@ DEFAULT_TEMPLATES = [
"title_template": "Bambuddy Test",
"body_template": "This is a test notification. If you see this, notifications are working!",
},
+ # Queue notifications
+ {
+ "event_type": "queue_job_added",
+ "name": "Queue Job Added",
+ "title_template": "Job Queued",
+ "body_template": "{job_name} added to queue for {target}",
+ },
+ {
+ "event_type": "queue_job_assigned",
+ "name": "Queue Job Assigned",
+ "title_template": "Job Assigned",
+ "body_template": "{job_name} assigned to {printer} (from Any {target_model} queue)",
+ },
+ {
+ "event_type": "queue_job_started",
+ "name": "Queue Job Started",
+ "title_template": "Queue Job Started",
+ "body_template": "{printer}: {job_name}\nEstimated: {estimated_time}",
+ },
+ {
+ "event_type": "queue_job_waiting",
+ "name": "Queue Job Waiting",
+ "title_template": "Job Waiting for Filament",
+ "body_template": "{job_name} waiting for {target_model}\n{waiting_reason}",
+ },
+ {
+ "event_type": "queue_job_skipped",
+ "name": "Queue Job Skipped",
+ "title_template": "Job Skipped",
+ "body_template": "{printer}: {job_name}\nReason: {reason}",
+ },
+ {
+ "event_type": "queue_job_failed",
+ "name": "Queue Job Failed",
+ "title_template": "Job Failed to Start",
+ "body_template": "{printer}: {job_name}\nReason: {reason}",
+ },
+ {
+ "event_type": "queue_completed",
+ "name": "Queue Completed",
+ "title_template": "Queue Complete",
+ "body_template": "All {completed_count} queued jobs have finished",
+ },
]
diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py
index cd33ee121..53c54cb93 100644
--- a/backend/app/models/print_queue.py
+++ b/backend/app/models/print_queue.py
@@ -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(
diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py
index e0285f464..ca8ff5b41 100644
--- a/backend/app/schemas/notification.py
+++ b/backend/app/schemas/notification.py
@@ -53,6 +53,15 @@ class NotificationProviderBase(BaseModel):
# Event triggers - Build plate detection
on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
+ # Event triggers - Print queue
+ on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
+ on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
+ on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing")
+ on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament")
+ on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped")
+ on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start")
+ on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish")
+
# Quiet hours
quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
@@ -120,6 +129,15 @@ class NotificationProviderUpdate(BaseModel):
# Event triggers - Build plate detection
on_plate_not_empty: bool | None = None
+ # Event triggers - Print queue
+ on_queue_job_added: bool | None = None
+ on_queue_job_assigned: bool | None = None
+ on_queue_job_started: bool | None = None
+ on_queue_job_waiting: bool | None = None
+ on_queue_job_skipped: bool | None = None
+ on_queue_job_failed: bool | None = None
+ on_queue_completed: bool | None = None
+
# Quiet hours
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py
index f64a0be4a..785145126 100644
--- a/backend/app/schemas/print_queue.py
+++ b/backend/app/schemas/print_queue.py
@@ -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
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
index 2c666ff02..92a51cd14 100644
--- a/backend/app/services/notification_service.py
+++ b/backend/app/services/notification_service.py
@@ -945,6 +945,156 @@ class NotificationService:
"""Clear the template cache. Call this when templates are updated."""
self._template_cache.clear()
+ # ==================== Queue Notifications ====================
+
+ async def on_queue_job_added(
+ self,
+ job_name: str,
+ target: str,
+ db: AsyncSession,
+ printer_id: int | None = None,
+ printer_name: str | None = None,
+ ):
+ """Handle queue job added event."""
+ providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id)
+ if not providers:
+ return
+
+ variables = {
+ "job_name": job_name,
+ "target": target, # e.g., "Printer1" or "Any X1C"
+ "printer": printer_name or target,
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_job_added", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name)
+
+ async def on_queue_job_assigned(
+ self,
+ job_name: str,
+ printer_id: int,
+ printer_name: str,
+ target_model: str,
+ db: AsyncSession,
+ ):
+ """Handle model-based job assigned to printer event."""
+ providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id)
+ if not providers:
+ return
+
+ variables = {
+ "job_name": job_name,
+ "printer": printer_name,
+ "target_model": target_model,
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name)
+
+ async def on_queue_job_started(
+ self,
+ job_name: str,
+ printer_id: int,
+ printer_name: str,
+ db: AsyncSession,
+ estimated_time: int | None = None,
+ ):
+ """Handle queue job started printing event."""
+ providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id)
+ if not providers:
+ return
+
+ variables = {
+ "job_name": job_name,
+ "printer": printer_name,
+ "estimated_time": self._format_duration(estimated_time),
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_job_started", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name)
+
+ async def on_queue_job_waiting(
+ self,
+ job_name: str,
+ target_model: str,
+ waiting_reason: str,
+ db: AsyncSession,
+ ):
+ """Handle job waiting for filament event."""
+ providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None)
+ if not providers:
+ return
+
+ variables = {
+ "job_name": job_name,
+ "target_model": target_model,
+ "waiting_reason": waiting_reason,
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_job_waiting")
+
+ async def on_queue_job_skipped(
+ self,
+ job_name: str,
+ printer_id: int,
+ printer_name: str,
+ reason: str,
+ db: AsyncSession,
+ ):
+ """Handle job skipped event (e.g., previous print failed)."""
+ providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id)
+ if not providers:
+ return
+
+ variables = {
+ "job_name": job_name,
+ "printer": printer_name,
+ "reason": reason,
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name)
+
+ async def on_queue_job_failed(
+ self,
+ job_name: str,
+ printer_id: int | None,
+ printer_name: str | None,
+ reason: str,
+ db: AsyncSession,
+ ):
+ """Handle job failed to start event (upload error, etc.)."""
+ providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id)
+ if not providers:
+ return
+
+ variables = {
+ "job_name": job_name,
+ "printer": printer_name or "Unknown",
+ "reason": reason,
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name)
+
+ async def on_queue_completed(
+ self,
+ completed_count: int,
+ db: AsyncSession,
+ ):
+ """Handle all queue jobs completed event."""
+ providers = await self._get_providers_for_event(db, "on_queue_completed", None)
+ if not providers:
+ return
+
+ variables = {
+ "completed_count": str(completed_count),
+ }
+
+ title, message = await self._build_message_from_template(db, "queue_completed", variables)
+ await self._send_to_providers(providers, title, message, db, "queue_completed")
+
async def _queue_for_digest(
self,
provider: NotificationProvider,
diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py
index 1c9728bca..c6759809f 100644
--- a/backend/app/services/print_scheduler.py
+++ b/backend/app/services/print_scheduler.py
@@ -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)
diff --git a/frontend/src/__tests__/components/NotificationProviderCard.test.tsx b/frontend/src/__tests__/components/NotificationProviderCard.test.tsx
index ffed64ee1..00d768c05 100644
--- a/frontend/src/__tests__/components/NotificationProviderCard.test.tsx
+++ b/frontend/src/__tests__/components/NotificationProviderCard.test.tsx
@@ -63,6 +63,14 @@ const createMockProvider = (
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
+ on_plate_not_empty: true,
+ on_queue_job_added: false,
+ on_queue_job_assigned: false,
+ on_queue_job_started: false,
+ on_queue_job_waiting: true,
+ on_queue_job_skipped: true,
+ on_queue_job_failed: true,
+ on_queue_completed: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
@@ -282,3 +290,83 @@ describe('NotificationProviderCard AMS toggles', () => {
});
});
});
+
+describe('NotificationProviderCard Queue notifications', () => {
+ describe('queue job notifications', () => {
+ it('includes on_queue_job_added in provider data', () => {
+ const provider = createMockProvider({ on_queue_job_added: true });
+ expect(provider.on_queue_job_added).toBe(true);
+ });
+
+ it('includes on_queue_job_assigned in provider data', () => {
+ const provider = createMockProvider({ on_queue_job_assigned: true });
+ expect(provider.on_queue_job_assigned).toBe(true);
+ });
+
+ it('includes on_queue_job_started in provider data', () => {
+ const provider = createMockProvider({ on_queue_job_started: true });
+ expect(provider.on_queue_job_started).toBe(true);
+ });
+
+ it('includes on_queue_job_waiting in provider data', () => {
+ const provider = createMockProvider({ on_queue_job_waiting: true });
+ expect(provider.on_queue_job_waiting).toBe(true);
+ });
+
+ it('includes on_queue_job_skipped in provider data', () => {
+ const provider = createMockProvider({ on_queue_job_skipped: true });
+ expect(provider.on_queue_job_skipped).toBe(true);
+ });
+
+ it('includes on_queue_job_failed in provider data', () => {
+ const provider = createMockProvider({ on_queue_job_failed: true });
+ expect(provider.on_queue_job_failed).toBe(true);
+ });
+
+ it('includes on_queue_completed in provider data', () => {
+ const provider = createMockProvider({ on_queue_completed: true });
+ expect(provider.on_queue_completed).toBe(true);
+ });
+ });
+
+ describe('queue notification defaults', () => {
+ it('defaults actionable notifications to true', () => {
+ const provider = createMockProvider();
+ // These should default to true (actionable - user needs to do something)
+ expect(provider.on_queue_job_waiting).toBe(true);
+ expect(provider.on_queue_job_skipped).toBe(true);
+ expect(provider.on_queue_job_failed).toBe(true);
+ });
+
+ it('defaults informational notifications to false', () => {
+ const provider = createMockProvider();
+ // These should default to false (informational only)
+ expect(provider.on_queue_job_added).toBe(false);
+ expect(provider.on_queue_job_assigned).toBe(false);
+ expect(provider.on_queue_job_started).toBe(false);
+ expect(provider.on_queue_completed).toBe(false);
+ });
+ });
+
+ describe('queue notification combinations', () => {
+ it('supports all queue toggles independently', () => {
+ const provider = createMockProvider({
+ on_queue_job_added: true,
+ on_queue_job_assigned: false,
+ on_queue_job_started: true,
+ on_queue_job_waiting: false,
+ on_queue_job_skipped: true,
+ on_queue_job_failed: false,
+ on_queue_completed: true,
+ });
+
+ expect(provider.on_queue_job_added).toBe(true);
+ expect(provider.on_queue_job_assigned).toBe(false);
+ expect(provider.on_queue_job_started).toBe(true);
+ expect(provider.on_queue_job_waiting).toBe(false);
+ expect(provider.on_queue_job_skipped).toBe(true);
+ expect(provider.on_queue_job_failed).toBe(false);
+ expect(provider.on_queue_completed).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/__tests__/components/UploadModal.test.tsx b/frontend/src/__tests__/components/UploadModal.test.tsx
index 424961348..70cd09e1e 100644
--- a/frontend/src/__tests__/components/UploadModal.test.tsx
+++ b/frontend/src/__tests__/components/UploadModal.test.tsx
@@ -10,20 +10,12 @@ import { UploadModal } from '../../components/UploadModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
-const mockPrinters = [
- { id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' },
- { id: 2, name: 'P1S', model: 'P1S', serial_number: '456' },
-];
-
describe('UploadModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
- http.get('/api/v1/printers/', () => {
- return HttpResponse.json(mockPrinters);
- }),
http.post('/api/v1/archives/upload-bulk', async () => {
return HttpResponse.json({
uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
});
- it('renders printer selection dropdown', async () => {
- render(
Print Queue
+ +Job Added
+Job added to queue
+Job Assigned
+Model-based job assigned to printer
+Job Started
+Queue job started printing
+Job Waiting
+Job waiting for filament
+Job Skipped
+Job skipped (previous failed)
+Job Failed
+Job failed to start
+Queue Complete
+All queue jobs finished
+
+
@@ -454,7 +472,7 @@ function SortableQueueItem({
vn?(In=qt,qt=null):In=qt.sibling;var Wn=st(Ze,qt,tt[vn],vt);if(Wn===null){qt===null&&(qt=In);break}h&&qt&&Wn.alternate===null&&g(Ze,qt),Ge=I(Wn,Ge,vn),Gn===null?Zt=Wn:Gn.sibling=Wn,Gn=Wn,qt=In}if(vn===tt.length)return _(Ze,qt),Un&&ac(Ze,vn),Zt;if(qt===null){for(;vn