Merge pull request #189 from maziggy/feature/printer_model_queue

Feature/printer model queue
This commit is contained in:
MartinNYHC
2026-01-30 13:11:29 +01:00
committed by GitHub
33 changed files with 10236 additions and 185 deletions
+18
View File
@@ -90,6 +90,24 @@ All notable changes to Bambuddy will be documented in this file.
- Bulk edit: printer assignment, print options, queue options
- Bulk cancel selected items
- Tri-state toggles: unchanged / on / off for each setting
- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162):
- Extract printer model from sliced 3MF files (e.g., "X1C", "P1S")
- Display sliced-for model in archive view
- New queue mode: assign to model instead of specific printer
- Scheduler auto-assigns to first idle printer of matching model
- Filament validation: only assign to printers with required filament types loaded
- Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)")
- "Waiting" status badge (purple) distinguishes from regular "Pending"
- Compatibility warnings when file/printer model mismatch
- **Queue Notifications** - Get notified about print queue events:
- Job Added: When a job is added to the queue
- Job Assigned: When a model-based job is assigned to a printer
- Job Started: When a queue job starts printing
- Job Waiting: When a job is waiting for filament (enabled by default)
- Job Skipped: When a job is skipped due to previous failure (enabled by default)
- Job Failed: When a job fails to start (enabled by default)
- Queue Complete: When all queued jobs finish
- New "Print Queue" section in notification provider settings
### Fixes
- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
+7 -4
View File
@@ -75,6 +75,8 @@
### ⏰ Scheduling & Automation
- Print queue with drag-and-drop
- Multi-printer selection (send to multiple printers at once)
- Model-based queue assignment (send to "any X1C" for load balancing)
- Filament validation (only assign to printers with required filaments)
- Per-printer AMS mapping (individual slot configuration for print farms)
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
@@ -116,6 +118,7 @@
- Print finish photo URL in notifications
- HMS error alerts (AMS, nozzle, etc.)
- Build plate detection alerts
- Queue events (waiting, skipped, failed)
### 🔧 Integrations
- [Spoolman](https://github.com/Donkie/Spoolman) filament sync
@@ -506,10 +509,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
---
If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
---
## 📄 License
MIT License — see [LICENSE](LICENSE) for details.
@@ -524,6 +523,10 @@ MIT License — see [LICENSE](LICENSE) for details.
---
If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
---
<p align="center">
Made with ❤️ for the 3D printing community
<br><br>
+1
View File
@@ -78,6 +78,7 @@ def archive_to_response(
"nozzle_diameter": archive.nozzle_diameter,
"bed_temperature": archive.bed_temperature,
"nozzle_temperature": archive.nozzle_temperature,
"sliced_for_model": archive.sliced_for_model,
"status": archive.status,
"started_at": archive.started_at,
"completed_at": archive.completed_at,
@@ -32,6 +32,14 @@ EVENT_NAMES = {
"filament_low": "Filament Low",
"maintenance_due": "Maintenance Due",
"test": "Test Notification",
# Queue notifications
"queue_job_added": "Queue Job Added",
"queue_job_assigned": "Queue Job Assigned",
"queue_job_started": "Queue Job Started",
"queue_job_waiting": "Queue Job Waiting",
"queue_job_skipped": "Queue Job Skipped",
"queue_job_failed": "Queue Job Failed",
"queue_completed": "Queue Completed",
}
+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,
+175 -7
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,75 @@ from backend.app.schemas.print_queue import (
PrintQueueItemUpdate,
PrintQueueReorder,
)
from backend.app.services.notification_service import notification_service
from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/queue", tags=["queue"])
def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]:
"""Extract unique filament types from a 3MF file.
Args:
file_path: Path to the 3MF file
plate_id: Optional plate index to filter for (for multi-plate files)
Returns:
List of unique filament types (e.g., ["PLA", "PETG"])
"""
types: set[str] = set()
try:
with zipfile.ZipFile(file_path, "r") as zf:
if "Metadata/slice_info.config" not in zf.namelist():
return []
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
if plate_id is not None:
# Find the plate element with matching index
for plate_elem in root.findall(".//plate"):
plate_index = None
for meta in plate_elem.findall("metadata"):
if meta.get("key") == "index":
try:
plate_index = int(meta.get("value", "0"))
except ValueError:
pass
break
if plate_index == plate_id:
for filament_elem in plate_elem.findall("filament"):
filament_type = filament_elem.get("type", "")
used_g = filament_elem.get("used_g", "0")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
used_grams = 0
if used_grams > 0 and filament_type:
types.add(filament_type)
break
else:
# No plate_id specified - extract all filaments with used_g > 0
for filament_elem in root.findall(".//filament"):
filament_type = filament_elem.get("type", "")
used_g = filament_elem.get("used_g", "0")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
used_grams = 0
if used_grams > 0 and filament_type:
types.add(filament_type)
except Exception as e:
logger.warning(f"Failed to extract filament types from {file_path}: {e}")
return sorted(types)
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"""Add nested archive/printer/library_file info to response."""
# Parse ams_mapping from JSON string BEFORE model_validate
@@ -38,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
except json.JSONDecodeError:
ams_mapping_parsed = None
# Parse required_filament_types from JSON string
required_filament_types_parsed = None
if item.required_filament_types:
try:
required_filament_types_parsed = json.loads(item.required_filament_types)
except json.JSONDecodeError:
required_filament_types_parsed = None
# Create response with parsed ams_mapping
item_dict = {
"id": item.id,
"printer_id": item.printer_id,
"target_model": item.target_model,
"required_filament_types": required_filament_types_parsed,
"waiting_reason": item.waiting_reason,
"archive_id": item.archive_id,
"library_file_id": item.library_file_id,
"position": item.position,
@@ -120,29 +198,71 @@ async def add_to_queue(
db: AsyncSession = Depends(get_db),
):
"""Add an item to the print queue."""
# Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
target_model_norm = None
if data.target_model:
target_model_norm = (
normalize_printer_model(data.target_model)
or normalize_printer_model_id(data.target_model)
or data.target_model
)
# Validate that either archive_id or library_file_id is provided
if not data.archive_id and not data.library_file_id:
raise HTTPException(400, "Either archive_id or library_file_id must be provided")
# Cannot specify both printer_id and target_model
if data.printer_id and target_model_norm:
raise HTTPException(400, "Cannot specify both printer_id and target_model")
# Validate printer exists (if assigned)
if data.printer_id is not None:
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Validate archive exists (if provided)
# Validate target_model has active printers
if target_model_norm:
result = await db.execute(
select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True) # noqa: E712
)
if not result.scalars().first():
raise HTTPException(400, f"No active printers for model: {target_model_norm}")
# Validate archive exists (if provided) and get it for filament extraction
archive = None
if data.archive_id:
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
if not result.scalar_one_or_none():
archive = result.scalar_one_or_none()
if not archive:
raise HTTPException(400, "Archive not found")
# Validate library file exists (if provided)
# Validate library file exists (if provided) and get it for filament extraction
library_file = None
if data.library_file_id:
result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
if not result.scalar_one_or_none():
library_file = result.scalar_one_or_none()
if not library_file:
raise HTTPException(400, "Library file not found")
# Get next position for this printer (or for unassigned items)
# Extract filament types for model-based assignment (used by scheduler for validation)
required_filament_types = None
if target_model_norm:
# Get file path from archive or library file
file_path = None
if archive:
file_path = settings.base_dir / archive.file_path
elif library_file:
lib_path = Path(library_file.file_path)
file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
if file_path and file_path.exists():
filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
if filament_types:
required_filament_types = json.dumps(filament_types)
logger.info(f"Extracted filament types for model-based queue: {filament_types}")
# Get next position for this printer (or for unassigned/model-based items)
if data.printer_id is not None:
result = await db.execute(
select(func.max(PrintQueueItem.position))
@@ -150,7 +270,7 @@ async def add_to_queue(
.where(PrintQueueItem.status == "pending")
)
else:
# For unassigned items, get max position across all unassigned
# For unassigned/model-based items, get max position across all unassigned
result = await db.execute(
select(func.max(PrintQueueItem.position))
.where(PrintQueueItem.printer_id.is_(None))
@@ -160,6 +280,8 @@ async def add_to_queue(
item = PrintQueueItem(
printer_id=data.printer_id,
target_model=target_model_norm,
required_filament_types=required_filament_types,
archive_id=data.archive_id,
library_file_id=data.library_file_id,
scheduled_time=data.scheduled_time,
@@ -185,7 +307,8 @@ async def add_to_queue(
await db.refresh(item, ["archive", "printer", "library_file"])
source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
logger.info(f"Added {source_name} to queue for {target_desc}")
# MQTT relay - publish queue job added
try:
@@ -200,6 +323,29 @@ async def add_to_queue(
except Exception:
pass # Don't fail queue add if MQTT fails
# Send notification for job added
try:
job_name = (
item.archive.filename
if item.archive
else item.library_file.filename
if item.library_file
else f"Job #{item.id}"
)
job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
target = (
item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
)
await notification_service.on_queue_job_added(
job_name=job_name,
target=target,
db=db,
printer_id=item.printer_id,
printer_name=item.printer.name if item.printer else None,
)
except Exception:
pass # Don't fail queue add if notification fails
return _enrich_response(item)
@@ -287,12 +433,34 @@ async def update_queue_item(
update_data = data.model_dump(exclude_unset=True)
# Normalize target_model if being updated
if "target_model" in update_data and update_data["target_model"]:
update_data["target_model"] = (
normalize_printer_model(update_data["target_model"])
or normalize_printer_model_id(update_data["target_model"])
or update_data["target_model"]
)
# Cannot specify both printer_id and target_model
new_printer_id = update_data.get("printer_id", item.printer_id)
new_target_model = update_data.get("target_model", item.target_model)
if new_printer_id and new_target_model:
raise HTTPException(400, "Cannot specify both printer_id and target_model")
# Validate new printer_id if being changed (and not None)
if "printer_id" in update_data and update_data["printer_id"] is not None:
result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Validate target_model has active printers
if "target_model" in update_data and update_data["target_model"]:
result = await db.execute(
select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True) # noqa: E712
)
if not result.scalars().first():
raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
# Serialize ams_mapping to JSON for TEXT column storage
if "ams_mapping" in update_data:
update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
+21
View File
@@ -311,6 +311,13 @@ async def export_backup(
"on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
"on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
"on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
"on_queue_job_added": getattr(p, "on_queue_job_added", False),
"on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False),
"on_queue_job_started": getattr(p, "on_queue_job_started", False),
"on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True),
"on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True),
"on_queue_job_failed": getattr(p, "on_queue_job_failed", True),
"on_queue_completed": getattr(p, "on_queue_completed", False),
"quiet_hours_enabled": p.quiet_hours_enabled,
"quiet_hours_start": p.quiet_hours_start,
"quiet_hours_end": p.quiet_hours_end,
@@ -1176,6 +1183,13 @@ async def import_backup(
existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True)
existing.on_queue_job_added = provider_data.get("on_queue_job_added", False)
existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False)
existing.on_queue_job_started = provider_data.get("on_queue_job_started", False)
existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True)
existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True)
existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True)
existing.on_queue_completed = provider_data.get("on_queue_completed", False)
existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
existing.quiet_hours_start = provider_data.get("quiet_hours_start")
existing.quiet_hours_end = provider_data.get("quiet_hours_end")
@@ -1206,6 +1220,13 @@ async def import_backup(
on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
on_plate_not_empty=provider_data.get("on_plate_not_empty", True),
on_queue_job_added=provider_data.get("on_queue_job_added", False),
on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False),
on_queue_job_started=provider_data.get("on_queue_job_started", False),
on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True),
on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True),
on_queue_job_failed=provider_data.get("on_queue_job_failed", True),
on_queue_completed=provider_data.get("on_queue_completed", False),
quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
quiet_hours_start=provider_data.get("quiet_hours_start"),
quiet_hours_end=provider_data.get("quiet_hours_end"),
+40
View File
@@ -760,6 +760,46 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Add sliced_for_model column to print_archives for printer model from 3MF
try:
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
except Exception:
pass
# Migration: Add target_model column to print_queue for model-based assignment
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
except Exception:
pass
# Migration: Add required_filament_types column to print_queue for filament validation
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
except Exception:
pass
# Migration: Add waiting_reason column to print_queue for status feedback
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
except Exception:
pass
# Migration: Add queue notification event columns to notification_providers
queue_notification_columns = [
("on_queue_job_added", "BOOLEAN DEFAULT 0"),
("on_queue_job_assigned", "BOOLEAN DEFAULT 0"),
("on_queue_job_started", "BOOLEAN DEFAULT 0"),
("on_queue_job_waiting", "BOOLEAN DEFAULT 1"),
("on_queue_job_skipped", "BOOLEAN DEFAULT 1"),
("on_queue_job_failed", "BOOLEAN DEFAULT 1"),
("on_queue_completed", "BOOLEAN DEFAULT 0"),
]
for col_name, col_def in queue_notification_columns:
try:
await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}"))
except Exception:
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+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))
+3
View File
@@ -35,6 +35,9 @@ class PrintArchive(Base):
bed_temperature: Mapped[int | None] = mapped_column(Integer)
nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
# Printer model this file was sliced for (extracted from 3MF metadata)
sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Print result
status: Mapped[str] = mapped_column(String(20), default="completed")
started_at: Mapped[datetime | None] = mapped_column(DateTime)
+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",
},
]
+9
View File
@@ -15,6 +15,15 @@ class PrintQueueItem(Base):
# Links
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
# Target printer model for model-based assignment (mutually exclusive with printer_id)
# When set, scheduler assigns to any idle printer of matching model
target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
# Used by scheduler to validate printer has compatible filaments loaded
required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
# Waiting reason - explains why a model-based job hasn't started yet
# Set by scheduler when no matching printer is available
waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# Either archive_id OR library_file_id must be set (archive created at print start from library file)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
library_file_id: Mapped[int | None] = mapped_column(
+2
View File
@@ -63,6 +63,8 @@ class ArchiveResponse(BaseModel):
bed_temperature: int | None
nozzle_temperature: int | None
sliced_for_model: str | None = None # Printer model this file was sliced for
status: str
started_at: datetime | None
completed_at: datetime | None
+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
@@ -45,6 +45,14 @@ EVENT_VARIABLES: dict[str, list[str]] = {
"ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
"ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
"test": ["app_name", "timestamp"],
# Queue notifications
"queue_job_added": ["job_name", "target", "timestamp", "app_name"],
"queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"],
"queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"],
"queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"],
"queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
"queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
"queue_completed": ["completed_count", "timestamp", "app_name"],
}
# Sample data for previewing templates
@@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
"app_name": "Bambuddy",
"timestamp": "2024-01-15 14:30",
},
# Queue notifications
"queue_job_added": {
"job_name": "Benchy.3mf",
"target": "Bambu X1C",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"queue_job_assigned": {
"job_name": "Benchy.3mf",
"printer": "Bambu X1C #1",
"target_model": "X1C",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"queue_job_started": {
"printer": "Bambu X1C",
"job_name": "Benchy.3mf",
"estimated_time": "1h 23m",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"queue_job_waiting": {
"job_name": "Benchy.3mf",
"target_model": "X1C",
"waiting_reason": "Printer1 (needs PLA)",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"queue_job_skipped": {
"printer": "Bambu X1C",
"job_name": "Benchy.3mf",
"reason": "Previous print failed",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"queue_job_failed": {
"printer": "Bambu X1C",
"job_name": "Benchy.3mf",
"reason": "Upload failed: connection timeout",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"queue_completed": {
"completed_count": "5",
"timestamp": "2024-01-15 18:30",
"app_name": "Bambuddy",
},
}
+6
View File
@@ -17,6 +17,8 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
class PrintQueueItemCreate(BaseModel):
printer_id: int | None = None # None = unassigned, user assigns later
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
# Either archive_id OR library_file_id must be provided
archive_id: int | None = None
library_file_id: int | None = None
@@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel):
class PrintQueueItemUpdate(BaseModel):
printer_id: int | None = None
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
position: int | None = None
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
@@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel):
class PrintQueueItemResponse(BaseModel):
id: int
printer_id: int | None # None = unassigned
target_model: str | None = None # Target printer model for model-based assignment
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
waiting_reason: str | None = None # Why a model-based job hasn't started yet
archive_id: int | None # None if library_file_id is set (archive created at print start)
library_file_id: int | None # For queue items from library files
position: int
+33 -3
View File
@@ -67,6 +67,19 @@ class ThreeMFParser:
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
# Extract printer_model_id from plate metadata
# Format: <plate><metadata key="printer_model_id" value="C11" /></plate>
for meta in root.findall(".//metadata"):
key = meta.get("key")
value = meta.get("value")
if key == "printer_model_id" and value:
from backend.app.utils.printer_models import normalize_printer_model_id
normalized = normalize_printer_model_id(value)
if normalized:
self.metadata["sliced_for_model"] = normalized
break
# Find the plate element (single-plate exports only have one plate)
plate = root.find(".//plate")
@@ -156,7 +169,7 @@ class ThreeMFParser:
pass
def _parse_gcode_header(self, zf: zipfile.ZipFile):
"""Parse G-code file header for total layer count."""
"""Parse G-code file header for total layer count and printer model."""
import re
try:
@@ -165,15 +178,25 @@ class ThreeMFParser:
if not gcode_files:
return
# Read first 2KB of G-code (header contains the layer count)
# Read first 4KB of G-code (header contains metadata)
gcode_path = gcode_files[0]
with zf.open(gcode_path) as f:
header = f.read(2048).decode("utf-8", errors="ignore")
header = f.read(4096).decode("utf-8", errors="ignore")
# Look for "; total layer number: XX" pattern
match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
if match:
self.metadata["total_layers"] = int(match.group(1))
# Look for printer_model in gcode header (fallback if not found in slice_info)
# Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
if "sliced_for_model" not in self.metadata:
match = re.search(r";\s*printer_model\s*=\s*(.+)", header, re.IGNORECASE)
if match:
from backend.app.utils.printer_models import normalize_printer_model
raw_model = match.group(1).strip()
self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
except Exception:
pass
@@ -256,6 +279,12 @@ class ThreeMFParser:
elif isinstance(val, (int, float, str)):
self.metadata["nozzle_temperature"] = int(float(val))
break
# Printer model (extract and normalize)
if "printer_model" in data:
from backend.app.utils.printer_models import normalize_printer_model
self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
except Exception:
pass
@@ -877,6 +906,7 @@ class ArchiveService:
nozzle_diameter=metadata.get("nozzle_diameter"),
bed_temperature=metadata.get("bed_temperature"),
nozzle_temperature=metadata.get("nozzle_temperature"),
sliced_for_model=metadata.get("sliced_for_model"),
makerworld_url=metadata.get("makerworld_url"),
designer=metadata.get("designer"),
status=status,
@@ -945,6 +945,156 @@ class NotificationService:
"""Clear the template cache. Call this when templates are updated."""
self._template_cache.clear()
# ==================== Queue Notifications ====================
async def on_queue_job_added(
self,
job_name: str,
target: str,
db: AsyncSession,
printer_id: int | None = None,
printer_name: str | None = None,
):
"""Handle queue job added event."""
providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id)
if not providers:
return
variables = {
"job_name": job_name,
"target": target, # e.g., "Printer1" or "Any X1C"
"printer": printer_name or target,
}
title, message = await self._build_message_from_template(db, "queue_job_added", variables)
await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name)
async def on_queue_job_assigned(
self,
job_name: str,
printer_id: int,
printer_name: str,
target_model: str,
db: AsyncSession,
):
"""Handle model-based job assigned to printer event."""
providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id)
if not providers:
return
variables = {
"job_name": job_name,
"printer": printer_name,
"target_model": target_model,
}
title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name)
async def on_queue_job_started(
self,
job_name: str,
printer_id: int,
printer_name: str,
db: AsyncSession,
estimated_time: int | None = None,
):
"""Handle queue job started printing event."""
providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id)
if not providers:
return
variables = {
"job_name": job_name,
"printer": printer_name,
"estimated_time": self._format_duration(estimated_time),
}
title, message = await self._build_message_from_template(db, "queue_job_started", variables)
await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name)
async def on_queue_job_waiting(
self,
job_name: str,
target_model: str,
waiting_reason: str,
db: AsyncSession,
):
"""Handle job waiting for filament event."""
providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None)
if not providers:
return
variables = {
"job_name": job_name,
"target_model": target_model,
"waiting_reason": waiting_reason,
}
title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
await self._send_to_providers(providers, title, message, db, "queue_job_waiting")
async def on_queue_job_skipped(
self,
job_name: str,
printer_id: int,
printer_name: str,
reason: str,
db: AsyncSession,
):
"""Handle job skipped event (e.g., previous print failed)."""
providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id)
if not providers:
return
variables = {
"job_name": job_name,
"printer": printer_name,
"reason": reason,
}
title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name)
async def on_queue_job_failed(
self,
job_name: str,
printer_id: int | None,
printer_name: str | None,
reason: str,
db: AsyncSession,
):
"""Handle job failed to start event (upload error, etc.)."""
providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id)
if not providers:
return
variables = {
"job_name": job_name,
"printer": printer_name or "Unknown",
"reason": reason,
}
title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name)
async def on_queue_completed(
self,
completed_count: int,
db: AsyncSession,
):
"""Handle all queue jobs completed event."""
providers = await self._get_providers_for_event(db, "on_queue_completed", None)
if not providers:
return
variables = {
"completed_count": str(completed_count),
}
title, message = await self._build_message_from_template(db, "queue_completed", variables)
await self._send_to_providers(providers, title, message, db, "queue_completed")
async def _queue_for_digest(
self,
provider: NotificationProvider,
+303 -41
View File
@@ -4,7 +4,7 @@ import asyncio
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings
@@ -15,8 +15,10 @@ from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import printer_manager
from backend.app.services.tasmota import tasmota_service
from backend.app.utils.printer_models import normalize_printer_model
logger = logging.getLogger(__name__)
@@ -62,13 +64,10 @@ class PrintScheduler:
if not items:
return
# Group by printer - only process first item per printer
processed_printers = set()
# Track busy printers to avoid assigning multiple items to same printer
busy_printers: set[int] = set()
for item in items:
if item.printer_id in processed_printers:
continue
# Check scheduled time first (scheduled_time is stored in UTC from ISO string)
if item.scheduled_time and item.scheduled_time > datetime.utcnow():
continue
@@ -77,46 +76,254 @@ class PrintScheduler:
if item.manual_start:
continue
# Check if printer is idle
printer_idle = self._is_printer_idle(item.printer_id)
printer_connected = printer_manager.is_connected(item.printer_id)
# If printer not connected, try to power on via smart plug
if not printer_connected:
plug = await self._get_smart_plug(db, item.printer_id)
if plug and plug.auto_on and plug.enabled:
logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
if powered_on:
printer_connected = True
printer_idle = self._is_printer_idle(item.printer_id)
else:
logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
processed_printers.add(item.printer_id)
continue
else:
# No plug or auto_on disabled
processed_printers.add(item.printer_id)
if item.printer_id:
# Specific printer assignment (existing behavior)
if item.printer_id in busy_printers:
continue
# Check if printer is idle (busy with another print)
if not printer_idle:
processed_printers.add(item.printer_id)
# Check if printer is idle
printer_idle = self._is_printer_idle(item.printer_id)
printer_connected = printer_manager.is_connected(item.printer_id)
# If printer not connected, try to power on via smart plug
if not printer_connected:
plug = await self._get_smart_plug(db, item.printer_id)
if plug and plug.auto_on and plug.enabled:
logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
if powered_on:
printer_connected = True
printer_idle = self._is_printer_idle(item.printer_id)
else:
logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
busy_printers.add(item.printer_id)
continue
else:
# No plug or auto_on disabled
busy_printers.add(item.printer_id)
continue
# Check if printer is idle (busy with another print)
if not printer_idle:
busy_printers.add(item.printer_id)
continue
# Check condition (previous print success)
if item.require_previous_success:
if not await self._check_previous_success(db, item):
item.status = "skipped"
item.error_message = "Previous print failed or was aborted"
item.completed_at = datetime.now()
await db.commit()
logger.info(f"Skipped queue item {item.id} - previous print failed")
# Send notification
job_name = await self._get_job_name(db, item)
printer = await self._get_printer(db, item.printer_id)
await notification_service.on_queue_job_skipped(
job_name=job_name,
printer_id=item.printer_id,
printer_name=printer.name if printer else "Unknown",
reason="Previous print failed or was aborted",
db=db,
)
continue
# Start the print
await self._start_print(db, item)
busy_printers.add(item.printer_id)
elif item.target_model:
# Model-based assignment - find any idle printer of matching model
# Parse required filament types if present
required_types = None
if item.required_filament_types:
try:
import json
required_types = json.loads(item.required_filament_types)
except json.JSONDecodeError:
pass
printer_id, waiting_reason = await self._find_idle_printer_for_model(
db, item.target_model, busy_printers, required_types
)
# Update waiting_reason if changed and send notification when first waiting
if item.waiting_reason != waiting_reason:
was_waiting = item.waiting_reason is not None
item.waiting_reason = waiting_reason
await db.commit()
# Send waiting notification only when transitioning to waiting state
if waiting_reason and not was_waiting:
job_name = await self._get_job_name(db, item)
await notification_service.on_queue_job_waiting(
job_name=job_name,
target_model=item.target_model,
waiting_reason=waiting_reason,
db=db,
)
if printer_id:
# Check condition (previous print success) before assigning
if item.require_previous_success:
if not await self._check_previous_success(db, item):
item.status = "skipped"
item.error_message = "Previous print failed or was aborted"
item.completed_at = datetime.now()
await db.commit()
logger.info(f"Skipped queue item {item.id} - previous print failed")
# Send notification
job_name = await self._get_job_name(db, item)
printer = await self._get_printer(db, printer_id)
await notification_service.on_queue_job_skipped(
job_name=job_name,
printer_id=printer_id,
printer_name=printer.name if printer else "Unknown",
reason="Previous print failed or was aborted",
db=db,
)
continue
# Assign printer and start - clear waiting reason
item.printer_id = printer_id
item.waiting_reason = None
logger.info(f"Model-based assignment: queue item {item.id} assigned to printer {printer_id}")
# Send assignment notification
job_name = await self._get_job_name(db, item)
printer = await self._get_printer(db, printer_id)
await notification_service.on_queue_job_assigned(
job_name=job_name,
printer_id=printer_id,
printer_name=printer.name if printer else "Unknown",
target_model=item.target_model,
db=db,
)
await self._start_print(db, item)
busy_printers.add(printer_id)
async def _find_idle_printer_for_model(
self,
db: AsyncSession,
model: str,
exclude_ids: set[int],
required_filament_types: list[str] | None = None,
) -> tuple[int | None, str | None]:
"""Find an idle, connected printer matching the model with compatible filaments.
Args:
db: Database session
model: Printer model to match (e.g., "X1C", "P1S")
exclude_ids: Printer IDs to exclude (already busy)
required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
If provided, only printers with all required types loaded will match.
Returns:
Tuple of (printer_id, waiting_reason):
- (printer_id, None) if a matching printer was found
- (None, reason) if no printer is available, with explanation
"""
# Normalize model name and use case-insensitive matching
normalized_model = normalize_printer_model(model) or model
result = await db.execute(
select(Printer)
.where(func.lower(Printer.model) == normalized_model.lower())
.where(Printer.is_active == True) # noqa: E712
)
printers = list(result.scalars().all())
if not printers:
return None, f"No active {normalized_model} printers configured"
# Track reasons for skipping printers
printers_busy = []
printers_offline = []
printers_missing_filament = []
for printer in printers:
if printer.id in exclude_ids:
printers_busy.append(printer.name)
continue
is_connected = printer_manager.is_connected(printer.id)
is_idle = self._is_printer_idle(printer.id) if is_connected else False
if not is_connected:
printers_offline.append(printer.name)
continue
if not is_idle:
printers_busy.append(printer.name)
continue
# Validate filament compatibility if required types are specified
if required_filament_types:
missing = self._get_missing_filament_types(printer.id, required_filament_types)
if missing:
printers_missing_filament.append((printer.name, missing))
logger.debug(f"Skipping printer {printer.id} ({printer.name}) - missing filaments: {missing}")
continue
# Check condition (previous print success)
if item.require_previous_success:
if not await self._check_previous_success(db, item):
item.status = "skipped"
item.error_message = "Previous print failed or was aborted"
item.completed_at = datetime.now()
await db.commit()
logger.info(f"Skipped queue item {item.id} - previous print failed")
continue
# Found a matching printer - clear waiting reason
return printer.id, None
# Start the print
await self._start_print(db, item)
processed_printers.add(item.printer_id)
# Build waiting reason from what we found
reasons = []
if printers_missing_filament:
# Filament mismatch is most actionable - show first
names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
if printers_busy:
reasons.append(f"Busy: {', '.join(printers_busy)}")
if printers_offline:
reasons.append(f"Offline: {', '.join(printers_offline)}")
return None, " | ".join(reasons) if reasons else f"No available {model} printers"
def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
"""Get the list of required filament types that are not loaded on the printer.
Args:
printer_id: The printer ID
required_types: List of filament types needed (e.g., ["PLA", "PETG"])
Returns:
List of missing filament types (empty if all are loaded)
"""
status = printer_manager.get_status(printer_id)
if not status:
return required_types # Can't determine, assume all missing
# Collect all filament types loaded on this printer (AMS units + external spool)
loaded_types: set[str] = set()
# Check AMS units (stored in raw_data["ams"])
ams_data = status.raw_data.get("ams", [])
if ams_data:
for ams_unit in ams_data:
for tray in ams_unit.get("tray", []):
tray_type = tray.get("tray_type")
if tray_type:
loaded_types.add(tray_type.upper())
# Check external spool (virtual tray, stored in raw_data["vt_tray"])
vt_tray = status.raw_data.get("vt_tray")
if vt_tray:
vt_type = vt_tray.get("tray_type")
if vt_type:
loaded_types.add(vt_type.upper())
# Find which required types are missing (case-insensitive comparison)
missing = []
for req_type in required_types:
if req_type.upper() not in loaded_types:
missing.append(req_type)
return missing
def _is_printer_idle(self, printer_id: int) -> bool:
"""Check if a printer is connected and idle."""
@@ -220,6 +427,25 @@ class PrintScheduler:
logger.info(f"Auto-off: Powering off printer {item.printer_id}")
await tasmota_service.turn_off(plug)
async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
"""Get a human-readable name for a queue item."""
if item.archive_id:
result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
archive = result.scalar_one_or_none()
if archive:
return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "")
if item.library_file_id:
result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
library_file = result.scalar_one_or_none()
if library_file:
return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
return f"Job #{item.id}"
async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:
"""Get printer by ID."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
return result.scalar_one_or_none()
async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
"""Upload file and start print for a queue item.
@@ -371,6 +597,16 @@ class PrintScheduler:
item.completed_at = datetime.utcnow()
await db.commit()
logger.error(f"Queue item {item.id}: FTP upload failed")
# Send failure notification
await notification_service.on_queue_job_failed(
job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
printer_id=printer.id,
printer_name=printer.name,
reason="Failed to upload file to printer",
db=db,
)
await self._power_off_if_needed(db, item)
return
@@ -411,6 +647,22 @@ class PrintScheduler:
await db.commit()
logger.info(f"Queue item {item.id}: Print started - {filename}")
# Get estimated time for notification
estimated_time = None
if archive and archive.print_time_seconds:
estimated_time = archive.print_time_seconds
elif library_file and library_file.print_time_seconds:
estimated_time = library_file.print_time_seconds
# Send job started notification
await notification_service.on_queue_job_started(
job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
printer_id=printer.id,
printer_name=printer.name,
db=db,
estimated_time=estimated_time,
)
# MQTT relay - publish queue job started
try:
from backend.app.services.mqtt_relay import mqtt_relay
@@ -430,6 +682,16 @@ class PrintScheduler:
item.completed_at = datetime.utcnow()
await db.commit()
logger.error(f"Queue item {item.id}: Failed to start print")
# Send failure notification
await notification_service.on_queue_job_failed(
job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
printer_id=printer.id,
printer_name=printer.name,
reason="Failed to send print command",
db=db,
)
await self._power_off_if_needed(db, item)
+86
View File
@@ -0,0 +1,86 @@
"""Printer model normalization utilities.
Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to
normalized short names (e.g., "X1C") that match database storage.
"""
# Map from 3MF printer_model strings to normalized short names
PRINTER_MODEL_MAP = {
"Bambu Lab X1 Carbon": "X1C",
"Bambu Lab X1": "X1",
"Bambu Lab X1E": "X1E",
"Bambu Lab P1S": "P1S",
"Bambu Lab P1P": "P1P",
"Bambu Lab P2S": "P2S",
"Bambu Lab A1": "A1",
"Bambu Lab A1 Mini": "A1 Mini",
"Bambu Lab A1 mini": "A1 Mini",
"Bambu Lab H2D": "H2D",
"Bambu Lab H2D Pro": "H2D Pro",
}
# Map from printer_model_id (internal codes in slice_info.config) to short names
# These are the codes Bambu Studio uses internally
PRINTER_MODEL_ID_MAP = {
# X1 series
"C11": "X1C",
"C12": "X1",
"C13": "X1E",
# P1 series
"P1P": "P1P",
"P1S": "P1S",
# P2 series
"P2S": "P2S",
# A1 series
"A11": "A1",
"A12": "A1 Mini",
"N1": "A1",
"N2S": "A1 Mini",
"A04": "A1 Mini",
# H2D series (Office/H series)
"O1D": "H2D",
"O2D": "H2D Pro",
}
def normalize_printer_model_id(model_id: str | None) -> str | None:
"""Convert printer_model_id (internal code) to normalized short name.
Args:
model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D")
Returns:
Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown.
"""
if not model_id:
return None
# Check known mappings
if model_id in PRINTER_MODEL_ID_MAP:
return PRINTER_MODEL_ID_MAP[model_id]
# Return original if unknown (might already be a short name)
return model_id
def normalize_printer_model(raw_model: str | None) -> str | None:
"""Convert 3MF printer_model to normalized short name.
Args:
raw_model: The printer_model string from 3MF metadata
(e.g., "Bambu Lab X1 Carbon")
Returns:
Normalized short name (e.g., "X1C") or None if input is empty.
Unknown models have "Bambu Lab " prefix stripped.
"""
if not raw_model:
return None
# Check known mappings first
if raw_model in PRINTER_MODEL_MAP:
return PRINTER_MODEL_MAP[raw_model]
# Strip "Bambu Lab " prefix for unknown models
stripped = raw_model.replace("Bambu Lab ", "").strip()
return stripped or None
@@ -63,6 +63,14 @@ const createMockProvider = (
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: true,
on_queue_job_added: false,
on_queue_job_assigned: false,
on_queue_job_started: false,
on_queue_job_waiting: true,
on_queue_job_skipped: true,
on_queue_job_failed: true,
on_queue_completed: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
@@ -282,3 +290,83 @@ describe('NotificationProviderCard AMS toggles', () => {
});
});
});
describe('NotificationProviderCard Queue notifications', () => {
describe('queue job notifications', () => {
it('includes on_queue_job_added in provider data', () => {
const provider = createMockProvider({ on_queue_job_added: true });
expect(provider.on_queue_job_added).toBe(true);
});
it('includes on_queue_job_assigned in provider data', () => {
const provider = createMockProvider({ on_queue_job_assigned: true });
expect(provider.on_queue_job_assigned).toBe(true);
});
it('includes on_queue_job_started in provider data', () => {
const provider = createMockProvider({ on_queue_job_started: true });
expect(provider.on_queue_job_started).toBe(true);
});
it('includes on_queue_job_waiting in provider data', () => {
const provider = createMockProvider({ on_queue_job_waiting: true });
expect(provider.on_queue_job_waiting).toBe(true);
});
it('includes on_queue_job_skipped in provider data', () => {
const provider = createMockProvider({ on_queue_job_skipped: true });
expect(provider.on_queue_job_skipped).toBe(true);
});
it('includes on_queue_job_failed in provider data', () => {
const provider = createMockProvider({ on_queue_job_failed: true });
expect(provider.on_queue_job_failed).toBe(true);
});
it('includes on_queue_completed in provider data', () => {
const provider = createMockProvider({ on_queue_completed: true });
expect(provider.on_queue_completed).toBe(true);
});
});
describe('queue notification defaults', () => {
it('defaults actionable notifications to true', () => {
const provider = createMockProvider();
// These should default to true (actionable - user needs to do something)
expect(provider.on_queue_job_waiting).toBe(true);
expect(provider.on_queue_job_skipped).toBe(true);
expect(provider.on_queue_job_failed).toBe(true);
});
it('defaults informational notifications to false', () => {
const provider = createMockProvider();
// These should default to false (informational only)
expect(provider.on_queue_job_added).toBe(false);
expect(provider.on_queue_job_assigned).toBe(false);
expect(provider.on_queue_job_started).toBe(false);
expect(provider.on_queue_completed).toBe(false);
});
});
describe('queue notification combinations', () => {
it('supports all queue toggles independently', () => {
const provider = createMockProvider({
on_queue_job_added: true,
on_queue_job_assigned: false,
on_queue_job_started: true,
on_queue_job_waiting: false,
on_queue_job_skipped: true,
on_queue_job_failed: false,
on_queue_completed: true,
});
expect(provider.on_queue_job_added).toBe(true);
expect(provider.on_queue_job_assigned).toBe(false);
expect(provider.on_queue_job_started).toBe(true);
expect(provider.on_queue_job_waiting).toBe(false);
expect(provider.on_queue_job_skipped).toBe(true);
expect(provider.on_queue_job_failed).toBe(false);
expect(provider.on_queue_completed).toBe(true);
});
});
});
@@ -10,20 +10,12 @@ import { UploadModal } from '../../components/UploadModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{ id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' },
{ id: 2, name: 'P1S', model: 'P1S', serial_number: '456' },
];
describe('UploadModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.post('/api/v1/archives/upload-bulk', async () => {
return HttpResponse.json({
uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
});
it('renders printer selection dropdown', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('renders Cancel button', () => {
render(<UploadModal onClose={mockOnClose} />);
@@ -79,32 +60,6 @@ describe('UploadModal', () => {
});
});
describe('printer selection', () => {
it('shows available printers in dropdown', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
// Check for printer options in the select
expect(screen.getByRole('option', { name: 'No printer' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'P1S' })).toBeInTheDocument();
});
});
it('allows selecting a printer', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: '1' } });
expect(select).toHaveValue('1');
});
});
describe('file handling with initialFiles', () => {
it('shows initial files when provided', () => {
const initialFiles = [
+30
View File
@@ -300,6 +300,7 @@ export interface Archive {
nozzle_diameter: number | null;
bed_temperature: number | null;
nozzle_temperature: number | null;
sliced_for_model: string | null; // Printer model this file was sliced for
status: string;
started_at: string | null;
completed_at: string | null;
@@ -1001,6 +1002,9 @@ export interface DiscoveredTasmotaDevice {
export interface PrintQueueItem {
id: number;
printer_id: number | null; // null = unassigned
target_model: string | null; // Target printer model for model-based assignment
required_filament_types: string[] | null; // Required filament types for model-based assignment
waiting_reason: string | null; // Why a model-based job hasn't started yet
// Either archive_id OR library_file_id must be set (archive created at print start)
archive_id: number | null;
library_file_id: number | null;
@@ -1033,6 +1037,7 @@ export interface PrintQueueItem {
export interface PrintQueueItemCreate {
printer_id?: number | null; // null = unassigned
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
// Either archive_id OR library_file_id must be provided
archive_id?: number | null;
library_file_id?: number | null;
@@ -1053,6 +1058,7 @@ export interface PrintQueueItemCreate {
export interface PrintQueueItemUpdate {
printer_id?: number | null; // null = unassign
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
position?: number;
scheduled_time?: string | null;
require_previous_success?: boolean;
@@ -1212,6 +1218,14 @@ export interface NotificationProvider {
on_ams_ht_temperature_high: boolean;
// Build plate detection
on_plate_not_empty: boolean;
// Print queue events
on_queue_job_added: boolean;
on_queue_job_assigned: boolean;
on_queue_job_started: boolean;
on_queue_job_waiting: boolean;
on_queue_job_skipped: boolean;
on_queue_job_failed: boolean;
on_queue_completed: boolean;
// Quiet hours
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
@@ -1254,6 +1268,14 @@ export interface NotificationProviderCreate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
on_queue_job_started?: boolean;
on_queue_job_waiting?: boolean;
on_queue_job_skipped?: boolean;
on_queue_job_failed?: boolean;
on_queue_completed?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -1289,6 +1311,14 @@ export interface NotificationProviderUpdate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
on_queue_job_started?: boolean;
on_queue_job_waiting?: boolean;
on_queue_job_skipped?: boolean;
on_queue_job_failed?: boolean;
on_queue_completed?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
</div>
{/* Print Queue Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Queue</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Added</p>
<p className="text-xs text-bambu-gray">Job added to queue</p>
</div>
<Toggle
checked={provider.on_queue_job_added ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_added: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Assigned</p>
<p className="text-xs text-bambu-gray">Model-based job assigned to printer</p>
</div>
<Toggle
checked={provider.on_queue_job_assigned ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_assigned: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Started</p>
<p className="text-xs text-bambu-gray">Queue job started printing</p>
</div>
<Toggle
checked={provider.on_queue_job_started ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_started: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Waiting</p>
<p className="text-xs text-bambu-gray">Job waiting for filament</p>
</div>
<Toggle
checked={provider.on_queue_job_waiting ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_waiting: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Skipped</p>
<p className="text-xs text-bambu-gray">Job skipped (previous failed)</p>
</div>
<Toggle
checked={provider.on_queue_job_skipped ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_skipped: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Failed</p>
<p className="text-xs text-bambu-gray">Job failed to start</p>
</div>
<Toggle
checked={provider.on_queue_job_failed ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_failed: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Queue Complete</p>
<p className="text-xs text-bambu-gray">All queue jobs finished</p>
</div>
<Toggle
checked={provider.on_queue_completed ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}
/>
</div>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Printer as PrinterIcon,
@@ -9,6 +9,7 @@ import {
Circle,
RefreshCw,
Wand2,
Users,
} from 'lucide-react';
import { api } from '../../api/client';
import { getColorName } from '../../utils/colors';
@@ -16,7 +17,7 @@ import {
normalizeColorForCompare,
colorsAreSimilar,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps } from './types';
import type { PrinterSelectorProps, AssignmentMode } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
onAutoConfigurePrinter?: (printerId: number) => void;
/** Callback to update printer config */
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
/** Handler for assignment mode change */
onAssignmentModeChange?: (mode: AssignmentMode) => void;
/** Selected target model (when assignmentMode is 'model') */
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/**
@@ -212,9 +223,42 @@ export function PrinterSelector({
filamentReqs,
onAutoConfigurePrinter,
onUpdatePrinterConfig,
assignmentMode = 'printer',
onAssignmentModeChange,
targetModel,
onTargetModelChange,
slicedForModel,
}: PrinterSelectorWithMappingProps) {
// State for showing all printers vs only matching model
const [showAllPrinters, setShowAllPrinters] = useState(false);
// Filter printers based on showInactive flag
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
// Filter by sliced model (only in printer mode, when slicedForModel is set)
const displayPrinters = useMemo(() => {
if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
return activePrinters;
}
// Filter to only show printers matching the sliced model
const matching = activePrinters.filter((p) => p.model === slicedForModel);
// If no matching printers, show all
return matching.length > 0 ? matching : activePrinters;
}, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
// Check if there are hidden printers due to model filtering
const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
// Get unique models from available printers (for model-based assignment)
const uniqueModels = useMemo(() => {
const models = activePrinters
.map(p => p.model)
.filter((m): m is string => Boolean(m));
return [...new Set(models)].sort();
}, [activePrinters]);
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
const showMappingOptions = allowMultiple &&
selectedPrinterIds.length > 1 &&
@@ -285,8 +329,56 @@ export function PrinterSelector({
return (
<div className="space-y-2 mb-6">
{/* Multi-select header */}
{allowMultiple && displayPrinters.length > 1 && (
{/* Assignment mode toggle (model vs specific printer) */}
{modelAssignmentAvailable && (
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => {
onAssignmentModeChange!('printer');
onTargetModelChange!(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
assignmentMode === 'printer'
? 'border-bambu-green bg-bambu-green/10 text-white'
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
}`}
>
<PrinterIcon className="w-4 h-4" />
<span className="text-sm">Specific Printer</span>
</button>
<button
type="button"
onClick={() => {
onAssignmentModeChange!('model');
onMultiSelect([]);
// Pre-select the sliced-for model if available, otherwise first model
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
? slicedForModel
: uniqueModels[0];
onTargetModelChange!(defaultModel);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
assignmentMode === 'model'
? 'border-bambu-green bg-bambu-green/10 text-white'
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
}`}
>
<Users className="w-4 h-4" />
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
</button>
</div>
)}
{/* Model info (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
<p className="text-xs text-bambu-gray mb-4">
Scheduler will assign to first available idle {targetModel} printer
</p>
)}
{/* Multi-select header (only in printer mode) */}
{assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
<span>
{selectedCount === 0
@@ -316,7 +408,8 @@ export function PrinterSelector({
</div>
)}
{displayPrinters.map((printer) => {
{/* Printer list (only in printer mode) */}
{assignmentMode === 'printer' && displayPrinters.map((printer) => {
const selected = isSelected(printer.id);
const mappingResult = getPrinterMappingResult(printer.id);
const hasOverride = mappingResult && !mappingResult.config.useDefault;
@@ -430,13 +523,45 @@ export function PrinterSelector({
);
})}
{/* Warning when no printer selected */}
{selectedCount === 0 && (
{/* Show hidden printers toggle */}
{assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
<button
type="button"
onClick={() => setShowAllPrinters(true)}
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
>
<AlertTriangle className="w-3 h-3 text-yellow-400" />
{hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
<span className="underline">show all</span>
</button>
)}
{/* Show matching only toggle */}
{assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
<button
type="button"
onClick={() => setShowAllPrinters(false)}
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
>
<span className="underline">Show only {slicedForModel} printers</span>
</button>
)}
{/* Warning when no printer selected (only in printer mode) */}
{assignmentMode === 'printer' && selectedCount === 0 && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select at least one printer
</p>
)}
{/* Warning when no model selected (only in model mode) */}
{assignmentMode === 'model' && !targetModel && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select a target printer model
</p>
)}
</div>
);
}
+144 -42
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
import { Card, CardContent } from '../Card';
@@ -19,6 +19,7 @@ import type {
PrintOptions,
ScheduleOptions,
ScheduleType,
AssignmentMode,
} from './types';
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
@@ -117,6 +118,23 @@ export function PrintModal({
// Per-printer override configs (for multi-printer selection)
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
// Assignment mode: 'printer' (specific) or 'model' (any of model)
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
// Initialize from queue item if editing with target_model
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return 'model';
}
return 'printer';
});
// Target model for model-based assignment
const [targetModel, setTargetModel] = useState<string | null>(() => {
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return queueItem.target_model;
}
return null;
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -145,6 +163,16 @@ export function PrintModal({
queryFn: api.getPrinters,
});
// Fetch archive details to get sliced_for_model
const { data: archiveDetails } = useQuery({
queryKey: ['archive', archiveId],
queryFn: () => api.getArchive(archiveId!),
enabled: !!archiveId && !isLibraryFile,
});
// Get sliced_for_model from archive or library file
const slicedForModel = archiveDetails?.sliced_for_model || null;
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
@@ -304,14 +332,20 @@ export function PrintModal({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
// Validate printer selection
if (selectedPrinters.length === 0) {
// Validate printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
showToast('Please select at least one printer', 'error');
return;
}
if (assignmentMode === 'model' && !targetModel) {
showToast('Please select a target printer model', 'error');
return;
}
setIsSubmitting(true);
setSubmitProgress({ current: 0, total: selectedPrinters.length });
// For model-based assignment, we just make one API call
const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
setSubmitProgress({ current: 0, total: totalCount });
const results: { success: number; failed: number; errors: string[] } = {
success: 0,
@@ -332,15 +366,16 @@ export function PrintModal({
};
// Common queue data for add-to-queue and edit modes
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
printer_id: printerId,
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: getMappingForPrinter(printerId),
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -348,35 +383,24 @@ export function PrintModal({
...printOptions,
});
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
// Model-based assignment: single API call
if (assignmentMode === 'model') {
setSubmitProgress({ current: 1, total: 1 });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
// Model-based reprint not supported (need specific printer for immediate print)
showToast('Model-based assignment only works with queue mode', 'error');
setIsSubmitting(false);
return;
} else if (mode === 'edit-queue-item') {
// Edit mode - update with target_model
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
printer_id: null,
target_model: targetModel,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
ams_mapping: undefined,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -385,14 +409,63 @@ export function PrintModal({
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
// Add-to-queue mode with model-based assignment
await addToQueueMutation.mutateAsync(getQueueData(null));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
results.errors.push((error as Error).message);
}
} else {
// Printer-based assignment: loop through selected printers
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
target_model: null,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
}
}
}
@@ -400,11 +473,15 @@ export function PrintModal({
// Show result toast
if (results.failed === 0) {
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
if (assignmentMode === 'model') {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
} else {
showToast(`Print ${action} ${results.success} printers`);
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
} else {
showToast(`Print ${action} ${results.success} printers`);
}
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
onSuccess?.();
@@ -422,14 +499,18 @@ export function PrintModal({
const canSubmit = useMemo(() => {
if (isPending) return false;
// Need at least one selected printer
if (selectedPrinters.length === 0) return false;
// Need valid printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
if (assignmentMode === 'model' && !targetModel) return false;
// Model-based assignment only works in queue modes (not immediate reprint)
if (assignmentMode === 'model' && mode === 'reprint') return false;
// For multi-plate archive files, need a selected plate (library files skip this)
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
return true;
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
// Modal title and action button text based on mode
const getModalConfig = () => {
@@ -541,8 +622,29 @@ export function PrintModal({
filamentReqs={effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
slicedForModel={slicedForModel}
/>
{/* Compatibility warning when sliced model doesn't match selected printer */}
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
return (
<div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-400">
File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
</span>
</div>
);
}
return null;
})()}
{/* Warning when archive data couldn't be loaded */}
{archiveDataMissing && (
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
@@ -565,7 +667,7 @@ export function PrintModal({
)}
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0) && (
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
)}
@@ -104,6 +104,13 @@ export interface PlatesResponse {
plates: PlateInfo[];
}
/**
* Assignment mode for queue items.
* - 'printer': Assign to specific printer(s)
* - 'model': Assign to any printer of a specific model (load balancing)
*/
export type AssignmentMode = 'printer' | 'model';
/**
* Props for the PrinterSelector component.
*/
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
allowMultiple?: boolean;
/** Show inactive printers (for edit mode where original assignment may be inactive) */
showInactive?: boolean;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
/** Handler for assignment mode change */
onAssignmentModeChange?: (mode: AssignmentMode) => void;
/** Selected target model (when assignmentMode is 'model') */
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/**
+6 -27
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { BulkUploadResult } from '../api/client';
@@ -27,7 +27,6 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
);
const [isDragging, setIsDragging] = useState(false);
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
// Close on Escape key
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const uploadMutation = useMutation({
mutationFn: (filesToUpload: File[]) =>
api.uploadArchivesBulk(filesToUpload, selectedPrinter),
api.uploadArchivesBulk(filesToUpload),
onSuccess: (result) => {
setUploadResult(result);
queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
</div>
</div>
{/* Optional Printer Selection */}
{/* Info about printer model extraction */}
<div className="px-4 pb-4">
<label className="block text-sm text-bambu-gray mb-2">
Associate with printer (optional)
</label>
<select
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={selectedPrinter || ''}
onChange={(e) =>
setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
}
disabled={isUploading}
>
<option value="">No printer</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray">
The printer model will be automatically extracted from the 3MF file metadata.
</p>
</div>
{/* File List */}
+21 -4
View File
@@ -797,6 +797,12 @@ function ArchiveCard({
{archive.object_count} object{archive.object_count > 1 ? 's' : ''}
</div>
)}
{archive.sliced_for_model && (
<div className="flex items-center gap-1.5 text-bambu-gray" title={`Sliced for ${archive.sliced_for_model}`}>
<Printer className="w-3 h-3" />
{archive.sliced_for_model}
</div>
)}
{archive.filament_type && (
<div className="flex items-center gap-1.5 col-span-2">
<span className="text-bambu-gray text-xs">{archive.filament_type}</span>
@@ -1612,9 +1618,20 @@ function ArchiveListRow({
</Link>
)}
</div>
{archive.filament_type && (
{(archive.filament_type || archive.sliced_for_model) && (
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
{archive.sliced_for_model && (
<span className="text-xs text-bambu-gray flex items-center gap-1" title={`Sliced for ${archive.sliced_for_model}`}>
<Printer className="w-2.5 h-2.5" />
{archive.sliced_for_model}
</span>
)}
{archive.sliced_for_model && archive.filament_type && (
<span className="text-bambu-gray/50">·</span>
)}
{archive.filament_type && (
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
)}
{archive.filament_color && (
<div className="flex items-center gap-0.5 flex-wrap">
{archive.filament_color.split(',').map((color, i) => (
@@ -2755,7 +2772,7 @@ export function ArchivesPage() {
<ArchiveCard
key={archive.id}
archive={archive}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
isSelected={selectedIds.has(archive.id)}
onSelect={toggleSelect}
selectionMode={selectionMode}
@@ -2782,7 +2799,7 @@ export function ArchivesPage() {
<ArchiveListRow
key={archive.id}
archive={archive}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
isSelected={selectedIds.has(archive.id)}
onSelect={toggleSelect}
selectionMode={selectionMode}
+26 -4
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' },
@@ -399,9 +409,13 @@ function SortableQueueItem({
</div>
<div className="flex items-center gap-3 text-sm text-bambu-gray">
<span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
{item.target_model
? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
: item.printer_id === null
? 'Unassigned'
: (item.printer_name || `Printer #${item.printer_id}`)}
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
@@ -448,6 +462,14 @@ function SortableQueueItem({
</div>
)}
{/* Waiting reason for model-based assignments */}
{item.waiting_reason && item.status === 'pending' && (
<p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
<AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{item.waiting_reason}</span>
</p>
)}
{/* Error message */}
{item.error_message && (
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
@@ -458,7 +480,7 @@ function SortableQueueItem({
</div>
{/* Status badge */}
<StatusBadge status={item.status} />
<StatusBadge status={item.status} waitingReason={item.waiting_reason} />
{/* Actions */}
<div className="flex items-center gap-1">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long