Browse Source

Merge pull request #189 from maziggy/feature/printer_model_queue

Feature/printer model queue
MartinNYHC 3 months ago
parent
commit
129ed5a29f
33 changed files with 1568 additions and 183 deletions
  1. 18 0
      CHANGELOG.md
  2. 7 4
      README.md
  3. 1 0
      backend/app/api/routes/archives.py
  4. 8 0
      backend/app/api/routes/notification_templates.py
  5. 8 0
      backend/app/api/routes/notifications.py
  6. 175 7
      backend/app/api/routes/print_queue.py
  7. 21 0
      backend/app/api/routes/settings.py
  8. 40 0
      backend/app/core/database.py
  9. 28 0
      backend/app/main.py
  10. 3 0
      backend/app/models/archive.py
  11. 9 0
      backend/app/models/notification.py
  12. 43 0
      backend/app/models/notification_template.py
  13. 9 0
      backend/app/models/print_queue.py
  14. 2 0
      backend/app/schemas/archive.py
  15. 18 0
      backend/app/schemas/notification.py
  16. 55 0
      backend/app/schemas/notification_template.py
  17. 6 0
      backend/app/schemas/print_queue.py
  18. 33 3
      backend/app/services/archive.py
  19. 150 0
      backend/app/services/notification_service.py
  20. 301 39
      backend/app/services/print_scheduler.py
  21. 86 0
      backend/app/utils/printer_models.py
  22. 88 0
      frontend/src/__tests__/components/NotificationProviderCard.test.tsx
  23. 0 45
      frontend/src/__tests__/components/UploadModal.test.tsx
  24. 30 0
      frontend/src/api/client.ts
  25. 82 0
      frontend/src/components/NotificationProviderCard.tsx
  26. 133 8
      frontend/src/components/PrintModal/PrinterSelector.tsx
  27. 144 42
      frontend/src/components/PrintModal/index.tsx
  28. 17 0
      frontend/src/components/PrintModal/types.ts
  29. 6 27
      frontend/src/components/UploadModal.tsx
  30. 21 4
      frontend/src/pages/ArchivesPage.tsx
  31. 26 4
      frontend/src/pages/QueuePage.tsx
  32. 0 0
      static/assets/index-8OJBC-HQ.css
  33. 0 0
      static/assets/index-BvTstZiS.js

+ 18 - 0
CHANGELOG.md

@@ -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
README.md

@@ -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 - 0
backend/app/api/routes/archives.py

@@ -78,6 +78,7 @@ def archive_to_response(
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
+        "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
         "started_at": archive.started_at,
         "completed_at": archive.completed_at,

+ 8 - 0
backend/app/api/routes/notification_templates.py

@@ -32,6 +32,14 @@ EVENT_NAMES = {
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "test": "Test Notification",
+    # Queue notifications
+    "queue_job_added": "Queue Job Added",
+    "queue_job_assigned": "Queue Job Assigned",
+    "queue_job_started": "Queue Job Started",
+    "queue_job_waiting": "Queue Job Waiting",
+    "queue_job_skipped": "Queue Job Skipped",
+    "queue_job_failed": "Queue Job Failed",
+    "queue_completed": "Queue Completed",
 }
 
 

+ 8 - 0
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,

+ 175 - 7
backend/app/api/routes/print_queue.py

@@ -2,13 +2,17 @@
 
 import json
 import logging
+import xml.etree.ElementTree as ET
+import zipfile
 from datetime import datetime
+from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
@@ -22,12 +26,75 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     PrintQueueReorder,
 )
+from backend.app.services.notification_service import notification_service
+from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/queue", tags=["queue"])
 
 
+def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]:
+    """Extract unique filament types from a 3MF file.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
+
+    Returns:
+        List of unique filament types (e.g., ["PLA", "PETG"])
+    """
+    types: set[str] = set()
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return []
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            if plate_id is not None:
+                # Find the plate element with matching index
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for filament_elem in plate_elem.findall("filament"):
+                            filament_type = filament_elem.get("type", "")
+                            used_g = filament_elem.get("used_g", "0")
+                            try:
+                                used_grams = float(used_g)
+                            except (ValueError, TypeError):
+                                used_grams = 0
+                            if used_grams > 0 and filament_type:
+                                types.add(filament_type)
+                        break
+            else:
+                # No plate_id specified - extract all filaments with used_g > 0
+                for filament_elem in root.findall(".//filament"):
+                    filament_type = filament_elem.get("type", "")
+                    used_g = filament_elem.get("used_g", "0")
+                    try:
+                        used_grams = float(used_g)
+                    except (ValueError, TypeError):
+                        used_grams = 0
+                    if used_grams > 0 and filament_type:
+                        types.add(filament_type)
+
+    except Exception as e:
+        logger.warning(f"Failed to extract filament types from {file_path}: {e}")
+
+    return sorted(types)
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -38,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             ams_mapping_parsed = None
 
+    # Parse required_filament_types from JSON string
+    required_filament_types_parsed = None
+    if item.required_filament_types:
+        try:
+            required_filament_types_parsed = json.loads(item.required_filament_types)
+        except json.JSONDecodeError:
+            required_filament_types_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
         "printer_id": item.printer_id,
+        "target_model": item.target_model,
+        "required_filament_types": required_filament_types_parsed,
+        "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
         "position": item.position,
@@ -120,29 +198,71 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
 ):
     """Add an item to the print queue."""
+    # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
+    target_model_norm = None
+    if data.target_model:
+        target_model_norm = (
+            normalize_printer_model(data.target_model)
+            or normalize_printer_model_id(data.target_model)
+            or data.target_model
+        )
+
     # Validate that either archive_id or library_file_id is provided
     if not data.archive_id and not data.library_file_id:
         raise HTTPException(400, "Either archive_id or library_file_id must be provided")
 
+    # Cannot specify both printer_id and target_model
+    if data.printer_id and target_model_norm:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Validate archive exists (if provided)
+    # Validate target_model has active printers
+    if target_model_norm:
+        result = await db.execute(
+            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
+        )
+        if not result.scalars().first():
+            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+
+    # Validate archive exists (if provided) and get it for filament extraction
+    archive = None
     if data.archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-        if not result.scalar_one_or_none():
+        archive = result.scalar_one_or_none()
+        if not archive:
             raise HTTPException(400, "Archive not found")
 
-    # Validate library file exists (if provided)
+    # Validate library file exists (if provided) and get it for filament extraction
+    library_file = None
     if data.library_file_id:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
-        if not result.scalar_one_or_none():
+        library_file = result.scalar_one_or_none()
+        if not library_file:
             raise HTTPException(400, "Library file not found")
 
-    # Get next position for this printer (or for unassigned items)
+    # Extract filament types for model-based assignment (used by scheduler for validation)
+    required_filament_types = None
+    if target_model_norm:
+        # Get file path from archive or library file
+        file_path = None
+        if archive:
+            file_path = settings.base_dir / archive.file_path
+        elif library_file:
+            lib_path = Path(library_file.file_path)
+            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+
+        if file_path and file_path.exists():
+            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
+            if filament_types:
+                required_filament_types = json.dumps(filament_types)
+                logger.info(f"Extracted filament types for model-based queue: {filament_types}")
+
+    # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
@@ -150,7 +270,7 @@ async def add_to_queue(
             .where(PrintQueueItem.status == "pending")
         )
     else:
-        # For unassigned items, get max position across all unassigned
+        # For unassigned/model-based items, get max position across all unassigned
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
             .where(PrintQueueItem.printer_id.is_(None))
@@ -160,6 +280,8 @@ async def add_to_queue(
 
     item = PrintQueueItem(
         printer_id=data.printer_id,
+        target_model=target_model_norm,
+        required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
@@ -185,7 +307,8 @@ async def add_to_queue(
     await db.refresh(item, ["archive", "printer", "library_file"])
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
-    logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
+    target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
+    logger.info(f"Added {source_name} to queue for {target_desc}")
 
     # MQTT relay - publish queue job added
     try:
@@ -200,6 +323,29 @@ async def add_to_queue(
     except Exception:
         pass  # Don't fail queue add if MQTT fails
 
+    # Send notification for job added
+    try:
+        job_name = (
+            item.archive.filename
+            if item.archive
+            else item.library_file.filename
+            if item.library_file
+            else f"Job #{item.id}"
+        )
+        job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
+        target = (
+            item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
+        )
+        await notification_service.on_queue_job_added(
+            job_name=job_name,
+            target=target,
+            db=db,
+            printer_id=item.printer_id,
+            printer_name=item.printer.name if item.printer else None,
+        )
+    except Exception:
+        pass  # Don't fail queue add if notification fails
+
     return _enrich_response(item)
 
 
@@ -287,12 +433,34 @@ async def update_queue_item(
 
     update_data = data.model_dump(exclude_unset=True)
 
+    # Normalize target_model if being updated
+    if "target_model" in update_data and update_data["target_model"]:
+        update_data["target_model"] = (
+            normalize_printer_model(update_data["target_model"])
+            or normalize_printer_model_id(update_data["target_model"])
+            or update_data["target_model"]
+        )
+
+    # Cannot specify both printer_id and target_model
+    new_printer_id = update_data.get("printer_id", item.printer_id)
+    new_target_model = update_data.get("target_model", item.target_model)
+    if new_printer_id and new_target_model:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
     # Validate new printer_id if being changed (and not None)
     if "printer_id" in update_data and update_data["printer_id"] is not None:
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
+    # Validate target_model has active printers
+    if "target_model" in update_data and update_data["target_model"]:
+        result = await db.execute(
+            select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True)  # noqa: E712
+        )
+        if not result.scalars().first():
+            raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
+
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None

+ 21 - 0
backend/app/api/routes/settings.py

@@ -311,6 +311,13 @@ async def export_backup(
                     "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
                     "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
                     "on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
+                    "on_queue_job_added": getattr(p, "on_queue_job_added", False),
+                    "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False),
+                    "on_queue_job_started": getattr(p, "on_queue_job_started", False),
+                    "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True),
+                    "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True),
+                    "on_queue_job_failed": getattr(p, "on_queue_job_failed", True),
+                    "on_queue_completed": getattr(p, "on_queue_completed", False),
                     "quiet_hours_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
@@ -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 - 0
backend/app/core/database.py

@@ -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 - 0
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))

+ 3 - 0
backend/app/models/archive.py

@@ -35,6 +35,9 @@ class PrintArchive(Base):
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
 
+    # Printer model this file was sliced for (extracted from 3MF metadata)
+    sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+
     # Print result
     status: Mapped[str] = mapped_column(String(20), default="completed")
     started_at: Mapped[datetime | None] = mapped_column(DateTime)

+ 9 - 0
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"

+ 43 - 0
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",
+    },
 ]

+ 9 - 0
backend/app/models/print_queue.py

@@ -15,6 +15,15 @@ class PrintQueueItem(Base):
 
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
+    # Target printer model for model-based assignment (mutually exclusive with printer_id)
+    # When set, scheduler assigns to any idle printer of matching model
+    target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
+    # Used by scheduler to validate printer has compatible filaments loaded
+    required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
+    # Waiting reason - explains why a model-based job hasn't started yet
+    # Set by scheduler when no matching printer is available
+    waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     # Either archive_id OR library_file_id must be set (archive created at print start from library file)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
     library_file_id: Mapped[int | None] = mapped_column(

+ 2 - 0
backend/app/schemas/archive.py

@@ -63,6 +63,8 @@ class ArchiveResponse(BaseModel):
     bed_temperature: int | None
     nozzle_temperature: int | None
 
+    sliced_for_model: str | None = None  # Printer model this file was sliced for
+
     status: str
     started_at: datetime | None
     completed_at: datetime | None

+ 18 - 0
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

+ 55 - 0
backend/app/schemas/notification_template.py

@@ -45,6 +45,14 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
+    # Queue notifications
+    "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
+    "queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"],
+    "queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"],
+    "queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"],
+    "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
+    "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
+    "queue_completed": ["completed_count", "timestamp", "app_name"],
 }
 
 # Sample data for previewing templates
@@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },
+    # Queue notifications
+    "queue_job_added": {
+        "job_name": "Benchy.3mf",
+        "target": "Bambu X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_assigned": {
+        "job_name": "Benchy.3mf",
+        "printer": "Bambu X1C #1",
+        "target_model": "X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_started": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "estimated_time": "1h 23m",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_waiting": {
+        "job_name": "Benchy.3mf",
+        "target_model": "X1C",
+        "waiting_reason": "Printer1 (needs PLA)",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_skipped": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "reason": "Previous print failed",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_failed": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "reason": "Upload failed: connection timeout",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_completed": {
+        "completed_count": "5",
+        "timestamp": "2024-01-15 18:30",
+        "app_name": "Bambuddy",
+    },
 }
 
 

+ 6 - 0
backend/app/schemas/print_queue.py

@@ -17,6 +17,8 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     library_file_id: int | None = None
@@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel):
 
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
+    target_model: str | None = None  # Target printer model for model-based assignment
+    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
     position: int

+ 33 - 3
backend/app/services/archive.py

@@ -67,6 +67,19 @@ class ThreeMFParser:
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
 
+                # Extract printer_model_id from plate metadata
+                # Format: <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,

+ 150 - 0
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,

+ 301 - 39
backend/app/services/print_scheduler.py

@@ -4,7 +4,7 @@ import asyncio
 import logging
 from datetime import datetime
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
@@ -15,8 +15,10 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
+from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
+from backend.app.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)
+                if item.printer_id:
+                    # Specific printer assignment (existing behavior)
+                    if item.printer_id in busy_printers:
+                        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")
+                                busy_printers.add(item.printer_id)
+                                continue
                         else:
-                            logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
-                            processed_printers.add(item.printer_id)
+                            # No plug or auto_on disabled
+                            busy_printers.add(item.printer_id)
                             continue
-                    else:
-                        # No plug or auto_on disabled
-                        processed_printers.add(item.printer_id)
+
+                    # Check if printer is idle (busy with another print)
+                    if not printer_idle:
+                        busy_printers.add(item.printer_id)
                         continue
 
-                # Check if printer is idle (busy with another print)
-                if not printer_idle:
-                    processed_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
 
-                # 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()
+                    # 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()
-                        logger.info(f"Skipped queue item {item.id} - previous print failed")
-                        continue
 
-                # Start the print
-                await self._start_print(db, item)
-                processed_printers.add(item.printer_id)
+                        # 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
+
+            # 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 (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 - 0
backend/app/utils/printer_models.py

@@ -0,0 +1,86 @@
+"""Printer model normalization utilities.
+
+Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to
+normalized short names (e.g., "X1C") that match database storage.
+"""
+
+# Map from 3MF printer_model strings to normalized short names
+PRINTER_MODEL_MAP = {
+    "Bambu Lab X1 Carbon": "X1C",
+    "Bambu Lab X1": "X1",
+    "Bambu Lab X1E": "X1E",
+    "Bambu Lab P1S": "P1S",
+    "Bambu Lab P1P": "P1P",
+    "Bambu Lab P2S": "P2S",
+    "Bambu Lab A1": "A1",
+    "Bambu Lab A1 Mini": "A1 Mini",
+    "Bambu Lab A1 mini": "A1 Mini",
+    "Bambu Lab H2D": "H2D",
+    "Bambu Lab H2D Pro": "H2D Pro",
+}
+
+# Map from printer_model_id (internal codes in slice_info.config) to short names
+# These are the codes Bambu Studio uses internally
+PRINTER_MODEL_ID_MAP = {
+    # X1 series
+    "C11": "X1C",
+    "C12": "X1",
+    "C13": "X1E",
+    # P1 series
+    "P1P": "P1P",
+    "P1S": "P1S",
+    # P2 series
+    "P2S": "P2S",
+    # A1 series
+    "A11": "A1",
+    "A12": "A1 Mini",
+    "N1": "A1",
+    "N2S": "A1 Mini",
+    "A04": "A1 Mini",
+    # H2D series (Office/H series)
+    "O1D": "H2D",
+    "O2D": "H2D Pro",
+}
+
+
+def normalize_printer_model_id(model_id: str | None) -> str | None:
+    """Convert printer_model_id (internal code) to normalized short name.
+
+    Args:
+        model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D")
+
+    Returns:
+        Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown.
+    """
+    if not model_id:
+        return None
+
+    # Check known mappings
+    if model_id in PRINTER_MODEL_ID_MAP:
+        return PRINTER_MODEL_ID_MAP[model_id]
+
+    # Return original if unknown (might already be a short name)
+    return model_id
+
+
+def normalize_printer_model(raw_model: str | None) -> str | None:
+    """Convert 3MF printer_model to normalized short name.
+
+    Args:
+        raw_model: The printer_model string from 3MF metadata
+            (e.g., "Bambu Lab X1 Carbon")
+
+    Returns:
+        Normalized short name (e.g., "X1C") or None if input is empty.
+        Unknown models have "Bambu Lab " prefix stripped.
+    """
+    if not raw_model:
+        return None
+
+    # Check known mappings first
+    if raw_model in PRINTER_MODEL_MAP:
+        return PRINTER_MODEL_MAP[raw_model]
+
+    # Strip "Bambu Lab " prefix for unknown models
+    stripped = raw_model.replace("Bambu Lab ", "").strip()
+    return stripped or None

+ 88 - 0
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);
+    });
+  });
+});

+ 0 - 45
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(<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 - 0
frontend/src/api/client.ts

@@ -300,6 +300,7 @@ export interface Archive {
   nozzle_diameter: number | null;
   bed_temperature: number | null;
   nozzle_temperature: number | null;
+  sliced_for_model: string | null;  // Printer model this file was sliced for
   status: string;
   started_at: string | null;
   completed_at: string | null;
@@ -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;

+ 82 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -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">

+ 133 - 8
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -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
frontend/src/components/PrintModal/index.tsx

@@ -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} />
             )}
 

+ 17 - 0
frontend/src/components/PrintModal/types.ts

@@ -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
frontend/src/components/UploadModal.tsx

@@ -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
frontend/src/pages/ArchivesPage.tsx

@@ -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
frontend/src/pages/QueuePage.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-8OJBC-HQ.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BvTstZiS.js


Some files were not shown because too many files changed in this diff