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 edit: printer assignment, print options, queue options
   - Bulk cancel selected items
   - Bulk cancel selected items
   - Tri-state toggles: unchanged / on / off for each setting
   - 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
 ### Fixes
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
 - **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
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - 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)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
@@ -116,6 +118,7 @@
 - Print finish photo URL in notifications
 - Print finish photo URL in notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
 - Build plate detection alerts
+- Queue events (waiting, skipped, failed)
 
 
 ### 🔧 Integrations
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
 - [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
 ## 📄 License
 
 
 MIT License — see [LICENSE](LICENSE) for details.
 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">
 <p align="center">
   Made with ❤️ for the 3D printing community
   Made with ❤️ for the 3D printing community
   <br><br>
   <br><br>

+ 1 - 0
backend/app/api/routes/archives.py

@@ -78,6 +78,7 @@ def archive_to_response(
         "nozzle_diameter": archive.nozzle_diameter,
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
+        "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
         "status": archive.status,
         "started_at": archive.started_at,
         "started_at": archive.started_at,
         "completed_at": archive.completed_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",
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "maintenance_due": "Maintenance Due",
     "test": "Test Notification",
     "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,
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
         "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
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
         "quiet_hours_start": provider.quiet_hours_start,

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

@@ -2,13 +2,17 @@
 
 
 import json
 import json
 import logging
 import logging
+import xml.etree.ElementTree as ET
+import zipfile
 from datetime import datetime
 from datetime import datetime
+from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
@@ -22,12 +26,75 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     PrintQueueItemUpdate,
     PrintQueueReorder,
     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__)
 logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/queue", tags=["queue"])
 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:
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -38,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             ams_mapping_parsed = None
             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
     # Create response with parsed ams_mapping
     item_dict = {
     item_dict = {
         "id": item.id,
         "id": item.id,
         "printer_id": item.printer_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,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
         "library_file_id": item.library_file_id,
         "position": item.position,
         "position": item.position,
@@ -120,29 +198,71 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Add an item to the print queue."""
     """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
     # Validate that either archive_id or library_file_id is provided
     if not data.archive_id and not data.library_file_id:
     if not data.archive_id and not data.library_file_id:
         raise HTTPException(400, "Either archive_id or library_file_id must be provided")
         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)
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             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:
     if data.archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == 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")
             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:
     if data.library_file_id:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == 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")
             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:
     if data.printer_id is not None:
         result = await db.execute(
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
             select(func.max(PrintQueueItem.position))
@@ -150,7 +270,7 @@ async def add_to_queue(
             .where(PrintQueueItem.status == "pending")
             .where(PrintQueueItem.status == "pending")
         )
         )
     else:
     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(
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
             select(func.max(PrintQueueItem.position))
             .where(PrintQueueItem.printer_id.is_(None))
             .where(PrintQueueItem.printer_id.is_(None))
@@ -160,6 +280,8 @@ async def add_to_queue(
 
 
     item = PrintQueueItem(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         printer_id=data.printer_id,
+        target_model=target_model_norm,
+        required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
         scheduled_time=data.scheduled_time,
@@ -185,7 +307,8 @@ async def add_to_queue(
     await db.refresh(item, ["archive", "printer", "library_file"])
     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}"
     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
     # MQTT relay - publish queue job added
     try:
     try:
@@ -200,6 +323,29 @@ async def add_to_queue(
     except Exception:
     except Exception:
         pass  # Don't fail queue add if MQTT fails
         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)
     return _enrich_response(item)
 
 
 
 
@@ -287,12 +433,34 @@ async def update_queue_item(
 
 
     update_data = data.model_dump(exclude_unset=True)
     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)
     # Validate new printer_id if being changed (and not None)
     if "printer_id" in update_data and update_data["printer_id"] is 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"]))
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             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
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
         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_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
                     "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_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_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_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
                     "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_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_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_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_enabled = provider_data.get("quiet_hours_enabled", False)
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
                     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_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_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_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_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
                     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:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """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:
                 except Exception:
                     pass  # Don't fail if MQTT fails
                     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)
                 # Handle auto_off_after - power off printer if requested (after cooldown)
                 if queue_item.auto_off_after:
                 if queue_item.auto_off_after:
                     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                     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)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_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
     # Print result
     status: Mapped[str] = mapped_column(String(20), default="completed")
     status: Mapped[str] = mapped_column(String(20), default="completed")
     started_at: Mapped[datetime | None] = mapped_column(DateTime)
     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
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     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 (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"
     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",
         "title_template": "Bambuddy Test",
         "body_template": "This is a test notification. If you see this, notifications are working!",
         "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
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
     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)
     # 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)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
     library_file_id: Mapped[int | None] = mapped_column(
     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
     bed_temperature: int | None
     nozzle_temperature: int | None
     nozzle_temperature: int | None
 
 
+    sliced_for_model: str | None = None  # Printer model this file was sliced for
+
     status: str
     status: str
     started_at: datetime | None
     started_at: datetime | None
     completed_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
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     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
     quiet_hours_enabled: bool = Field(default=False, description="Enable 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")
     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
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     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
     quiet_hours_enabled: bool | None = None
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | 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_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     "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
 # Sample data for previewing templates
@@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
         "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):
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     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
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     archive_id: int | None = None
     library_file_id: int | None = None
     library_file_id: int | None = None
@@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel):
 
 
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     printer_id: int | None = None
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     position: int | None = None
     position: int | None = None
     scheduled_time: datetime | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
     require_previous_success: bool | None = None
@@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     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)
     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
     library_file_id: int | None  # For queue items from library files
     position: int
     position: int

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

@@ -67,6 +67,19 @@ class ThreeMFParser:
                 content = zf.read("Metadata/slice_info.config").decode()
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
                 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)
                 # Find the plate element (single-plate exports only have one plate)
                 plate = root.find(".//plate")
                 plate = root.find(".//plate")
 
 
@@ -156,7 +169,7 @@ class ThreeMFParser:
             pass
             pass
 
 
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
     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
         import re
 
 
         try:
         try:
@@ -165,15 +178,25 @@ class ThreeMFParser:
             if not gcode_files:
             if not gcode_files:
                 return
                 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]
             gcode_path = gcode_files[0]
             with zf.open(gcode_path) as f:
             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
             # Look for "; total layer number: XX" pattern
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             if match:
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
                 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:
         except Exception:
             pass
             pass
 
 
@@ -256,6 +279,12 @@ class ThreeMFParser:
                     elif isinstance(val, (int, float, str)):
                     elif isinstance(val, (int, float, str)):
                         self.metadata["nozzle_temperature"] = int(float(val))
                         self.metadata["nozzle_temperature"] = int(float(val))
                     break
                     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:
         except Exception:
             pass
             pass
 
 
@@ -877,6 +906,7 @@ class ArchiveService:
             nozzle_diameter=metadata.get("nozzle_diameter"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
             bed_temperature=metadata.get("bed_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
+            sliced_for_model=metadata.get("sliced_for_model"),
             makerworld_url=metadata.get("makerworld_url"),
             makerworld_url=metadata.get("makerworld_url"),
             designer=metadata.get("designer"),
             designer=metadata.get("designer"),
             status=status,
             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."""
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
         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(
     async def _queue_for_digest(
         self,
         self,
         provider: NotificationProvider,
         provider: NotificationProvider,

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

@@ -4,7 +4,7 @@ import asyncio
 import logging
 import logging
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 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.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 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.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.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.printer_models import normalize_printer_model
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -62,13 +64,10 @@ class PrintScheduler:
             if not items:
             if not items:
                 return
                 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:
             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)
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
                     continue
@@ -77,46 +76,254 @@ class PrintScheduler:
                 if item.manual_start:
                 if item.manual_start:
                     continue
                     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:
                         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
                             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
                         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()
                         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:
     def _is_printer_idle(self, printer_id: int) -> bool:
         """Check if a printer is connected and idle."""
         """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}")
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
             await tasmota_service.turn_off(plug)
             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):
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
         """Upload file and start print for a queue item.
         """Upload file and start print for a queue item.
 
 
@@ -371,6 +597,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
             logger.error(f"Queue item {item.id}: FTP upload failed")
             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)
             await self._power_off_if_needed(db, item)
             return
             return
 
 
@@ -411,6 +647,22 @@ class PrintScheduler:
             await db.commit()
             await db.commit()
             logger.info(f"Queue item {item.id}: Print started - {filename}")
             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
             # MQTT relay - publish queue job started
             try:
             try:
                 from backend.app.services.mqtt_relay import mqtt_relay
                 from backend.app.services.mqtt_relay import mqtt_relay
@@ -430,6 +682,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
             logger.error(f"Queue item {item.id}: Failed to start print")
             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)
             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_temperature_high: false,
   on_ams_ht_humidity_high: false,
   on_ams_ht_humidity_high: false,
   on_ams_ht_temperature_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_enabled: false,
   quiet_hours_start: null,
   quiet_hours_start: null,
   quiet_hours_end: 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 { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 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', () => {
 describe('UploadModal', () => {
   const mockOnClose = vi.fn();
   const mockOnClose = vi.fn();
 
 
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
     server.use(
     server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
       http.post('/api/v1/archives/upload-bulk', async () => {
       http.post('/api/v1/archives/upload-bulk', async () => {
         return HttpResponse.json({
         return HttpResponse.json({
           uploaded: 1,
           uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
       expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
       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', () => {
     it('renders Cancel button', () => {
       render(<UploadModal onClose={mockOnClose} />);
       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', () => {
   describe('file handling with initialFiles', () => {
     it('shows initial files when provided', () => {
     it('shows initial files when provided', () => {
       const initialFiles = [
       const initialFiles = [

+ 30 - 0
frontend/src/api/client.ts

@@ -300,6 +300,7 @@ export interface Archive {
   nozzle_diameter: number | null;
   nozzle_diameter: number | null;
   bed_temperature: number | null;
   bed_temperature: number | null;
   nozzle_temperature: number | null;
   nozzle_temperature: number | null;
+  sliced_for_model: string | null;  // Printer model this file was sliced for
   status: string;
   status: string;
   started_at: string | null;
   started_at: string | null;
   completed_at: string | null;
   completed_at: string | null;
@@ -1001,6 +1002,9 @@ export interface DiscoveredTasmotaDevice {
 export interface PrintQueueItem {
 export interface PrintQueueItem {
   id: number;
   id: number;
   printer_id: number | null;  // null = unassigned
   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)
   // Either archive_id OR library_file_id must be set (archive created at print start)
   archive_id: number | null;
   archive_id: number | null;
   library_file_id: number | null;
   library_file_id: number | null;
@@ -1033,6 +1037,7 @@ export interface PrintQueueItem {
 
 
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   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
   // Either archive_id OR library_file_id must be provided
   archive_id?: number | null;
   archive_id?: number | null;
   library_file_id?: number | null;
   library_file_id?: number | null;
@@ -1053,6 +1058,7 @@ export interface PrintQueueItemCreate {
 
 
 export interface PrintQueueItemUpdate {
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   printer_id?: number | null;  // null = unassign
+  target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   position?: number;
   position?: number;
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
@@ -1212,6 +1218,14 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty: boolean;
   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
   quiet_hours_enabled: boolean;
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
   quiet_hours_start: string | null;
@@ -1254,6 +1268,14 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty?: boolean;
   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
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;
@@ -1289,6 +1311,14 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   // Build plate detection
   on_plate_not_empty?: boolean;
   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
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;

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

@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 </div>
                 </div>
               </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 */}
               {/* Quiet Hours */}
               <div className="space-y-2">
               <div className="space-y-2">
                 <div className="flex items-center justify-between">
                 <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 { useQueryClient } from '@tanstack/react-query';
 import {
 import {
   Printer as PrinterIcon,
   Printer as PrinterIcon,
@@ -9,6 +9,7 @@ import {
   Circle,
   Circle,
   RefreshCw,
   RefreshCw,
   Wand2,
   Wand2,
+  Users,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
@@ -16,7 +17,7 @@ import {
   normalizeColorForCompare,
   normalizeColorForCompare,
   colorsAreSimilar,
   colorsAreSimilar,
 } from '../../utils/amsHelpers';
 } from '../../utils/amsHelpers';
-import type { PrinterSelectorProps } from './types';
+import type { PrinterSelectorProps, AssignmentMode } from './types';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
 import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
 
 
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   onAutoConfigurePrinter?: (printerId: number) => void;
   onAutoConfigurePrinter?: (printerId: number) => void;
   /** Callback to update printer config */
   /** Callback to update printer config */
   onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
   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,
   filamentReqs,
   onAutoConfigurePrinter,
   onAutoConfigurePrinter,
   onUpdatePrinterConfig,
   onUpdatePrinterConfig,
+  assignmentMode = 'printer',
+  onAssignmentModeChange,
+  targetModel,
+  onTargetModelChange,
+  slicedForModel,
 }: PrinterSelectorWithMappingProps) {
 }: PrinterSelectorWithMappingProps) {
+  // State for showing all printers vs only matching model
+  const [showAllPrinters, setShowAllPrinters] = useState(false);
+
   // Filter printers based on showInactive flag
   // 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 &&
   const showMappingOptions = allowMultiple &&
     selectedPrinterIds.length > 1 &&
     selectedPrinterIds.length > 1 &&
@@ -285,8 +329,56 @@ export function PrinterSelector({
 
 
   return (
   return (
     <div className="space-y-2 mb-6">
     <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">
         <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
           <span>
           <span>
             {selectedCount === 0
             {selectedCount === 0
@@ -316,7 +408,8 @@ export function PrinterSelector({
         </div>
         </div>
       )}
       )}
 
 
-      {displayPrinters.map((printer) => {
+      {/* Printer list (only in printer mode) */}
+      {assignmentMode === 'printer' && displayPrinters.map((printer) => {
         const selected = isSelected(printer.id);
         const selected = isSelected(printer.id);
         const mappingResult = getPrinterMappingResult(printer.id);
         const mappingResult = getPrinterMappingResult(printer.id);
         const hasOverride = mappingResult && !mappingResult.config.useDefault;
         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">
         <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
           <AlertCircle className="w-3 h-3" />
           <AlertCircle className="w-3 h-3" />
           Select at least one printer
           Select at least one printer
         </p>
         </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>
     </div>
   );
   );
 }
 }

+ 144 - 42
frontend/src/components/PrintModal/index.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 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 { api } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import { Card, CardContent } from '../Card';
 import { Card, CardContent } from '../Card';
@@ -19,6 +19,7 @@ import type {
   PrintOptions,
   PrintOptions,
   ScheduleOptions,
   ScheduleOptions,
   ScheduleType,
   ScheduleType,
+  AssignmentMode,
 } from './types';
 } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } 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)
   // Per-printer override configs (for multi-printer selection)
   const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
   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)
   // 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 [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -145,6 +163,16 @@ export function PrintModal({
     queryFn: api.getPrinters,
     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
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
     queryKey: ['archive-plates', archiveId],
@@ -304,14 +332,20 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent) => {
   const handleSubmit = async (e?: React.FormEvent) => {
     e?.preventDefault();
     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');
       showToast('Please select at least one printer', 'error');
       return;
       return;
     }
     }
+    if (assignmentMode === 'model' && !targetModel) {
+      showToast('Please select a target printer model', 'error');
+      return;
+    }
 
 
     setIsSubmitting(true);
     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[] } = {
     const results: { success: number; failed: number; errors: string[] } = {
       success: 0,
       success: 0,
@@ -332,15 +366,16 @@ export function PrintModal({
     };
     };
 
 
     // Common queue data for add-to-queue and edit modes
     // 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
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       auto_off_after: scheduleOptions.autoOffAfter,
       manual_start: scheduleOptions.scheduleType === 'manual',
       manual_start: scheduleOptions.scheduleType === 'manual',
-      ams_mapping: getMappingForPrinter(printerId),
+      ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
       plate_id: selectedPlate,
       plate_id: selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
         ? new Date(scheduleOptions.scheduledTime).toISOString()
         ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -348,35 +383,24 @@ export function PrintModal({
       ...printOptions,
       ...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 {
       try {
         if (mode === 'reprint') {
         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 = {
           const updateData: PrintQueueItemUpdate = {
-            printer_id: printerId,
+            printer_id: null,
+            target_model: targetModel,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
             manual_start: scheduleOptions.scheduleType === 'manual',
-            ams_mapping: printerMapping,
+            ams_mapping: undefined,
             plate_id: selectedPlate,
             plate_id: selectedPlate,
             scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
             scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
               ? new Date(scheduleOptions.scheduledTime).toISOString()
               ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -385,14 +409,63 @@ export function PrintModal({
           };
           };
           await updateQueueMutation.mutateAsync(updateData);
           await updateQueueMutation.mutateAsync(updateData);
         } else {
         } 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++;
         results.success++;
       } catch (error) {
       } catch (error) {
         results.failed++;
         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
     // Show result toast
     if (results.failed === 0) {
     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 {
       } 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'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       onSuccess?.();
       onSuccess?.();
@@ -422,14 +499,18 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
   const canSubmit = useMemo(() => {
     if (isPending) return false;
     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)
     // For multi-plate archive files, need a selected plate (library files skip this)
     if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
     if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
 
 
     return true;
     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
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {
@@ -541,8 +622,29 @@ export function PrintModal({
               filamentReqs={effectiveFilamentReqs}
               filamentReqs={effectiveFilamentReqs}
               onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
               onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
               onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
               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 */}
             {/* Warning when archive data couldn't be loaded */}
             {archiveDataMissing && (
             {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">
               <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 */}
             {/* Print options */}
-            {(mode === 'reprint' || effectivePrinterCount > 0) && (
+            {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
             )}
             )}
 
 

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

@@ -104,6 +104,13 @@ export interface PlatesResponse {
   plates: PlateInfo[];
   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.
  * Props for the PrinterSelector component.
  */
  */
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
   allowMultiple?: boolean;
   allowMultiple?: boolean;
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   /** Show inactive printers (for edit mode where original assignment may be inactive) */
   showInactive?: boolean;
   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 { 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 { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { BulkUploadResult } 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 })) || []
     initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
   );
   );
   const [isDragging, setIsDragging] = useState(false);
   const [isDragging, setIsDragging] = useState(false);
-  const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
   const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
   const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
 
 
   // Close on Escape key
   // Close on Escape key
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
   }, [onClose]);
 
 
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: api.getPrinters,
-  });
-
   const uploadMutation = useMutation({
   const uploadMutation = useMutation({
     mutationFn: (filesToUpload: File[]) =>
     mutationFn: (filesToUpload: File[]) =>
-      api.uploadArchivesBulk(filesToUpload, selectedPrinter),
+      api.uploadArchivesBulk(filesToUpload),
     onSuccess: (result) => {
     onSuccess: (result) => {
       setUploadResult(result);
       setUploadResult(result);
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             </div>
             </div>
           </div>
           </div>
 
 
-          {/* Optional Printer Selection */}
+          {/* Info about printer model extraction */}
           <div className="px-4 pb-4">
           <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>
           </div>
 
 
           {/* File List */}
           {/* File List */}

+ 21 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -797,6 +797,12 @@ function ArchiveCard({
               {archive.object_count} object{archive.object_count > 1 ? 's' : ''}
               {archive.object_count} object{archive.object_count > 1 ? 's' : ''}
             </div>
             </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 && (
           {archive.filament_type && (
             <div className="flex items-center gap-1.5 col-span-2">
             <div className="flex items-center gap-1.5 col-span-2">
               <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
               <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
@@ -1612,9 +1618,20 @@ function ArchiveListRow({
               </Link>
               </Link>
             )}
             )}
           </div>
           </div>
-          {archive.filament_type && (
+          {(archive.filament_type || archive.sliced_for_model) && (
             <div className="flex items-center gap-1.5 mt-0.5">
             <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 && (
               {archive.filament_color && (
                 <div className="flex items-center gap-0.5 flex-wrap">
                 <div className="flex items-center gap-0.5 flex-wrap">
                   {archive.filament_color.split(',').map((color, i) => (
                   {archive.filament_color.split(',').map((color, i) => (
@@ -2755,7 +2772,7 @@ export function ArchivesPage() {
             <ArchiveCard
             <ArchiveCard
               key={archive.id}
               key={archive.id}
               archive={archive}
               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)}
               isSelected={selectedIds.has(archive.id)}
               onSelect={toggleSelect}
               onSelect={toggleSelect}
               selectionMode={selectionMode}
               selectionMode={selectionMode}
@@ -2782,7 +2799,7 @@ export function ArchivesPage() {
               <ArchiveListRow
               <ArchiveListRow
                 key={archive.id}
                 key={archive.id}
                 archive={archive}
                 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)}
                 isSelected={selectedIds.has(archive.id)}
                 onSelect={toggleSelect}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}
                 selectionMode={selectionMode}

+ 26 - 4
frontend/src/pages/QueuePage.tsx

@@ -77,7 +77,17 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
   return formatDateTime(dateString, 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 = {
   const config = {
     pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
     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' },
     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>
 
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
           <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" />
               <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>
             </span>
             {item.print_time_seconds && (
             {item.print_time_seconds && (
               <span className="flex items-center gap-1.5">
               <span className="flex items-center gap-1.5">
@@ -448,6 +462,14 @@ function SortableQueueItem({
             </div>
             </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 */}
           {/* Error message */}
           {item.error_message && (
           {item.error_message && (
             <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
             <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
@@ -458,7 +480,7 @@ function SortableQueueItem({
         </div>
         </div>
 
 
         {/* Status badge */}
         {/* Status badge */}
-        <StatusBadge status={item.status} />
+        <StatusBadge status={item.status} waitingReason={item.waiting_reason} />
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex items-center gap-1">
         <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