فهرست منبع

Merge pull request #88 from maziggy/0.1.6b8

0.1.6b8

  New Features

  MQTT Publishing
  Publish BamBuddy events to external MQTT brokers for integration with Home Assistant, Node-RED, and other automation platforms:
  - New "Network" tab in Settings for MQTT configuration
  - Configure broker, port, credentials, TLS, and topic prefix
  - Real-time connection status indicator
  - Topics: printer status, print lifecycle, AMS changes, queue events, maintenance alerts, smart plug states, archive events

  Virtual Printer Queue Mode
  New mode that archives files and adds them directly to the print queue:
  - Three modes: Archive (immediate), Review (pending list), Queue (print queue)
  - Queue mode creates unassigned items that can be assigned to a printer later

  Unassigned Queue Items
  Print queue now supports items without an assigned printer:
  - "Unassigned" filter option on Queue page
  - Unassigned items highlighted in orange
  - Assign printer via edit modal

  Sidebar Badge Indicators
  Visual indicators on sidebar icons:
  - Queue icon: yellow badge with pending item count
  - Archive icon: blue badge with pending uploads count
  - Auto-updates every 5 seconds and on window focus

  Project Parts Tracking
  Track individual parts/objects separately from print plates:
  - "Target Parts" field alongside "Target Plates"
  - Separate progress bars for plates vs parts
  - Parts count auto-detected from 3MF files

  Bug Fixes

  - Chamber temp on A1/P1S - Fixed regression where chamber temperature appeared on printers without sensors in multi-printer setups
  - Queue prints on A1 - Fixed "MicroSD Card read/write exception error" when starting prints from queue
  - Spoolman sync - Fixed Bambu Lab spool detection and AMS tray data persistence
  - FTP downloads - Fixed downloads failing for .3mf files without .gcode extension
  - Project statistics - Fixed inconsistent display between project list and detail views
  - Chamber light state - Fixed WebSocket broadcasts not including light state changes
  - Backup/restore - Improved handling of nullable fields and AMS mapping data

  ---
MartinNYHC 7 ماه پیش
والد
کامیت
4c632de0f6
53فایلهای تغییر یافته به همراه3091 افزوده شده و 427 حذف شده
  1. 86 0
      CHANGELOG.md
  2. 2 2
      README.md
  3. 1 1
      SECURITY.md
  4. 12 0
      backend/app/api/routes/maintenance.py
  5. 43 16
      backend/app/api/routes/print_queue.py
  6. 53 25
      backend/app/api/routes/printers.py
  7. 60 24
      backend/app/api/routes/projects.py
  8. 207 13
      backend/app/api/routes/settings.py
  9. 22 0
      backend/app/api/routes/smart_plugs.py
  10. 18 6
      backend/app/api/routes/spoolman.py
  11. 2 2
      backend/app/api/routes/websocket.py
  12. 1 1
      backend/app/core/config.py
  13. 50 0
      backend/app/core/database.py
  14. 124 3
      backend/app/main.py
  15. 1 1
      backend/app/models/print_queue.py
  16. 6 1
      backend/app/models/project.py
  17. 2 2
      backend/app/schemas/print_queue.py
  18. 11 3
      backend/app/schemas/project.py
  19. 20 1
      backend/app/schemas/settings.py
  20. 9 0
      backend/app/services/archive.py
  21. 27 1
      backend/app/services/bambu_mqtt.py
  22. 1 1
      backend/app/services/camera.py
  23. 650 0
      backend/app/services/mqtt_relay.py
  24. 37 3
      backend/app/services/print_scheduler.py
  25. 70 3
      backend/app/services/printer_manager.py
  26. 75 33
      backend/app/services/spoolman.py
  27. 75 2
      backend/app/services/virtual_printer/manager.py
  28. 8 1
      backend/tests/conftest.py
  29. 1 0
      backend/tests/integration/test_printers_api.py
  30. 149 0
      backend/tests/integration/test_projects_api.py
  31. 71 0
      backend/tests/integration/test_settings_api.py
  32. 21 1
      backend/tests/integration/test_virtual_printer_api.py
  33. 78 0
      backend/tests/unit/services/test_archive_service.py
  34. 95 0
      backend/tests/unit/services/test_printer_manager.py
  35. 2 2
      frontend/public/sw.js
  36. 61 19
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  37. 48 14
      frontend/src/api/client.ts
  38. 8 0
      frontend/src/components/BackupModal.tsx
  39. 24 14
      frontend/src/components/EditQueueItemModal.tsx
  40. 36 2
      frontend/src/components/Layout.tsx
  41. 55 7
      frontend/src/components/RestoreModal.tsx
  42. 33 14
      frontend/src/components/VirtualPrinterSettings.tsx
  43. 32 3
      frontend/src/pages/PrintersPage.tsx
  44. 89 47
      frontend/src/pages/ProjectDetailPage.tsx
  45. 136 51
      frontend/src/pages/ProjectsPage.tsx
  46. 10 4
      frontend/src/pages/QueuePage.tsx
  47. 310 100
      frontend/src/pages/SettingsPage.tsx
  48. 155 0
      scripts/update_archive_quantities.py
  49. 0 0
      static/assets/index-DSlUhPr3.css
  50. 0 0
      static/assets/index-Dzh7xD3q.css
  51. 0 0
      static/assets/index-Yr6VeTyb.js
  52. 2 2
      static/index.html
  53. 2 2
      static/sw.js

+ 86 - 0
CHANGELOG.md

@@ -2,6 +2,92 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b15] - 2026-01-17
+
+### Added
+- **Virtual Printer "Queue" mode** - New mode that archives files and adds them to the print queue:
+  - Three modes now available: Archive (immediate), Review (pending list), Queue (print queue)
+  - Queue mode creates unassigned queue items that can be assigned to a printer later
+  - Renamed old "Queue for Review" to "Review" to avoid confusion with print queue
+- **Unassigned queue items** - Print queue now supports items without an assigned printer:
+  - Queue items can be created without a printer (printer_id nullable)
+  - Queue page shows "Unassigned" filter option and highlights unassigned items in orange
+  - Edit modal allows assigning a printer to unassigned items
+  - Useful for virtual printer uploads where target printer is decided later
+- **Sidebar badge indicators** - Visual indicators on sidebar icons for pending items:
+  - Queue icon shows yellow badge with count of pending queue items
+  - Archive icon shows blue badge with count of pending uploads (virtual printer review items)
+  - Badges auto-update every 5 seconds and on window focus
+
+### Changed
+- Virtual printer mode labels: "Immediate" → "Archive", "Queue for Review" → "Review"
+- Queue page printer filter now includes "Unassigned" option
+
+## [0.1.6b14] - 2026-01-17
+
+### Fixed
+- **Chamber temp showing on printers without sensor in multi-printer setups** - Fixed regression where A1/P1S chamber temperature would appear after adding a second printer:
+  - Root cause: REST API `/printers/{id}/status` endpoint wasn't filtering chamber temp by model
+  - WebSocket broadcasts filtered correctly, but initial REST fetch didn't
+  - Now both REST and WebSocket consistently filter chamber temp for P1P/P1S/A1/A1Mini
+  - Fixes [#82](https://github.com/maziggy/bambuddy/issues/82) (multi-printer regression)
+
+## [0.1.6b13] - 2026-01-16
+
+### Fixed
+- **Queue prints failing on A1 printers** - Fixed "MicroSD Card read/write exception error" when starting prints from queue:
+  - Root cause: Queue uploaded files to `/cache/` but print command referenced root `/`
+  - Queue now uploads to root directory like archive reprint does
+  - Added filename cleaning (handles `.gcode.3mf` double extensions)
+  - Added pre-delete of existing files to avoid FTP 553 errors
+  - Fixes [#86](https://github.com/maziggy/bambuddy/issues/86)
+
+## [0.1.6b12] - 2026-01-16
+
+### Added
+- **Project parts tracking** - Track individual parts/objects separately from print plates:
+  - New "Target Parts" field in project create/edit modal alongside "Target Plates"
+  - Separate progress bars for plates (print jobs) vs parts (objects printed)
+  - Parts count auto-detected from 3MF files when archiving prints
+  - Project cards show both plates and parts counts in footer stats
+  - Example: Voron build with 25 plates producing 150 parts total
+  - Closes [#85](https://github.com/maziggy/bambuddy/issues/85)
+- **Archive quantity auto-detection** - Automatically set parts count when archiving:
+  - Extracts printable object count from 3MF `slice_info.config`
+  - Respects skipped objects (not counted)
+  - Falls back to 1 if extraction fails
+- **Migration script for existing archives** - Update quantities on existing archives:
+  - Run `python scripts/update_archive_quantities.py` to update existing archives
+  - Use `--dry-run` flag to preview changes without applying them
+  - Parses 3MF files to extract correct object counts
+
+### Changed
+- Project stats now correctly distinguish between plates (archive count) and parts (sum of quantities)
+- Project list `completed_count` now represents parts printed, not print jobs
+- Progress calculations: plates use `archive_count/target_count`, parts use `completed_count/target_parts_count`
+
+## [0.1.6b11] - 2026-01-13
+
+### Added
+- **MQTT Publishing** - Publish BamBuddy events to external MQTT brokers for integration with Home Assistant, Node-RED, and other automation platforms:
+  - New "Network" tab in Settings (between Filament and API Keys)
+  - Configure broker hostname, port, username/password, TLS, and topic prefix
+  - Auto-populate port when toggling TLS (1883 ↔ 8883)
+  - Real-time connection status indicator in tab and settings card
+  - Published topics include:
+    - `bambuddy/status` - Online/offline status
+    - `bambuddy/printers/{serial}/status` - Real-time printer state (throttled to 1/sec)
+    - `bambuddy/printers/{serial}/print/started|completed|failed` - Print lifecycle events
+    - `bambuddy/printers/{serial}/ams/changed` - AMS filament changes
+    - `bambuddy/queue/job_added|job_started|job_completed` - Print queue events
+    - `bambuddy/maintenance/alert|reset` - Maintenance notifications
+    - `bambuddy/smart_plugs/on|off|energy` - Smart plug state changes
+    - `bambuddy/archive/created|updated` - Archive events
+  - Supports TLS/SSL encryption with self-signed certificates
+  - Auto-reconnect when settings change
+  - Settings included in backup/restore
+- **FTP Retry moved to Network tab** - FTP retry settings relocated from General to Network tab for better organization
+
 ## [0.1.6b10] - 2026-01-11
 
 ### Added

+ 2 - 2
README.md

@@ -78,8 +78,8 @@
 
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
-- Track progress with target counts
-- Quantity tracking for batch prints
+- Track plates (print jobs) and parts separately
+- Auto-detect parts count from 3MF files
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
 

+ 1 - 1
SECURITY.md

@@ -56,7 +56,7 @@ Bambuddy communicates with your printers over your local network using:
 2. **Use reverse proxy**: If exposing to the internet, use a reverse proxy with HTTPS
 3. **Keep updated**: Always run the latest version for security patches
 4. **Secure API keys**: Treat API keys like passwords; don't share them publicly
-5. **LAN Mode**: Use your printer's LAN Mode access code; don't share it
+5. **Developer Mode**: Use your printer's Developer Mode access code; don't share it
 
 ### Known Security Features
 

+ 12 - 0
backend/app/api/routes/maintenance.py

@@ -496,6 +496,18 @@ async def perform_maintenance(
 
     await db.commit()
 
+    # MQTT relay - publish maintenance reset
+    try:
+        from backend.app.services.mqtt_relay import mqtt_relay
+
+        await mqtt_relay.on_maintenance_reset(
+            printer_id=item.printer_id,
+            printer_name=printer.name,
+            maintenance_type=item.maintenance_type.name,
+        )
+    except Exception:
+        pass  # Don't fail if MQTT fails
+
     # Calculate status
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
     interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"

+ 43 - 16
backend/app/api/routes/print_queue.py

@@ -64,7 +64,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
 
 @router.get("/", response_model=list[PrintQueueItemResponse])
 async def list_queue(
-    printer_id: int | None = Query(None, description="Filter by printer"),
+    printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
     status: str | None = Query(None, description="Filter by status"),
     db: AsyncSession = Depends(get_db),
 ):
@@ -72,11 +72,15 @@ async def list_queue(
     query = (
         select(PrintQueueItem)
         .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
-        .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
+        .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
 
     if printer_id is not None:
-        query = query.where(PrintQueueItem.printer_id == printer_id)
+        if printer_id == -1:
+            # Special value: filter for unassigned items
+            query = query.where(PrintQueueItem.printer_id.is_(None))
+        else:
+            query = query.where(PrintQueueItem.printer_id == printer_id)
     if status:
         query = query.where(PrintQueueItem.status == status)
 
@@ -91,22 +95,31 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
 ):
     """Add an item to the print queue."""
-    # Validate printer exists
-    result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
-    if not result.scalar_one_or_none():
-        raise HTTPException(400, "Printer not found")
+    # Validate printer exists (if assigned)
+    if data.printer_id is not None:
+        result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
 
     # Validate archive exists
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
     if not result.scalar_one_or_none():
         raise HTTPException(400, "Archive not found")
 
-    # Get next position for this printer
-    result = await db.execute(
-        select(func.max(PrintQueueItem.position))
-        .where(PrintQueueItem.printer_id == data.printer_id)
-        .where(PrintQueueItem.status == "pending")
-    )
+    # Get next position for this printer (or for unassigned items)
+    if data.printer_id is not None:
+        result = await db.execute(
+            select(func.max(PrintQueueItem.position))
+            .where(PrintQueueItem.printer_id == data.printer_id)
+            .where(PrintQueueItem.status == "pending")
+        )
+    else:
+        # For unassigned items, get max position across all unassigned
+        result = await db.execute(
+            select(func.max(PrintQueueItem.position))
+            .where(PrintQueueItem.printer_id.is_(None))
+            .where(PrintQueueItem.status == "pending")
+        )
     max_pos = result.scalar() or 0
 
     item = PrintQueueItem(
@@ -127,7 +140,21 @@ async def add_to_queue(
     # Load relationships for response
     await db.refresh(item, ["archive", "printer"])
 
-    logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id}")
+    logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}")
+
+    # MQTT relay - publish queue job added
+    try:
+        from backend.app.services.mqtt_relay import mqtt_relay
+
+        await mqtt_relay.on_queue_job_added(
+            job_id=item.id,
+            filename=item.archive.filename if item.archive else "",
+            printer_id=item.printer_id,
+            printer_name=item.printer.name if item.printer else None,
+        )
+    except Exception:
+        pass  # Don't fail queue add if MQTT fails
+
     return _enrich_response(item)
 
 
@@ -162,8 +189,8 @@ async def update_queue_item(
 
     update_data = data.model_dump(exclude_unset=True)
 
-    # Validate new printer_id if being changed
-    if "printer_id" in update_data:
+    # Validate new printer_id if being changed (and not None)
+    if "printer_id" in update_data and update_data["printer_id"] is not None:
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")

+ 53 - 25
backend/app/api/routes/printers.py

@@ -30,7 +30,7 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     list_files_async,
 )
-from backend.app.services.printer_manager import get_derived_status_name, printer_manager
+from backend.app.services.printer_manager import get_derived_status_name, printer_manager, supports_chamber_temp
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -338,6 +338,14 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
     tray_now = state.tray_now
     logger.debug(f"Using tray_now directly as global ID: {tray_now}")
 
+    # Filter out chamber temp for models that don't have a real sensor
+    # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
+    temperatures = state.temperatures
+    if not supports_chamber_temp(printer.model):
+        temperatures = {
+            k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
+        }
+
     return PrinterStatus(
         id=printer_id,
         name=printer.name,
@@ -350,7 +358,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         remaining_time=state.remaining_time,
         layer_num=state.layer_num,
         total_layers=state.total_layers,
-        temperatures=state.temperatures,
+        temperatures=temperatures,
         cover_url=cover_url,
         hms_errors=hms_errors,
         ams=ams_units,
@@ -488,24 +496,37 @@ async def get_printer_cover(
         if cache_key in _cover_cache[printer_id]:
             return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
 
-    # Build 3MF filename from subtask_name
-    # Bambu printers store files as "name.gcode.3mf"
-    filename = subtask_name
-    if not filename.endswith(".3mf"):
-        filename = filename + ".gcode.3mf"
+    # Build possible 3MF filenames from subtask_name
+    # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
+    # or just "name.3mf" (uploaded directly)
+    possible_filenames = []
+    if subtask_name.endswith(".3mf"):
+        possible_filenames.append(subtask_name)
+    else:
+        # Try both naming patterns
+        possible_filenames.append(f"{subtask_name}.gcode.3mf")
+        possible_filenames.append(f"{subtask_name}.3mf")
+
+    # Build list of all remote paths to try
+    remote_paths = []
+    for filename in possible_filenames:
+        remote_paths.extend(
+            [
+                f"/{filename}",  # Root directory (most common)
+                f"/cache/{filename}",
+                f"/model/{filename}",
+                f"/data/{filename}",
+            ]
+        )
 
-    # Try to download the 3MF file from printer
-    temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{filename}"
+    # Use first filename for temp path (will be reused)
+    temp_filename = possible_filenames[0]
+    temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
     temp_path.parent.mkdir(parents=True, exist_ok=True)
 
-    remote_paths = [
-        f"/{filename}",  # Root directory (most common)
-        f"/cache/{filename}",
-        f"/model/{filename}",
-        f"/data/{filename}",
-    ]
-
-    logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
+    logger.info(
+        f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
+    )
 
     # Retry logic for transient FTP failures
     max_retries = 2
@@ -535,7 +556,8 @@ async def get_printer_cover(
 
     if not downloaded:
         raise HTTPException(
-            404, f"Could not download 3MF file '{filename}' from printer {printer.ip_address}. Tried: {remote_paths}"
+            404,
+            f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
         )
 
     # Verify file actually exists and has content
@@ -547,7 +569,7 @@ async def get_printer_cover(
 
     if file_size == 0:
         temp_path.unlink()
-        raise HTTPException(500, f"Downloaded file is empty: {filename}")
+        raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
 
     try:
         # Extract thumbnail from 3MF (which is a ZIP file)
@@ -1207,16 +1229,22 @@ async def get_printable_objects(
             from backend.app.services.archive import extract_printable_objects_from_3mf
             from backend.app.services.bambu_ftp import download_file_try_paths_async
 
-            # Build 3MF filename
-            filename = subtask_name
-            if not filename.endswith(".3mf"):
-                filename = filename + ".gcode.3mf"
+            # Build possible 3MF filenames (try both .gcode.3mf and .3mf)
+            possible_filenames = []
+            if subtask_name.endswith(".3mf"):
+                possible_filenames.append(subtask_name)
+            else:
+                possible_filenames.append(f"{subtask_name}.gcode.3mf")
+                possible_filenames.append(f"{subtask_name}.3mf")
 
             # Download 3MF from printer
-            temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{filename}"
+            temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
             temp_path.parent.mkdir(parents=True, exist_ok=True)
 
-            remote_paths = [f"/{filename}", f"/cache/{filename}", f"/model/{filename}"]
+            # Build list of all remote paths to try
+            remote_paths = []
+            for filename in possible_filenames:
+                remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"])
 
             try:
                 downloaded = await download_file_try_paths_async(

+ 60 - 24
backend/app/api/routes/projects.py

@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/projects", tags=["projects"])
 
 
-async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
+async def compute_project_stats(
+    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
     """Compute statistics for a project."""
     # Count total archives (distinct print jobs)
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
@@ -49,18 +51,11 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     )
     total_items = total_items_result.scalar() or 0
 
-    # Sum completed items (using quantity field)
-    completed_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-            PrintArchive.project_id == project_id, PrintArchive.status == "completed"
-        )
-    )
-    completed_prints = completed_result.scalar() or 0
-
-    # Sum failed items (using quantity field)
+    # Count failed archives (number of print jobs) - includes all failure states
     failed_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-            PrintArchive.project_id == project_id, PrintArchive.status == "failed"
+        select(func.count(PrintArchive.id)).where(
+            PrintArchive.project_id == project_id,
+            PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
         )
     )
     failed_prints = failed_result.scalar() or 0
@@ -93,12 +88,28 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     )
     in_progress_prints = in_progress_result.scalar() or 0
 
-    # Calculate progress
+    # Sum completed items (parts) - sum of quantities for successful prints
+    completed_items_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
+            PrintArchive.project_id == project_id,
+            PrintArchive.status.in_(["completed", "archived"]),
+        )
+    )
+    completed_items = int(completed_items_result.scalar() or 0)
+
+    # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     remaining_prints = None
     if target_count and target_count > 0:
-        progress_percent = round((completed_prints / target_count) * 100, 1)
-        remaining_prints = max(0, target_count - completed_prints)
+        progress_percent = round((total_archives / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - total_archives)
+
+    # Calculate progress for parts (target_parts_count vs completed_items)
+    parts_progress_percent = None
+    remaining_parts = None
+    if target_parts_count and target_parts_count > 0:
+        parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
+        remaining_parts = max(0, target_parts_count - completed_items)
 
     # BOM stats
     bom_result = await db.execute(
@@ -114,17 +125,19 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     return ProjectStats(
         total_archives=total_archives,
         total_items=int(total_items),
-        completed_prints=int(completed_prints),
+        completed_prints=completed_items,  # Now reflects sum of quantities for completed prints
         failed_prints=int(failed_prints),
         queued_prints=queued_prints,
         in_progress_prints=in_progress_prints,
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
         total_filament_grams=round(sums.total_filament or 0, 2),
         progress_percent=progress_percent,
+        parts_progress_percent=parts_progress_percent,
         estimated_cost=round((sums.total_filament_cost or 0), 2),
         total_energy_kwh=round((sums.total_energy or 0), 3),
         total_energy_cost=round((sums.total_energy_cost or 0), 2),
         remaining_prints=remaining_prints,
+        remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,
         bom_completed_items=int(bom_stats.completed or 0),
     )
@@ -169,18 +182,28 @@ async def list_projects(
         )
         queue_count = queue_count_result.scalar() or 0
 
-        # Get completed count for progress (sum of quantities)
+        # Sum completed parts (quantities) - includes "archived" as successful
         completed_result = await db.execute(
             select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
-                PrintArchive.status == "completed",
+                PrintArchive.status.in_(["completed", "archived"]),
             )
         )
         completed_count = int(completed_result.scalar() or 0)
 
+        # Sum failed parts (quantities) - includes all failure states
+        failed_result = await db.execute(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
+                PrintArchive.project_id == project.id,
+                PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
+            )
+        )
+        failed_count = int(failed_result.scalar() or 0)
+
+        # Plates progress: archive_count / target_count
         progress_percent = None
         if project.target_count and project.target_count > 0:
-            progress_percent = round((completed_count / project.target_count) * 100, 1)
+            progress_percent = round((archive_count / project.target_count) * 100, 1)
 
         # Get archive previews (up to 6 most recent)
         archives_result = await db.execute(
@@ -210,9 +233,12 @@ async def list_projects(
                 color=project.color,
                 status=project.status,
                 target_count=project.target_count,
+                target_parts_count=project.target_parts_count,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 total_items=total_items,
+                completed_count=completed_count,
+                failed_count=failed_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
                 archives=archive_previews,
@@ -242,6 +268,7 @@ async def create_project(
         description=data.description,
         color=data.color,
         target_count=data.target_count,
+        target_parts_count=data.target_parts_count,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -253,7 +280,7 @@ async def create_project(
     await db.flush()
     await db.refresh(project)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -262,6 +289,7 @@ async def create_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -339,6 +367,7 @@ async def create_project_from_template(
         description=template.description,
         color=template.color,
         target_count=template.target_count,
+        target_parts_count=template.target_parts_count,
         notes=template.notes,
         tags=template.tags,
         priority=template.priority,
@@ -370,7 +399,7 @@ async def create_project_from_template(
     await db.flush()
     await db.refresh(project)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -379,6 +408,7 @@ async def create_project_from_template(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -451,7 +481,7 @@ async def get_project(
     # Get children
     children = await get_child_previews(db, project.id)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -460,6 +490,7 @@ async def get_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -503,6 +534,8 @@ async def update_project(
         project.status = data.status
     if data.target_count is not None:
         project.target_count = data.target_count
+    if data.target_parts_count is not None:
+        project.target_parts_count = data.target_parts_count
     if data.notes is not None:
         project.notes = data.notes
     if data.tags is not None:
@@ -539,7 +572,7 @@ async def update_project(
     # Get children
     children = await get_child_previews(db, project.id)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -548,6 +581,7 @@ async def update_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -1131,6 +1165,7 @@ async def create_template_from_project(
         description=source.description,
         color=source.color,
         target_count=source.target_count,
+        target_parts_count=source.target_parts_count,
         notes=source.notes,
         tags=source.tags,
         priority=source.priority,
@@ -1162,7 +1197,7 @@ async def create_template_from_project(
     await db.flush()
     await db.refresh(template)
 
-    stats = await compute_project_stats(db, template.id, template.target_count)
+    stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)
 
     return ProjectResponse(
         id=template.id,
@@ -1171,6 +1206,7 @@ async def create_template_from_project(
         color=template.color,
         status=template.status,
         target_count=template.target_count,
+        target_parts_count=template.target_parts_count,
         notes=template.notes,
         attachments=template.attachments,
         tags=template.tags,

+ 207 - 13
backend/app/api/routes/settings.py

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.filament import Filament
@@ -73,6 +74,8 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "telemetry_enabled",
                 "virtual_printer_enabled",
                 "ftp_retry_enabled",
+                "mqtt_enabled",
+                "mqtt_use_tls",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
@@ -83,6 +86,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "ams_history_retention_days",
                 "ftp_retry_count",
                 "ftp_retry_delay",
+                "mqtt_port",
             ]:
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":
@@ -102,6 +106,18 @@ async def update_settings(
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
 
+    # Check if any MQTT settings are being updated
+    mqtt_keys = {
+        "mqtt_enabled",
+        "mqtt_broker",
+        "mqtt_port",
+        "mqtt_username",
+        "mqtt_password",
+        "mqtt_topic_prefix",
+        "mqtt_use_tls",
+    }
+    mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
+
     for key, value in update_data.items():
         # Convert value to string for storage
         if isinstance(value, bool):
@@ -114,6 +130,24 @@ async def update_settings(
 
     await db.commit()
 
+    # Reconfigure MQTT relay if any MQTT settings changed
+    if mqtt_updated:
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            mqtt_settings = {
+                "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
+                "mqtt_broker": await get_setting(db, "mqtt_broker") or "",
+                "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
+                "mqtt_username": await get_setting(db, "mqtt_username") or "",
+                "mqtt_password": await get_setting(db, "mqtt_password") or "",
+                "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
+                "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
+            }
+            await mqtt_relay.configure(mqtt_settings)
+        except Exception:
+            pass  # Don't fail the settings update if MQTT reconfiguration fails
+
     # Return updated settings
     return await get_settings(db)
 
@@ -195,6 +229,7 @@ async def export_backup(
     include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
+    include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -478,7 +513,7 @@ async def export_backup(
         for qi in queue_items:
             backup["print_queue"].append(
                 {
-                    "printer_serial": printer_id_to_serial.get(qi.printer_id),
+                    "printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None,
                     "archive_hash": archive_id_to_hash.get(qi.archive_id),
                     "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
                     "position": qi.position,
@@ -486,6 +521,7 @@ async def export_backup(
                     "require_previous_success": qi.require_previous_success,
                     "auto_off_after": qi.auto_off_after,
                     "manual_start": qi.manual_start,
+                    "ams_mapping": qi.ams_mapping,
                     "status": qi.status,
                     "started_at": qi.started_at.isoformat() if qi.started_at else None,
                     "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
@@ -494,12 +530,13 @@ async def export_backup(
             )
         backup["included"].append("print_queue")
 
-    # Collect files for ZIP (icons + archives)
+    # Collect files for ZIP (icons + archives + project attachments)
     backup_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
+    base_dir = app_settings.base_dir
 
     # Add external link icon files
     if include_external_links and "external_links" in backup:
-        icons_dir = app_settings.base_dir / "icons"
+        icons_dir = base_dir / "icons"
         for link_data in backup["external_links"]:
             if "custom_icon_path" in link_data:
                 icon_path = icons_dir / link_data["custom_icon"]
@@ -511,7 +548,6 @@ async def export_backup(
         result = await db.execute(select(PrintArchive))
         archives = result.scalars().all()
         backup["archives"] = []
-        base_dir = app_settings.base_dir
 
         # Build project ID to name mapping for archive export
         project_id_to_name: dict[int, str] = {}
@@ -680,6 +716,39 @@ async def export_backup(
             backup["pending_uploads"].append(upload_data)
         backup["included"].append("pending_uploads")
 
+    # API keys (note: key_hash cannot be restored, new keys must be generated)
+    if include_api_keys:
+        # Build printer ID to serial mapping for cross-system compatibility
+        printer_id_to_serial: dict[int, str] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_id_to_serial[pr.id] = pr.serial_number
+
+        result = await db.execute(select(APIKey))
+        api_keys = result.scalars().all()
+        backup["api_keys"] = []
+        for key in api_keys:
+            # Convert printer_ids from list of IDs to list of serials
+            printer_serials = None
+            if key.printer_ids:
+                printer_serials = [
+                    printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial
+                ]
+
+            backup["api_keys"].append(
+                {
+                    "name": key.name,
+                    "key_prefix": key.key_prefix,  # For identification only
+                    "can_queue": key.can_queue,
+                    "can_control_printer": key.can_control_printer,
+                    "can_read_status": key.can_read_status,
+                    "printer_serials": printer_serials,  # Use serials instead of IDs
+                    "enabled": key.enabled,
+                    "expires_at": key.expires_at.isoformat() if key.expires_at else None,
+                }
+            )
+        backup["included"].append("api_keys")
+
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
         zip_buffer = io.BytesIO()
@@ -1451,32 +1520,44 @@ async def import_backup(
         skipped_details["print_queue"] = []
 
         for qi_data in backup["print_queue"]:
-            printer_serial = qi_data.get("printer_serial")
+            printer_serial = qi_data.get("printer_serial")  # Can be None for unassigned items
             archive_hash = qi_data.get("archive_hash")
 
-            if not printer_serial or not archive_hash:
+            # Archive is required, but printer can be None (unassigned)
+            if not archive_hash:
                 skipped["print_queue"] += 1
                 continue
 
-            printer_id = printer_serial_to_id.get(printer_serial)
+            # Look up printer_id (None if unassigned or printer not found)
+            printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
             archive_id = archive_hash_to_id.get(archive_hash)
 
-            if not printer_id or not archive_id:
+            # Archive must exist, but printer is optional (unassigned items)
+            if not archive_id:
+                skipped["print_queue"] += 1
+                skipped_details["print_queue"].append(
+                    f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}"
+                )
+                continue
+
+            # If printer_serial was specified but printer not found, skip
+            if printer_serial and not printer_id:
                 skipped["print_queue"] += 1
-                skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}")
+                skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}")
                 continue
 
             project_name = qi_data.get("project_name")
             project_id = project_name_to_id.get(project_name) if project_name else None
 
             qi = PrintQueueItem(
-                printer_id=printer_id,
+                printer_id=printer_id,  # Can be None for unassigned items
                 archive_id=archive_id,
                 project_id=project_id,
                 position=qi_data.get("position", 0),
                 require_previous_success=qi_data.get("require_previous_success", False),
                 auto_off_after=qi_data.get("auto_off_after", False),
                 manual_start=qi_data.get("manual_start", False),
+                ams_mapping=qi_data.get("ams_mapping"),
                 status=qi_data.get("status", "pending"),
                 error_message=qi_data.get("error_message"),
             )
@@ -1565,6 +1646,78 @@ async def import_backup(
                 db.add(pending)
                 restored["pending_uploads"] += 1
 
+    # Restore API keys (generates new keys since we can't restore the hash)
+    new_api_keys: list[dict] = []  # Track newly generated keys for response
+    if "api_keys" in backup:
+        from backend.app.core.auth import generate_api_key
+
+        # Build printer serial to ID mapping
+        printer_serial_to_id: dict[str, int] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
+        restored["api_keys"] = 0
+        skipped["api_keys"] = 0
+        skipped_details["api_keys"] = []
+
+        for key_data in backup["api_keys"]:
+            # Check if key with same name already exists
+            result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    # Update permissions but keep the existing key
+                    existing.can_queue = key_data.get("can_queue", True)
+                    existing.can_control_printer = key_data.get("can_control_printer", False)
+                    existing.can_read_status = key_data.get("can_read_status", True)
+                    existing.enabled = key_data.get("enabled", True)
+                    if key_data.get("expires_at"):
+                        existing.expires_at = datetime.fromisoformat(key_data["expires_at"])
+                    # Convert printer serials to IDs
+                    if key_data.get("printer_serials"):
+                        existing.printer_ids = [
+                            printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
+                        ]
+                    restored["api_keys"] += 1
+                else:
+                    skipped["api_keys"] += 1
+                    skipped_details["api_keys"].append(key_data["name"])
+            else:
+                # Generate new key
+                full_key, key_hash, key_prefix = generate_api_key()
+
+                # Convert printer serials to IDs
+                printer_ids = None
+                if key_data.get("printer_serials"):
+                    printer_ids = [
+                        printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
+                    ]
+
+                api_key = APIKey(
+                    name=key_data["name"],
+                    key_hash=key_hash,
+                    key_prefix=key_prefix,
+                    can_queue=key_data.get("can_queue", True),
+                    can_control_printer=key_data.get("can_control_printer", False),
+                    can_read_status=key_data.get("can_read_status", True),
+                    printer_ids=printer_ids,
+                    enabled=key_data.get("enabled", True),
+                )
+                if key_data.get("expires_at"):
+                    api_key.expires_at = datetime.fromisoformat(key_data["expires_at"])
+                db.add(api_key)
+                restored["api_keys"] += 1
+
+                # Track the new key so user can see it
+                new_api_keys.append(
+                    {
+                        "name": key_data["name"],
+                        "key": full_key,
+                        "key_prefix": key_prefix,
+                    }
+                )
+
     await db.commit()
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1623,6 +1776,23 @@ async def import_backup(
         except Exception:
             pass  # Virtual printer config failed, but don't fail the restore
 
+        # Reconfigure MQTT relay if settings were restored
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            mqtt_settings = {
+                "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
+                "mqtt_broker": await get_setting(db, "mqtt_broker") or "",
+                "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
+                "mqtt_username": await get_setting(db, "mqtt_username") or "",
+                "mqtt_password": await get_setting(db, "mqtt_password") or "",
+                "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
+                "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
+            }
+            await mqtt_relay.configure(mqtt_settings)
+        except Exception:
+            pass  # MQTT relay config failed, but don't fail the restore
+
     # Build summary message
     restored_parts = []
     for key, count in restored.items():
@@ -1644,7 +1814,7 @@ async def import_backup(
     if skipped_parts:
         message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
 
-    return {
+    response = {
         "success": True,
         "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
         "restored": restored,
@@ -1654,6 +1824,12 @@ async def import_backup(
         "total_skipped": total_skipped,
     }
 
+    # Include newly generated API keys if any (so user can see them)
+    if new_api_keys:
+        response["new_api_keys"] = new_api_keys
+
+    return response
+
 
 # =============================================================================
 # Virtual Printer Settings
@@ -1724,11 +1900,16 @@ async def update_virtual_printer_settings(
     new_model = model if model is not None else current_model
 
     # Validate mode
-    if new_mode not in ("immediate", "queue"):
+    # "review" is the new name for "queue" (pending review before archiving)
+    # "print_queue" archives and adds to print queue (unassigned)
+    if new_mode not in ("immediate", "queue", "review", "print_queue"):
         return JSONResponse(
             status_code=400,
-            content={"detail": "Mode must be 'immediate' or 'queue'"},
+            content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
         )
+    # Normalize legacy "queue" to "review" for storage
+    if new_mode == "queue":
+        new_mode = "review"
 
     # Validate model
     if model is not None and model not in VIRTUAL_PRINTER_MODELS:
@@ -1780,3 +1961,16 @@ async def update_virtual_printer_settings(
         )
 
     return await get_virtual_printer_settings(db)
+
+
+# =============================================================================
+# MQTT Relay Settings
+# =============================================================================
+
+
+@router.get("/mqtt/status")
+async def get_mqtt_status():
+    """Get MQTT relay connection status."""
+    from backend.app.services.mqtt_relay import mqtt_relay
+
+    return mqtt_relay.get_status()

+ 22 - 0
backend/app/api/routes/smart_plugs.py

@@ -298,6 +298,28 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     await db.commit()
 
+    # MQTT relay - publish smart plug state change
+    if expected_state:
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            # Get printer name if linked
+            printer_name = None
+            if plug.printer_id:
+                result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))
+                printer = result.scalar_one_or_none()
+                printer_name = printer.name if printer else None
+
+            await mqtt_relay.on_smart_plug_state(
+                plug_id=plug.id,
+                plug_name=plug.name,
+                state="on" if expected_state == "ON" else "off",
+                printer_id=plug.printer_id,
+                printer_name=printer_name,
+            )
+        except Exception:
+            pass  # Don't fail if MQTT fails
+
     return {"success": True, "action": control.action}
 
 

+ 18 - 6
backend/app/api/routes/spoolman.py

@@ -213,7 +213,7 @@ async def sync_printer_ams(
             location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
 
             # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
-            if not client.is_bambu_lab_spool(tray.tray_uuid):
+            if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
                 skipped.append(
                     SkippedSpool(
                         location=location,
@@ -224,8 +224,14 @@ async def sync_printer_ams(
                 )
                 continue
 
-            # Track this tray UUID as currently present in the AMS
-            current_tray_uuids.add(tray.tray_uuid.upper())
+            # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
+            spool_tag = (
+                tray.tray_uuid
+                if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
+                else tray.tag_uid
+            )
+            if spool_tag:
+                current_tray_uuids.add(spool_tag.upper())
 
             try:
                 sync_result = await client.sync_ams_tray(tray, printer.name)
@@ -337,7 +343,7 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                 location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
 
                 # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
-                if not client.is_bambu_lab_spool(tray.tray_uuid):
+                if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
                     all_skipped.append(
                         SkippedSpool(
                             location=location,
@@ -348,8 +354,14 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                     )
                     continue
 
-                # Track this tray UUID as currently present in the AMS
-                printer_tray_uuids[printer.name].add(tray.tray_uuid.upper())
+                # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
+                spool_tag = (
+                    tray.tray_uuid
+                    if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
+                    else tray.tag_uid
+                )
+                if spool_tag:
+                    printer_tray_uuids[printer.name].add(spool_tag.upper())
 
                 try:
                     sync_result = await client.sync_ams_tray(tray, printer.name)

+ 2 - 2
backend/app/api/routes/websocket.py

@@ -24,7 +24,7 @@ async def websocket_endpoint(websocket: WebSocket):
                 {
                     "type": "printer_status",
                     "printer_id": printer_id,
-                    "data": printer_state_to_dict(state),
+                    "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                 }
             )
         logger.info(f"Sent initial status for {len(statuses)} printers")
@@ -47,7 +47,7 @@ async def websocket_endpoint(websocket: WebSocket):
                             {
                                 "type": "printer_status",
                                 "printer_id": printer_id,
-                                "data": printer_state_to_dict(state),
+                                "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
                             }
                         )
 

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6b7"
+APP_VERSION = "0.1.6b8"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 50 - 0
backend/app/core/database.py

@@ -399,6 +399,56 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add target_parts_count column to projects for tracking total parts needed
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER"))
+    except Exception:
+        pass
+
+    # Migration: Make printer_id nullable in print_queue for unassigned queue items
+    # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
+    try:
+        # Check if printer_id is already nullable by trying to insert NULL
+        # This is a safe check that won't affect existing data
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
+        row = result.fetchone()
+        if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
+            # Need to migrate - printer_id is currently NOT NULL
+            await conn.execute(
+                text("""
+                CREATE TABLE print_queue_new (
+                    id INTEGER PRIMARY KEY,
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
+                    archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+                    project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+                    position INTEGER DEFAULT 0,
+                    scheduled_time DATETIME,
+                    manual_start BOOLEAN DEFAULT 0,
+                    require_previous_success BOOLEAN DEFAULT 0,
+                    auto_off_after BOOLEAN DEFAULT 0,
+                    ams_mapping TEXT,
+                    status VARCHAR(20) DEFAULT 'pending',
+                    started_at DATETIME,
+                    completed_at DATETIME,
+                    error_message TEXT,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+                )
+            """)
+            )
+            await conn.execute(
+                text("""
+                INSERT INTO print_queue_new
+                SELECT id, printer_id, archive_id, project_id, position, scheduled_time,
+                       manual_start, require_previous_success, auto_off_after, ams_mapping,
+                       status, started_at, completed_at, error_message, created_at
+                FROM print_queue
+            """)
+            )
+            await conn.execute(text("DROP TABLE print_queue"))
+            await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 124 - 3
backend/app/main.py

@@ -85,6 +85,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
 from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.notification_service import notification_service
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.printer_manager import (
@@ -241,16 +242,26 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
-        f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}"
+        f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
+        f"{state.chamber_light}"
     )
+
+    # MQTT relay - publish status (before dedup check - always publish to MQTT)
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info:
+            await mqtt_relay.on_printer_status(printer_id, state, printer_info.name, printer_info.serial_number)
+    except Exception:
+        pass  # Don't fail status callback if MQTT fails
+
     if _last_status_broadcast.get(printer_id) == status_key:
-        return  # No change, skip broadcast
+        return  # No change, skip WebSocket broadcast
 
     _last_status_broadcast[printer_id] = status_key
 
     await ws_manager.send_printer_status(
         printer_id,
-        printer_state_to_dict(state, printer_id),
+        printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
     )
 
 
@@ -260,6 +271,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
 
     logger = logging.getLogger(__name__)
 
+    # MQTT relay - publish AMS change
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info:
+            await mqtt_relay.on_ams_change(printer_id, printer_info.name, printer_info.serial_number, ams_data)
+    except Exception:
+        pass  # Don't fail AMS callback if MQTT fails
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
@@ -378,6 +397,20 @@ async def on_print_start(printer_id: int, data: dict):
 
     await ws_manager.send_print_start(printer_id, data)
 
+    # MQTT relay - publish print start
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info:
+            await mqtt_relay.on_print_start(
+                printer_id,
+                printer_info.name,
+                printer_info.serial_number,
+                data.get("filename", ""),
+                data.get("subtask_name", ""),
+            )
+    except Exception:
+        pass  # Don't fail print start callback if MQTT fails
+
     # Track if notification was sent (to avoid sending twice)
     notification_sent = False
 
@@ -750,6 +783,17 @@ async def on_print_start(printer_id: int, data: dict):
                     }
                 )
 
+                # MQTT relay - publish archive created
+                try:
+                    await mqtt_relay.on_archive_created(
+                        archive_id=fallback_archive.id,
+                        print_name=fallback_archive.print_name,
+                        printer_name=printer.name,
+                        status=fallback_archive.status,
+                    )
+                except Exception:
+                    pass  # Don't fail if MQTT fails
+
                 # Send notification without archive data (file not found)
                 if not notification_sent:
                     await _send_print_start_notification(printer_id, data, logger=logger)
@@ -812,6 +856,17 @@ async def on_print_start(printer_id: int, data: dict):
                     }
                 )
 
+                # MQTT relay - publish archive created
+                try:
+                    await mqtt_relay.on_archive_created(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        printer_name=printer.name,
+                        status=archive.status,
+                    )
+                except Exception:
+                    pass  # Don't fail if MQTT fails
+
                 # Send notification with archive data (new archive created)
                 if not notification_sent:
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
@@ -984,6 +1039,21 @@ async def on_print_complete(printer_id: int, data: dict):
     except Exception as e:
         logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}")
 
+    # MQTT relay - publish print complete
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info:
+            await mqtt_relay.on_print_complete(
+                printer_id,
+                printer_info.name,
+                printer_info.serial_number,
+                data.get("filename", ""),
+                data.get("subtask_name", ""),
+                data.get("status", "completed"),
+            )
+    except Exception:
+        pass  # Don't fail print complete callback if MQTT fails
+
     filename = data.get("filename", "")
     subtask_name = data.get("subtask_name", "")
 
@@ -1139,6 +1209,16 @@ async def on_print_complete(printer_id: int, data: dict):
                 }
             )
             logger.info(f"[ARCHIVE] WebSocket notification sent for archive {archive_id}")
+
+            # MQTT relay - publish archive updated
+            try:
+                await mqtt_relay.on_archive_updated(
+                    archive_id=archive_id,
+                    print_name=filename or subtask_name,
+                    status=status,
+                )
+            except Exception:
+                pass  # Don't fail if MQTT fails
     except Exception as e:
         logger.error(f"[ARCHIVE] Failed to update archive {archive_id} status: {e}", exc_info=True)
         # Continue with other operations even if archive update fails
@@ -1339,6 +1419,19 @@ async def on_print_complete(printer_id: int, data: dict):
                 if items_needing_attention:
                     await notification_service.on_maintenance_due(printer_id, printer_name, items_needing_attention, db)
                     logger.info(f"[MAINT-BG] Sent notification: {len(items_needing_attention)} items need attention")
+
+                    # MQTT relay - publish maintenance alerts
+                    for item in items_needing_attention:
+                        try:
+                            await mqtt_relay.on_maintenance_alert(
+                                printer_id=printer_id,
+                                printer_name=printer_name,
+                                maintenance_type=item["name"],
+                                current_value=0,  # Not easily available here
+                                threshold=0,  # Not easily available here
+                            )
+                        except Exception:
+                            pass  # Don't fail if MQTT fails
                 else:
                     logger.info("[MAINT-BG] Completed (no items need attention)")
         except Exception as e:
@@ -1378,6 +1471,19 @@ async def on_print_complete(printer_id: int, data: dict):
                 await db.commit()
                 logger.info(f"Updated queue item {queue_item.id} status to {status}")
 
+                # MQTT relay - publish queue job completed
+                try:
+                    printer_info = printer_manager.get_printer(printer_id)
+                    await mqtt_relay.on_queue_job_completed(
+                        job_id=queue_item.id,
+                        filename=filename or subtask_name,
+                        printer_id=printer_id,
+                        printer_name=printer_info.name if printer_info else "Unknown",
+                        status=status,
+                    )
+                except Exception:
+                    pass  # Don't fail if MQTT fails
+
                 # Handle auto_off_after - power off printer if requested (after cooldown)
                 if queue_item.auto_off_after:
                     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
@@ -1733,6 +1839,21 @@ async def lifespan(app: FastAPI):
     printer_manager.set_print_complete_callback(on_print_complete)
     printer_manager.set_ams_change_callback(on_ams_change)
 
+    # Initialize MQTT relay from settings
+    async with async_session() as db:
+        from backend.app.api.routes.settings import get_setting
+
+        mqtt_settings = {
+            "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
+            "mqtt_broker": await get_setting(db, "mqtt_broker") or "",
+            "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
+            "mqtt_username": await get_setting(db, "mqtt_username") or "",
+            "mqtt_password": await get_setting(db, "mqtt_password") or "",
+            "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
+            "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
+        }
+        await mqtt_relay.configure(mqtt_settings)
+
     # Connect to all active printers
     async with async_session() as db:
         await init_printer_connections(db)

+ 1 - 1
backend/app/models/print_queue.py

@@ -14,7 +14,7 @@ class PrintQueueItem(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
 
     # Links
-    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
     archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 

+ 6 - 1
backend/app/models/project.py

@@ -16,7 +16,12 @@ class Project(Base):
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
     color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
-    target_count: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Optional target number of prints
+    target_count: Mapped[int | None] = mapped_column(
+        Integer, nullable=True
+    )  # Optional target number of prints (plates)
+    target_parts_count: Mapped[int | None] = mapped_column(
+        Integer, nullable=True
+    )  # Optional target number of parts/objects
 
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 2 - 2
backend/app/schemas/print_queue.py

@@ -16,7 +16,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 
 
 class PrintQueueItemCreate(BaseModel):
-    printer_id: int
+    printer_id: int | None = None  # None = unassigned, user assigns later
     archive_id: int
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
@@ -39,7 +39,7 @@ class PrintQueueItemUpdate(BaseModel):
 
 class PrintQueueItemResponse(BaseModel):
     id: int
-    printer_id: int
+    printer_id: int | None  # None = unassigned
     archive_id: int
     position: int
     scheduled_time: UTCDatetime

+ 11 - 3
backend/app/schemas/project.py

@@ -10,6 +10,7 @@ class ProjectCreate(BaseModel):
     description: str | None = None
     color: str | None = None
     target_count: int | None = None
+    target_parts_count: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -26,6 +27,7 @@ class ProjectUpdate(BaseModel):
     color: str | None = None
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
+    target_parts_count: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -45,12 +47,14 @@ class ProjectStats(BaseModel):
     in_progress_prints: int = 0
     total_print_time_hours: float = 0.0
     total_filament_grams: float = 0.0
-    progress_percent: float | None = None  # Based on target_count
+    progress_percent: float | None = None  # Based on target_count (plates)
+    parts_progress_percent: float | None = None  # Based on target_parts_count
     # Cost tracking (Phase 6)
     estimated_cost: float = 0.0  # Based on filament cost
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
-    remaining_prints: int | None = None  # target_count - completed_prints
+    remaining_prints: int | None = None  # target_count - total_archives
+    remaining_parts: int | None = None  # target_parts_count - completed_prints
     # BOM stats (Phase 7)
     bom_total_items: int = 0
     bom_completed_items: int = 0
@@ -75,6 +79,7 @@ class ProjectResponse(BaseModel):
     color: str | None
     status: str
     target_count: int | None
+    target_parts_count: int | None = None
     notes: str | None = None
     attachments: list | None = None
     tags: str | None = None
@@ -114,10 +119,13 @@ class ProjectListResponse(BaseModel):
     color: str | None
     status: str
     target_count: int | None
+    target_parts_count: int | None = None
     created_at: datetime
     # Quick stats
     archive_count: int = 0  # Number of print jobs
-    total_items: int = 0  # Sum of quantities (total items printed)
+    total_items: int = 0  # Sum of quantities (total items printed, including failed)
+    completed_count: int = 0  # Sum of quantities for completed prints only
+    failed_count: int = 0  # Sum of quantities for failed prints
     queue_count: int = 0
     progress_percent: float | None = None
     # Preview of archives (up to 5)

+ 20 - 1
backend/app/schemas/settings.py

@@ -54,7 +54,10 @@ class AppSettings(BaseModel):
     # Virtual Printer
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
-    virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'")
+    virtual_printer_mode: str = Field(
+        default="immediate",
+        description="Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)",
+    )
 
     # Dark mode theme settings
     dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant")
@@ -73,6 +76,15 @@ class AppSettings(BaseModel):
     ftp_retry_count: int = Field(default=3, description="Number of retry attempts for FTP operations (1-10)")
     ftp_retry_delay: int = Field(default=2, description="Seconds to wait between FTP retry attempts (1-30)")
 
+    # MQTT Relay settings for publishing events to external broker
+    mqtt_enabled: bool = Field(default=False, description="Enable MQTT event publishing to external broker")
+    mqtt_broker: str = Field(default="", description="MQTT broker hostname or IP address")
+    mqtt_port: int = Field(default=1883, description="MQTT broker port (default 1883, TLS typically 8883)")
+    mqtt_username: str = Field(default="", description="MQTT username for authentication (optional)")
+    mqtt_password: str = Field(default="", description="MQTT password for authentication (optional)")
+    mqtt_topic_prefix: str = Field(default="bambuddy", description="Topic prefix for all published messages")
+    mqtt_use_tls: bool = Field(default=False, description="Use TLS/SSL encryption for MQTT connection")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -110,3 +122,10 @@ class AppSettingsUpdate(BaseModel):
     ftp_retry_enabled: bool | None = None
     ftp_retry_count: int | None = None
     ftp_retry_delay: int | None = None
+    mqtt_enabled: bool | None = None
+    mqtt_broker: str | None = None
+    mqtt_port: int | None = None
+    mqtt_username: str | None = None
+    mqtt_password: str | None = None
+    mqtt_topic_prefix: str | None = None
+    mqtt_use_tls: bool | None = None

+ 9 - 0
backend/app/services/archive.py

@@ -834,6 +834,14 @@ class ArchiveService:
                 default_cost_per_kg = 25.0
                 cost = round((filament_grams / 1000) * default_cost_per_kg, 2)
 
+        # Calculate quantity from printable objects count
+        # printable_objects is a dict of {identify_id: name} for non-skipped objects
+        quantity = 1  # Default to 1
+        printable_objects = metadata.get("printable_objects")
+        if printable_objects and isinstance(printable_objects, dict):
+            quantity = len(printable_objects)
+            logger.debug(f"Auto-detected {quantity} parts from 3MF printable objects")
+
         # Create archive record
         archive = PrintArchive(
             printer_id=printer_id,
@@ -858,6 +866,7 @@ class ArchiveService:
             started_at=started_at,
             completed_at=completed_at,
             cost=cost,
+            quantity=quantity,
             extra_data=metadata,
         )
 

+ 27 - 1
backend/app/services/bambu_mqtt.py

@@ -912,6 +912,7 @@ class BambuMQTTClient:
 
         # Merge AMS data instead of replacing, to handle partial updates
         # During prints, the printer may only send updates for active AMS units
+        # We need deep merging at the tray level to preserve fields like tray_sub_brands
         existing_ams = self.state.raw_data.get("ams", [])
         existing_by_id = {ams.get("id"): ams for ams in existing_ams if ams.get("id") is not None}
 
@@ -919,6 +920,31 @@ class BambuMQTTClient:
         for ams_unit in ams_list:
             ams_id = ams_unit.get("id")
             if ams_id is not None:
+                existing_unit = existing_by_id.get(ams_id)
+                if existing_unit and "tray" in ams_unit:
+                    # Deep merge trays to preserve fields from previous updates
+                    existing_trays = {t.get("id"): t for t in existing_unit.get("tray", []) if t.get("id") is not None}
+                    merged_trays = []
+                    for new_tray in ams_unit.get("tray", []):
+                        tray_id = new_tray.get("id")
+                        if tray_id is not None and tray_id in existing_trays:
+                            # Merge: start with existing, update with new non-empty values
+                            merged_tray = existing_trays[tray_id].copy()
+                            for key, value in new_tray.items():
+                                # Only overwrite if new value is not empty/None
+                                # Exception: remain/k can be 0, which is valid
+                                if key in ("remain", "k", "id", "cali_idx") or value not in (
+                                    None,
+                                    "",
+                                    "0000000000000000",
+                                    "00000000000000000000000000000000",
+                                ):
+                                    merged_tray[key] = value
+                            merged_trays.append(merged_tray)
+                        else:
+                            merged_trays.append(new_tray)
+                    # Update ams_unit with merged trays
+                    ams_unit = {**ams_unit, "tray": merged_trays}
                 existing_by_id[ams_id] = ams_unit
 
         # Convert back to list, sorted by ID for consistent ordering
@@ -1915,7 +1941,7 @@ class BambuMQTTClient:
     ):
         """Start a print job on the printer.
 
-        The file should already be uploaded to /cache/ on the printer via FTP.
+        The file should already be uploaded to the printer's root directory via FTP.
 
         Args:
             filename: Name of the uploaded file

+ 1 - 1
backend/app/services/camera.py

@@ -460,7 +460,7 @@ async def test_camera_connection(
                 "success": False,
                 "error": (
                     "Failed to capture frame from camera. "
-                    "Ensure the printer is powered on, camera is enabled, and LAN mode is active. "
+                    "Ensure the printer is powered on, camera is enabled, and Developer Mode is active. "
                     "If running in Docker, try 'network_mode: host' in docker-compose.yml."
                 ),
             }

+ 650 - 0
backend/app/services/mqtt_relay.py

@@ -0,0 +1,650 @@
+"""MQTT Relay Service for publishing BamBuddy events to external MQTT brokers.
+
+This service enables integration with external automation systems like
+Node-RED, Home Assistant, and other MQTT-based platforms.
+"""
+
+import asyncio
+import json
+import logging
+import ssl
+import threading
+import time
+from datetime import datetime
+from typing import Any
+
+import paho.mqtt.client as mqtt
+
+logger = logging.getLogger(__name__)
+
+
+class MQTTRelayService:
+    """Publishes BamBuddy events to an external MQTT broker."""
+
+    # Minimum interval between status updates per printer (seconds)
+    STATUS_THROTTLE_SECONDS = 1.0
+
+    def __init__(self):
+        self.client: mqtt.Client | None = None
+        self.enabled = False
+        self.connected = False
+        self.topic_prefix = "bambuddy"
+        self._lock = threading.Lock()
+        self._loop: asyncio.AbstractEventLoop | None = None
+        self._broker = ""
+        self._port = 1883
+        self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
+
+    async def configure(self, settings: dict) -> bool:
+        """Configure MQTT connection from settings.
+
+        Returns True if connection was successful or MQTT is disabled.
+        """
+        self.enabled = settings.get("mqtt_enabled", False)
+
+        if not self.enabled:
+            await self.disconnect()
+            logger.info("MQTT relay disabled")
+            return True
+
+        broker = settings.get("mqtt_broker", "")
+        port = settings.get("mqtt_port", 1883)
+        username = settings.get("mqtt_username", "")
+        password = settings.get("mqtt_password", "")
+        self.topic_prefix = settings.get("mqtt_topic_prefix", "bambuddy")
+        use_tls = settings.get("mqtt_use_tls", False)
+
+        if not broker:
+            logger.warning("MQTT enabled but no broker configured")
+            return False
+
+        # Store for status endpoint
+        self._broker = broker
+        self._port = port
+
+        # Disconnect existing connection if settings changed
+        if self.client:
+            await self.disconnect()
+
+        # Create and connect client
+        return await self._connect(broker, port, username, password, use_tls)
+
+    async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
+        """Establish MQTT connection."""
+        try:
+            # Create client with callback API version 2 (use MQTTv311 for broader compatibility)
+            self.client = mqtt.Client(
+                callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+                client_id=f"bambuddy-{id(self)}",
+                protocol=mqtt.MQTTv311,
+            )
+
+            # Set up callbacks
+            self.client.on_connect = self._on_connect
+            self.client.on_disconnect = self._on_disconnect
+
+            # Configure authentication
+            if username:
+                self.client.username_pw_set(username, password)
+
+            # Configure TLS (allow self-signed certs for testing)
+            if use_tls:
+                self.client.tls_set(cert_reqs=ssl.CERT_NONE)
+                self.client.tls_insecure_set(True)  # Allow self-signed certs
+
+            # Connect (non-blocking with loop_start)
+            self.client.connect_async(broker, port, keepalive=60)
+            self.client.loop_start()
+
+            # Wait briefly for connection
+            await asyncio.sleep(1.0)
+
+            if self.connected:
+                logger.info(f"MQTT relay connected to {broker}:{port}")
+                # Publish online status
+                self._publish_status("online")
+                return True
+            else:
+                logger.warning(f"MQTT relay connection pending to {broker}:{port}")
+                return True  # Connection is async, may succeed later
+
+        except Exception as e:
+            logger.error(f"MQTT relay connection failed: {e}")
+            self.connected = False
+            return False
+
+    def _on_connect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags: dict,
+        reason_code: int | mqtt.ReasonCode,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when connected to broker."""
+        # Handle both MQTTv311 (int) and MQTTv5 (ReasonCode) return codes
+        rc = reason_code if isinstance(reason_code, int) else reason_code.value
+        if rc == 0:
+            self.connected = True
+            logger.info("MQTT relay connected successfully")
+            # Publish online status
+            self._publish_status("online")
+        else:
+            self.connected = False
+            logger.error(f"MQTT relay connection failed: {reason_code}")
+
+    def _on_disconnect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags_or_rc: dict | int | mqtt.ReasonCode,
+        reason_code: int | mqtt.ReasonCode | None = None,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when disconnected from broker."""
+        self.connected = False
+        # Handle both MQTTv311 (rc as 3rd param) and MQTTv5 (flags, rc, props)
+        rc = reason_code if reason_code is not None else flags_or_rc
+        rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
+        if rc_val != 0:
+            logger.warning(f"MQTT relay disconnected: {rc}")
+        else:
+            logger.info("MQTT relay disconnected cleanly")
+
+    async def disconnect(self):
+        """Disconnect from MQTT broker."""
+        if self.client:
+            try:
+                # Publish offline status before disconnecting
+                self._publish_status("offline")
+                self.client.loop_stop()
+                self.client.disconnect()
+            except Exception as e:
+                logger.debug(f"MQTT disconnect error (ignored): {e}")
+            finally:
+                self.client = None
+                self.connected = False
+
+    def _publish_status(self, status: str):
+        """Publish BamBuddy status (online/offline)."""
+        self._publish(
+            f"{self.topic_prefix}/status",
+            {"status": status, "timestamp": datetime.utcnow().isoformat()},
+            retain=True,
+        )
+
+    def _publish(self, topic: str, payload: dict, retain: bool = False):
+        """Publish message to MQTT broker."""
+        if not self.client or not self.connected:
+            return
+
+        try:
+            with self._lock:
+                self.client.publish(topic, json.dumps(payload, default=str), qos=1, retain=retain)
+        except Exception as e:
+            logger.debug(f"MQTT publish error: {e}")
+
+    def get_status(self) -> dict:
+        """Get current MQTT relay status for API."""
+        return {
+            "enabled": self.enabled,
+            "connected": self.connected,
+            "broker": self._broker if self.enabled else "",
+            "port": self._port if self.enabled else 0,
+            "topic_prefix": self.topic_prefix,
+        }
+
+    # =========================================================================
+    # Printer Events
+    # =========================================================================
+
+    async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):
+        """Publish printer status change (throttled to 1 update/sec per printer)."""
+        if not self.enabled or not self.connected:
+            return
+
+        # Throttle status updates to avoid flooding MQTT broker
+        now = time.time()
+        last_publish = self._last_printer_status.get(printer_id, 0)
+        if now - last_publish < self.STATUS_THROTTLE_SECONDS:
+            return  # Skip this update, too soon since last publish
+        self._last_printer_status[printer_id] = now
+
+        # Build status payload from PrinterState
+        payload = {
+            "printer_id": printer_id,
+            "printer_name": printer_name,
+            "printer_serial": printer_serial,
+            "timestamp": datetime.utcnow().isoformat(),
+            "connected": state.connected,
+            "state": state.state,
+            "progress": state.progress,
+            "remaining_time": state.remaining_time,
+            "layer_num": state.layer_num,
+            "total_layers": state.total_layers,
+            "current_print": state.current_print,
+            "subtask_name": state.subtask_name,
+            "gcode_file": state.gcode_file,
+            "temperatures": state.temperatures,
+            "wifi_signal": state.wifi_signal,
+            "chamber_light": state.chamber_light,
+            "speed_level": state.speed_level,
+            "cooling_fan_speed": state.cooling_fan_speed,
+            "big_fan1_speed": state.big_fan1_speed,
+            "big_fan2_speed": state.big_fan2_speed,
+            "heatbreak_fan_speed": state.heatbreak_fan_speed,
+        }
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/status",
+            payload,
+            retain=True,
+        )
+
+    async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
+        """Publish printer came online event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/online",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_printer_offline(self, printer_id: int, printer_name: str, printer_serial: str):
+        """Publish printer went offline event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/offline",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_print_start(
+        self,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+        filename: str,
+        subtask_name: str,
+    ):
+        """Publish print started event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/print/started",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "filename": filename,
+                "subtask_name": subtask_name,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_print_complete(
+        self,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+        filename: str,
+        subtask_name: str,
+        status: str,
+    ):
+        """Publish print completed event."""
+        if not self.enabled or not self.connected:
+            return
+
+        # Determine topic based on status
+        if status == "completed":
+            topic = f"{self.topic_prefix}/printers/{printer_serial}/print/completed"
+        else:
+            topic = f"{self.topic_prefix}/printers/{printer_serial}/print/failed"
+
+        self._publish(
+            topic,
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "filename": filename,
+                "subtask_name": subtask_name,
+                "status": status,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_ams_change(
+        self,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+        ams_data: list,
+    ):
+        """Publish AMS filament change event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/ams/changed",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "ams_units": ams_data,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_printer_error(
+        self,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+        errors: list,
+    ):
+        """Publish printer HMS error event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/error",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "errors": errors,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    # =========================================================================
+    # Print Queue Events
+    # =========================================================================
+
+    async def on_queue_job_added(
+        self,
+        job_id: int,
+        filename: str,
+        printer_id: int | None,
+        printer_name: str | None,
+    ):
+        """Publish job added to queue event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/queue/job_added",
+            {
+                "job_id": job_id,
+                "filename": filename,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_queue_job_started(
+        self,
+        job_id: int,
+        filename: str,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+    ):
+        """Publish queued job started printing event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/queue/job_started",
+            {
+                "job_id": job_id,
+                "filename": filename,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_queue_job_completed(
+        self,
+        job_id: int,
+        filename: str,
+        printer_id: int,
+        printer_name: str,
+        status: str,
+    ):
+        """Publish queued job finished event."""
+        if not self.enabled or not self.connected:
+            return
+
+        topic = (
+            f"{self.topic_prefix}/queue/job_completed"
+            if status == "completed"
+            else f"{self.topic_prefix}/queue/job_failed"
+        )
+
+        self._publish(
+            topic,
+            {
+                "job_id": job_id,
+                "filename": filename,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "status": status,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    # =========================================================================
+    # Maintenance Events
+    # =========================================================================
+
+    async def on_maintenance_alert(
+        self,
+        printer_id: int,
+        printer_name: str,
+        maintenance_type: str,
+        current_value: float,
+        threshold: float,
+    ):
+        """Publish maintenance alert triggered event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/maintenance/alert",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "maintenance_type": maintenance_type,
+                "current_value": current_value,
+                "threshold": threshold,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_maintenance_acknowledged(
+        self,
+        printer_id: int,
+        printer_name: str,
+        maintenance_type: str,
+    ):
+        """Publish maintenance alert acknowledged event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/maintenance/acknowledged",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "maintenance_type": maintenance_type,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_maintenance_reset(
+        self,
+        printer_id: int,
+        printer_name: str,
+        maintenance_type: str,
+    ):
+        """Publish maintenance counter reset event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/maintenance/reset",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "maintenance_type": maintenance_type,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    # =========================================================================
+    # Archive Events
+    # =========================================================================
+
+    async def on_archive_created(
+        self,
+        archive_id: int,
+        print_name: str,
+        printer_name: str,
+        status: str,
+    ):
+        """Publish print archived event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/archive/created",
+            {
+                "archive_id": archive_id,
+                "print_name": print_name,
+                "printer_name": printer_name,
+                "status": status,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_archive_updated(
+        self,
+        archive_id: int,
+        print_name: str,
+        status: str,
+    ):
+        """Publish archive record updated event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/archive/updated",
+            {
+                "archive_id": archive_id,
+                "print_name": print_name,
+                "status": status,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    # =========================================================================
+    # Filament/Spoolman Events
+    # =========================================================================
+
+    async def on_filament_low(
+        self,
+        spool_id: int,
+        spool_name: str,
+        remaining_weight: float,
+        remaining_percent: float,
+    ):
+        """Publish filament inventory low event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/filament/low",
+            {
+                "spool_id": spool_id,
+                "spool_name": spool_name,
+                "remaining_weight": remaining_weight,
+                "remaining_percent": remaining_percent,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    # =========================================================================
+    # Smart Plug Events
+    # =========================================================================
+
+    async def on_smart_plug_state(
+        self,
+        plug_id: int,
+        plug_name: str,
+        state: str,
+        printer_id: int | None = None,
+        printer_name: str | None = None,
+    ):
+        """Publish smart plug state change event."""
+        if not self.enabled or not self.connected:
+            return
+
+        topic = f"{self.topic_prefix}/smart_plugs/on" if state == "on" else f"{self.topic_prefix}/smart_plugs/off"
+
+        self._publish(
+            topic,
+            {
+                "plug_id": plug_id,
+                "plug_name": plug_name,
+                "state": state,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+    async def on_smart_plug_energy(
+        self,
+        plug_id: int,
+        plug_name: str,
+        power: float,
+        energy_today: float,
+        energy_total: float,
+    ):
+        """Publish smart plug energy update event."""
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/smart_plugs/energy",
+            {
+                "plug_id": plug_id,
+                "plug_name": plug_name,
+                "power_watts": power,
+                "energy_today_kwh": energy_today,
+                "energy_total_kwh": energy_total,
+                "timestamp": datetime.utcnow().isoformat(),
+            },
+        )
+
+
+# Global instance
+mqtt_relay = MQTTRelayService()

+ 37 - 3
backend/app/services/print_scheduler.py

@@ -13,7 +13,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
-from backend.app.services.bambu_ftp import 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.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 
@@ -268,12 +268,32 @@ class PrintScheduler:
             return
 
         # Upload file to printer via FTP
-        remote_filename = archive.filename
-        remote_path = f"/cache/{remote_filename}"
+        # Use a clean filename to avoid issues with double extensions like .gcode.3mf
+        base_name = archive.filename
+        if base_name.endswith(".gcode.3mf"):
+            base_name = base_name[:-10]  # Remove .gcode.3mf
+        elif base_name.endswith(".3mf"):
+            base_name = base_name[:-4]  # Remove .3mf
+        remote_filename = f"{base_name}.3mf"
+        # Upload to root directory (not /cache/) - the start_print command references
+        # files by name only (ftp://{filename}), so they must be in the root
+        remote_path = f"/{remote_filename}"
 
         # Get FTP retry settings
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
+        # Delete existing file if present (avoids 553 error on overwrite)
+        try:
+            await delete_file_async(
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                socket_timeout=ftp_timeout,
+                printer_model=printer.model,
+            )
+        except Exception:
+            pass  # File may not exist, that's fine
+
         try:
             if ftp_retry_enabled:
                 uploaded = await with_ftp_retry(
@@ -337,6 +357,20 @@ class PrintScheduler:
             item.started_at = datetime.utcnow()
             await db.commit()
             logger.info(f"Queue item {item.id}: Print started - {archive.filename}")
+
+            # MQTT relay - publish queue job started
+            try:
+                from backend.app.services.mqtt_relay import mqtt_relay
+
+                await mqtt_relay.on_queue_job_started(
+                    job_id=item.id,
+                    filename=archive.filename,
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    printer_serial=printer.serial_number,
+                )
+            except Exception:
+                pass  # Don't fail if MQTT fails
         else:
             item.status = "failed"
             item.error_message = "Failed to send print command"

+ 70 - 3
backend/app/services/printer_manager.py

@@ -7,18 +7,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.models.printer import Printer
 from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name
 
+# Models that have a real chamber temperature sensor
+# Based on Home Assistant Bambu Lab integration
+# P1P/P1S and A1/A1Mini do NOT have chamber temp sensors
+CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
+    [
+        "X1",
+        "X1C",
+        "X1E",  # X1 series
+        "P2S",  # P2 series
+        "H2C",
+        "H2D",
+        "H2DPRO",
+        "H2S",  # H2 series
+    ]
+)
+
+
+def supports_chamber_temp(model: str | None) -> bool:
+    """Check if a printer model has a real chamber temperature sensor.
+
+    P1P, P1S, A1, and A1Mini do NOT have chamber temp sensors.
+    The 'chamber_temper' value they report is meaningless.
+    """
+    if not model:
+        return False
+    # Normalize model name (uppercase, strip whitespace)
+    model_upper = model.strip().upper()
+    return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
+
+
+class PrinterInfo:
+    """Basic printer info for callbacks."""
+
+    def __init__(self, name: str, serial_number: str):
+        self.name = name
+        self.serial_number = serial_number
+
 
 class PrinterManager:
     """Manager for multiple printer connections."""
 
     def __init__(self):
         self._clients: dict[int, BambuMQTTClient] = {}
+        self._models: dict[int, str | None] = {}  # Cache printer models for feature detection
+        self._printer_info: dict[int, PrinterInfo] = {}  # Cache printer name/serial for callbacks
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
 
+    def get_printer(self, printer_id: int) -> PrinterInfo | None:
+        """Get printer info by ID."""
+        return self._printer_info.get(printer_id)
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
         self._loop = loop
@@ -94,6 +137,8 @@ class PrinterManager:
 
         client.connect()
         self._clients[printer_id] = client
+        self._models[printer_id] = printer.model  # Cache model for feature detection
+        self._printer_info[printer_id] = PrinterInfo(printer.name, printer.serial_number)
 
         # Wait a moment for connection
         await asyncio.sleep(1)
@@ -104,6 +149,8 @@ class PrinterManager:
         if printer_id in self._clients:
             self._clients[printer_id].disconnect()
             del self._clients[printer_id]
+        self._models.pop(printer_id, None)  # Clean up model cache
+        self._printer_info.pop(printer_id, None)  # Clean up printer info cache
 
     def disconnect_all(self):
         """Disconnect from all printers."""
@@ -119,6 +166,10 @@ class PrinterManager:
             return client.state
         return None
 
+    def get_model(self, printer_id: int) -> str | None:
+        """Get the cached model for a printer."""
+        return self._models.get(printer_id)
+
     def get_all_statuses(self) -> dict[int, PrinterState]:
         """Get status of all connected printers (checks for stale connections)."""
         result = {}
@@ -353,8 +404,14 @@ def get_derived_status_name(state: PrinterState) -> str | None:
     return None
 
 
-def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
-    """Convert PrinterState to a JSON-serializable dict."""
+def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
+    """Convert PrinterState to a JSON-serializable dict.
+
+    Args:
+        state: The printer state to convert
+        printer_id: Optional printer ID for generating cover URLs
+        model: Optional printer model for filtering unsupported features
+    """
     # Parse AMS data from raw_data
     ams_units = []
     vt_tray = None
@@ -468,6 +525,14 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
     ams_extruder_map = raw_data.get("ams_extruder_map", {})
 
+    # Filter out chamber temp for models that don't have a real sensor
+    # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
+    temperatures = state.temperatures
+    if not supports_chamber_temp(model):
+        temperatures = {
+            k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
+        }
+
     result = {
         "connected": state.connected,
         "state": state.state,
@@ -478,7 +543,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "remaining_time": state.remaining_time,
         "layer_num": state.layer_num,
         "total_layers": state.total_layers,
-        "temperatures": state.temperatures,
+        "temperatures": temperatures,
         "hms_errors": [
             {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
             for e in (state.hms_errors or [])
@@ -504,6 +569,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "big_fan1_speed": state.big_fan1_speed,
         "big_fan2_speed": state.big_fan2_speed,
         "heatbreak_fan_speed": state.heatbreak_fan_speed,
+        # Chamber light state
+        "chamber_light": state.chamber_light,
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE/PAUSED states so skip objects modal can show cover

+ 75 - 33
backend/app/services/spoolman.py

@@ -49,6 +49,7 @@ class AMSTray:
     remain: int  # Remaining percentage (0-100)
     tag_uid: str  # RFID tag UID
     tray_uuid: str  # Spool UUID
+    tray_info_idx: str  # Bambu filament preset ID like "GFA00"
     tray_weight: int  # Spool weight in grams (usually 1000)
 
 
@@ -521,6 +522,9 @@ class SpoolmanClient:
         if tray_uuid in ("", "00000000000000000000000000000000"):
             tray_uuid = ""
 
+        # Get tray_info_idx (Bambu filament preset ID like "GFA00")
+        tray_info_idx = tray_data.get("tray_info_idx", "") or ""
+
         # Get remaining percentage, ensure non-negative
         remain = max(0, int(tray_data.get("remain", 0)))
 
@@ -533,6 +537,7 @@ class SpoolmanClient:
             remain=remain,
             tag_uid=tag_uid,
             tray_uuid=tray_uuid,
+            tray_info_idx=tray_info_idx.strip(),
             tray_weight=int(tray_data.get("tray_weight", 1000)),
         )
 
@@ -552,35 +557,57 @@ class SpoolmanClient:
         ams_letter = chr(ord("A") + ams_id)
         return f"AMS {ams_letter}{tray_id + 1}"
 
-    def is_bambu_lab_spool(self, tray_uuid: str) -> bool:
-        """Check if a tray has a valid Bambu Lab spool UUID.
+    def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
+        """Check if a tray has a valid Bambu Lab spool.
 
-        Bambu Lab spools have a tray_uuid which is a 32-character hex string.
-        This UUID is consistent across all printer models (unlike tag_uid which
-        varies between X1C/H2D readers).
+        Bambu Lab spools can be identified by:
+        1. tray_uuid: 32-character hex string (preferred, consistent across printers)
+        2. tag_uid: 16-character hex string (RFID tag, varies between readers)
+        3. tray_info_idx: Bambu filament preset ID like "GFA00" (most reliable)
 
-        Non-Bambu Lab spools (SpoolEase, third-party) won't have a valid tray_uuid.
+        Non-Bambu Lab spools (SpoolEase, third-party) won't have these identifiers.
 
         Args:
-            tray_uuid: The tray UUID to check
+            tray_uuid: The tray UUID to check (32 hex chars)
+            tag_uid: The RFID tag UID to check as fallback (16 hex chars)
+            tray_info_idx: Bambu filament preset ID like "GFA00", "GFB00"
 
         Returns:
-            True if the spool has a valid Bambu Lab UUID, False otherwise.
+            True if the spool has valid Bambu Lab identifiers, False otherwise.
         """
-        if not tray_uuid:
-            return False
-        # Bambu Lab tray_uuid is always 32 hex characters
-        uuid = tray_uuid.strip()
-        if len(uuid) != 32:
-            return False
-        # Verify it's all hex characters and not empty/zero
-        if uuid == "00000000000000000000000000000000":
-            return False
-        try:
-            int(uuid, 16)
-            return True
-        except ValueError:
-            return False
+        # Check tray_info_idx first - Bambu filament preset IDs like "GFA00", "GFB00", etc.
+        # This is the most reliable indicator as it's set when the spool is recognized
+        if tray_info_idx:
+            idx = tray_info_idx.strip()
+            # Bambu Lab preset IDs start with "GF" followed by letter and digits
+            # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
+            if idx and len(idx) >= 3 and idx.startswith("GF"):
+                logger.debug(f"Identified Bambu Lab spool via tray_info_idx: {idx}")
+                return True
+
+        # Check tray_uuid (preferred - consistent across printer models)
+        if tray_uuid:
+            uuid = tray_uuid.strip()
+            if len(uuid) == 32 and uuid != "00000000000000000000000000000000":
+                try:
+                    int(uuid, 16)
+                    return True
+                except ValueError:
+                    pass
+
+        # Fallback: check tag_uid (RFID tag - varies between printer readers)
+        # Bambu Lab RFID tags are 16 hex characters (8 bytes)
+        if tag_uid:
+            tag = tag_uid.strip()
+            if len(tag) == 16 and tag != "0000000000000000":
+                try:
+                    int(tag, 16)
+                    logger.debug(f"Identified Bambu Lab spool via tag_uid fallback: {tag}")
+                    return True
+                except ValueError:
+                    pass
+
+        return False
 
     def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
         """Calculate remaining weight from percentage.
@@ -612,29 +639,44 @@ class SpoolmanClient:
         """
         logger.debug(
             f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
-            f"type={tray.tray_type}, uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}..."
+            f"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, "
+            f"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, "
+            f"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}..."
         )
 
-        # Only sync trays with valid Bambu Lab tray_uuid
-        if not self.is_bambu_lab_spool(tray.tray_uuid):
-            if tray.tray_uuid or tray.tag_uid:
+        # Only sync trays with valid Bambu Lab identifiers
+        if not self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
+            if tray.tray_uuid or tray.tag_uid or tray.tray_info_idx:
                 logger.info(
                     f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
-                    f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
+                    f"(tray_info_idx={tray.tray_info_idx}, tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
                 )
             else:
                 logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}")
             return None
 
+        # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
+        spool_tag = (
+            tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid
+        )
+
+        # If no unique identifier available, we can't sync even if it's a Bambu Lab spool
+        if not spool_tag:
+            logger.warning(
+                f"Bambu Lab spool detected but no unique identifier for Spoolman: "
+                f"{printer_name} AMS {tray.ams_id} tray {tray.tray_id} (tray_info_idx={tray.tray_info_idx})"
+            )
+            return None
+
         # Calculate remaining weight
         remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
         location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
 
-        # Find existing spool by tray_uuid (stored as "tag" in Spoolman)
-        existing = await self.find_spool_by_tag(tray.tray_uuid)
+        # Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
+        existing = await self.find_spool_by_tag(spool_tag)
         if existing:
             # Update existing spool
-            logger.info(f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}")
+            logger.info(f"Updating existing spool {existing['id']} for tag {spool_tag[:16]}...")
             return await self.update_spool(
                 spool_id=existing["id"],
                 remaining_weight=remaining,
@@ -642,7 +684,7 @@ class SpoolmanClient:
             )
 
         # Spool not found - auto-create it
-        logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tray_uuid: {tray.tray_uuid[:16]}...)")
+        logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tag: {spool_tag[:16]}...)")
 
         # First find or create the filament type
         filament = await self._find_or_create_filament(tray)
@@ -650,7 +692,7 @@ class SpoolmanClient:
             logger.error(f"Failed to find or create filament for {tray.tray_sub_brands}")
             return None
 
-        # Create the spool with tray_uuid stored as "tag" in extra field
+        # Create the spool with identifier stored as "tag" in extra field
         # Note: Spoolman extra field values must be valid JSON, so we encode the string
         import json
 
@@ -659,7 +701,7 @@ class SpoolmanClient:
             remaining_weight=remaining,
             location=location,
             comment="Created by Bambuddy",
-            extra={"tag": json.dumps(tray.tray_uuid)},
+            extra={"tag": json.dumps(spool_tag)},
         )
 
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:

+ 75 - 2
backend/app/services/virtual_printer/manager.py

@@ -290,11 +290,16 @@ class VirtualPrinterManager:
         # Store file reference for MQTT correlation
         self._pending_files[file_path.name] = file_path
 
-        # In immediate mode, archive right away
-        # In queue mode, create pending upload record
+        # Handle based on mode:
+        # - immediate: archive right away
+        # - review: create pending upload record for user review before archiving
+        # - print_queue: archive and add to print queue (unassigned)
         if self._mode == "immediate":
             await self._archive_file(file_path, source_ip)
+        elif self._mode == "print_queue":
+            await self._add_to_print_queue(file_path, source_ip)
         else:
+            # "review" mode (or legacy "queue" mode)
             await self._queue_file(file_path, source_ip)
 
     async def _on_print_command(self, filename: str, data: dict) -> None:
@@ -408,6 +413,74 @@ class VirtualPrinterManager:
         except Exception as e:
             logger.error(f"Error queueing file: {e}")
 
+    async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
+        """Archive file and add to print queue (unassigned).
+
+        Args:
+            file_path: Path to the 3MF file
+            source_ip: IP address of uploader
+        """
+        if not self._session_factory:
+            logger.error("Cannot add to print queue: no database session factory configured")
+            return
+
+        # Only process 3MF files
+        if file_path.suffix.lower() != ".3mf":
+            logger.debug(f"Skipping non-3MF file: {file_path.name}")
+            self._pending_files.pop(file_path.name, None)
+            try:
+                file_path.unlink()
+            except Exception:
+                pass
+            return
+
+        try:
+            from backend.app.models.print_queue import PrintQueueItem
+            from backend.app.services.archive import ArchiveService
+
+            async with self._session_factory() as db:
+                service = ArchiveService(db)
+
+                # First, archive the print
+                archive = await service.archive_print(
+                    printer_id=None,  # No physical printer
+                    source_file=file_path,
+                    print_data={
+                        "status": "archived",
+                        "source": "virtual_printer",
+                        "source_ip": source_ip,
+                    },
+                )
+
+                if archive:
+                    logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}")
+
+                    # Now add to print queue (unassigned)
+                    queue_item = PrintQueueItem(
+                        printer_id=None,  # Unassigned - user will assign later
+                        archive_id=archive.id,
+                        position=1,  # Will be adjusted when assigned to a printer
+                        status="pending",
+                    )
+                    db.add(queue_item)
+                    await db.commit()
+
+                    logger.info(f"Added to print queue (unassigned): queue_id={queue_item.id}, archive_id={archive.id}")
+
+                    # Clean up uploaded file (it's now copied to archive)
+                    try:
+                        file_path.unlink()
+                    except Exception:
+                        pass
+
+                    # Remove from pending
+                    self._pending_files.pop(file_path.name, None)
+                else:
+                    logger.error(f"Failed to archive file: {file_path.name}")
+
+        except Exception as e:
+            logger.error(f"Error adding to print queue: {e}")
+
     def get_status(self) -> dict:
         """Get virtual printer status.
 

+ 8 - 1
backend/tests/conftest.py

@@ -94,8 +94,15 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
 
     app.dependency_overrides[get_db] = override_get_db
 
+    # Mock init_printer_connections to prevent MQTT connection attempts during tests
+    async def mock_init_printer_connections(db):
+        pass  # No-op - don't connect to real printers
+
     # Also patch the module-level async_session used by services
-    with patch("backend.app.core.database.async_session", test_async_session):
+    with (
+        patch("backend.app.core.database.async_session", test_async_session),
+        patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
+    ):
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
 

+ 1 - 0
backend/tests/integration/test_printers_api.py

@@ -525,6 +525,7 @@ class TestSkipObjectsAPI:
         mock_client.state.printable_objects = {}
         mock_client.state.skipped_objects = []
         mock_client.state.state = "IDLE"
+        mock_client.state.subtask_name = None  # Prevent FTP download attempt
 
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client

+ 149 - 0
backend/tests/integration/test_projects_api.py

@@ -113,6 +113,155 @@ class TestProjectsAPI:
         assert response.status_code == 404
 
 
+class TestProjectPartsTracking:
+    """Tests for project parts tracking feature."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {
+                "name": "Parts Test Project",
+                "description": "Test project",
+                "color": "#FF0000",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            defaults = {
+                "filename": "test.3mf",
+                "file_path": "test/test.3mf",
+                "file_size": 1000,
+                "print_name": "Test Print",
+                "status": "completed",
+                "quantity": 1,
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):
+        """Verify project can be created with target_parts_count."""
+        data = {
+            "name": "Parts Project",
+            "target_count": 10,  # 10 plates
+            "target_parts_count": 50,  # 50 parts total
+        }
+        response = await async_client.post("/api/v1/projects/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_count"] == 10
+        assert result["target_parts_count"] == 50
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify target_parts_count can be updated."""
+        project = await project_factory()
+        response = await async_client.patch(
+            f"/api/v1/projects/{project.id}",
+            json={"target_parts_count": 100},
+        )
+        assert response.status_code == 200
+        assert response.json()["target_parts_count"] == 100
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_parts_progress_calculation(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Verify parts progress is calculated from archive quantities."""
+        # Create project with target of 20 parts
+        project = await project_factory(target_parts_count=20)
+
+        # Create archives with different quantities
+        await archive_factory(project_id=project.id, quantity=3, status="completed")  # 3 parts
+        await archive_factory(project_id=project.id, quantity=5, status="completed")  # 5 parts
+        await archive_factory(project_id=project.id, quantity=2, status="completed")  # 2 parts
+        # Total: 10 parts completed out of 20 = 50%
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+
+        # Check stats
+        assert data["stats"]["completed_prints"] == 10  # Sum of quantities
+        assert data["stats"]["parts_progress_percent"] == 50.0  # 10/20 = 50%
+        assert data["stats"]["remaining_parts"] == 10  # 20 - 10 = 10
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_list_shows_parts_count(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Verify project list returns correct completed_count (parts sum)."""
+        project = await project_factory(name="List Parts Project", target_parts_count=100)
+
+        # Create archives with quantities
+        await archive_factory(project_id=project.id, quantity=4, status="completed")
+        await archive_factory(project_id=project.id, quantity=6, status="completed")
+        # Total: 10 parts, 2 plates
+
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        data = response.json()
+
+        # Find our project
+        our_project = next((p for p in data if p["name"] == "List Parts Project"), None)
+        assert our_project is not None
+        assert our_project["archive_count"] == 2  # 2 plates
+        assert our_project["completed_count"] == 10  # 10 parts (sum of quantities)
+        assert our_project["target_parts_count"] == 100
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plates_vs_parts_progress(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Verify plates and parts progress are calculated separately."""
+        # Project needs 5 plates producing 25 parts total (5 parts per plate)
+        project = await project_factory(target_count=5, target_parts_count=25)
+
+        # Complete 2 plates, each with 5 parts
+        await archive_factory(project_id=project.id, quantity=5, status="completed")
+        await archive_factory(project_id=project.id, quantity=5, status="completed")
+        # Plates: 2/5 = 40%, Parts: 10/25 = 40%
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+
+        assert data["stats"]["total_archives"] == 2  # 2 plates
+        assert data["stats"]["completed_prints"] == 10  # 10 parts
+        assert data["stats"]["progress_percent"] == 40.0  # plates: 2/5
+        assert data["stats"]["parts_progress_percent"] == 40.0  # parts: 10/25
+
+
 class TestProjectArchivesAPI:
     """Tests for project-archive relationships."""
 

+ 71 - 0
backend/tests/integration/test_settings_api.py

@@ -214,3 +214,74 @@ class TestSettingsAPI:
         result = response.json()
         assert result["currency"] == "JPY"
         assert result["check_updates"] is False
+
+    # ========================================================================
+    # MQTT settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_settings(self, async_client: AsyncClient):
+        """Verify MQTT settings can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "mqtt_enabled": True,
+                "mqtt_broker": "mqtt.example.com",
+                "mqtt_port": 8883,
+                "mqtt_username": "testuser",
+                "mqtt_password": "testpass",
+                "mqtt_topic_prefix": "myprefix",
+                "mqtt_use_tls": True,
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_enabled"] is True
+        assert result["mqtt_broker"] == "mqtt.example.com"
+        assert result["mqtt_port"] == 8883
+        assert result["mqtt_username"] == "testuser"
+        assert result["mqtt_password"] == "testpass"
+        assert result["mqtt_topic_prefix"] == "myprefix"
+        assert result["mqtt_use_tls"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_mqtt_status_endpoint(self, async_client: AsyncClient):
+        """Verify MQTT status endpoint returns expected fields."""
+        response = await async_client.get("/api/v1/settings/mqtt/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "enabled" in result
+        assert "connected" in result
+        assert "broker" in result
+        assert "port" in result
+        assert "topic_prefix" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_mqtt_defaults(self, async_client: AsyncClient):
+        """Verify MQTT has correct default values."""
+        # Reset MQTT settings to defaults
+        await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "mqtt_enabled": False,
+                "mqtt_broker": "",
+                "mqtt_port": 1883,
+                "mqtt_username": "",
+                "mqtt_password": "",
+                "mqtt_topic_prefix": "bambuddy",
+                "mqtt_use_tls": False,
+            },
+        )
+
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert result["mqtt_enabled"] is False
+        assert result["mqtt_port"] == 1883
+        assert result["mqtt_topic_prefix"] == "bambuddy"
+        assert result["mqtt_use_tls"] is False

+ 21 - 1
backend/tests/integration/test_virtual_printer_api.py

@@ -53,11 +53,31 @@ class TestVirtualPrinterSettingsAPI:
     @pytest.mark.integration
     async def test_update_mode(self, async_client: AsyncClient):
         """Verify mode can be updated."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=review")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mode"] == "review"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mode_to_print_queue(self, async_client: AsyncClient):
+        """Verify mode can be set to print_queue."""
+        response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mode"] == "print_queue"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mode_legacy_queue_maps_to_review(self, async_client: AsyncClient):
+        """Verify legacy 'queue' mode is normalized to 'review'."""
         response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
 
         assert response.status_code == 200
         result = response.json()
-        assert result["mode"] == "queue"
+        assert result["mode"] == "review"  # Legacy queue maps to review
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 78 - 0
backend/tests/unit/services/test_archive_service.py

@@ -214,3 +214,81 @@ class TestArchiveThumbnails:
         ]
         for path in expected_thumbnail_paths:
             assert "png" in path.lower()
+
+
+class TestPrintableObjectsExtraction:
+    """Tests for extracting printable objects count from 3MF files."""
+
+    def test_extract_printable_objects_from_slice_info(self):
+        """Test parsing printable objects from slice_info.config XML."""
+        from xml.etree import ElementTree as ET
+
+        # Example slice_info.config content with 4 objects
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate plate_idx="1">
+                <metadata key="prediction" value="3600" />
+                <metadata key="weight" value="50.5" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+                <object identify_id="2" name="Part_B" skipped="false" />
+                <object identify_id="3" name="Part_C" skipped="false" />
+                <object identify_id="4" name="Part_D" skipped="true" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        # Count non-skipped objects (should be 3, not 4)
+        count = 0
+        for obj in plate.findall("object"):
+            skipped = obj.get("skipped", "false")
+            if skipped.lower() != "true":
+                count += 1
+
+        assert count == 3  # 3 objects (Part_D is skipped)
+
+    def test_extract_printable_objects_empty_plate(self):
+        """Test handling plate with no objects."""
+        from xml.etree import ElementTree as ET
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate plate_idx="1">
+                <metadata key="prediction" value="0" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        count = 0
+        for obj in plate.findall("object"):
+            skipped = obj.get("skipped", "false")
+            if skipped.lower() != "true":
+                count += 1
+
+        assert count == 0
+
+    def test_extract_printable_objects_all_skipped(self):
+        """Test handling plate where all objects are skipped."""
+        from xml.etree import ElementTree as ET
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate plate_idx="1">
+                <object identify_id="1" name="Part_A" skipped="true" />
+                <object identify_id="2" name="Part_B" skipped="true" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        count = 0
+        for obj in plate.findall("object"):
+            skipped = obj.get("skipped", "false")
+            if skipped.lower() != "true":
+                count += 1
+
+        assert count == 0  # All objects skipped

+ 95 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -14,6 +14,7 @@ from backend.app.services.printer_manager import (
     get_derived_status_name,
     init_printer_connections,
     printer_state_to_dict,
+    supports_chamber_temp,
 )
 
 
@@ -744,6 +745,100 @@ class TestPrinterStateToDict:
 
         assert result["ams"][0]["is_ams_ht"] is False
 
+    def test_chamber_temp_filtered_for_p1s(self, mock_state):
+        """Verify chamber temperature is filtered out for P1S (no chamber sensor)."""
+        mock_state.temperatures = {
+            "nozzle": 200,
+            "bed": 60,
+            "chamber": 5,
+            "chamber_target": 0,
+            "chamber_heating": False,
+        }
+
+        result = printer_state_to_dict(mock_state, model="P1S")
+
+        assert "chamber" not in result["temperatures"]
+        assert "chamber_target" not in result["temperatures"]
+        assert "chamber_heating" not in result["temperatures"]
+        assert result["temperatures"]["nozzle"] == 200
+        assert result["temperatures"]["bed"] == 60
+
+    def test_chamber_temp_kept_for_x1c(self, mock_state):
+        """Verify chamber temperature is kept for X1C (has chamber sensor)."""
+        mock_state.temperatures = {
+            "nozzle": 200,
+            "bed": 60,
+            "chamber": 25,
+            "chamber_target": 45,
+            "chamber_heating": True,
+        }
+
+        result = printer_state_to_dict(mock_state, model="X1C")
+
+        assert result["temperatures"]["chamber"] == 25
+        assert result["temperatures"]["chamber_target"] == 45
+        assert result["temperatures"]["chamber_heating"] is True
+
+    def test_chamber_temp_filtered_for_a1(self, mock_state):
+        """Verify chamber temperature is filtered out for A1 (no chamber sensor)."""
+        mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 5}
+
+        result = printer_state_to_dict(mock_state, model="A1")
+
+        assert "chamber" not in result["temperatures"]
+
+    def test_chamber_temp_kept_when_no_model(self, mock_state):
+        """Verify chamber temperature is kept when model is not specified (conservative approach)."""
+        mock_state.temperatures = {"nozzle": 200, "bed": 60, "chamber": 25}
+
+        result = printer_state_to_dict(mock_state)  # No model specified
+
+        # When model is unknown, we can't filter - leave as is
+        # Actually supports_chamber_temp returns False for None, so it will filter
+        # Let's check the actual behavior
+        assert "chamber" not in result["temperatures"]
+
+
+class TestSupportsChamberTemp:
+    """Tests for supports_chamber_temp helper function."""
+
+    def test_x1_series_supported(self):
+        """Verify X1 series printers support chamber temp."""
+        assert supports_chamber_temp("X1") is True
+        assert supports_chamber_temp("X1C") is True
+        assert supports_chamber_temp("X1E") is True
+
+    def test_p2_series_supported(self):
+        """Verify P2 series printers support chamber temp."""
+        assert supports_chamber_temp("P2S") is True
+
+    def test_h2_series_supported(self):
+        """Verify H2 series printers support chamber temp."""
+        assert supports_chamber_temp("H2C") is True
+        assert supports_chamber_temp("H2D") is True
+        assert supports_chamber_temp("H2DPRO") is True
+        assert supports_chamber_temp("H2S") is True
+
+    def test_p1_series_not_supported(self):
+        """Verify P1 series printers do NOT support chamber temp."""
+        assert supports_chamber_temp("P1P") is False
+        assert supports_chamber_temp("P1S") is False
+
+    def test_a1_series_not_supported(self):
+        """Verify A1 series printers do NOT support chamber temp."""
+        assert supports_chamber_temp("A1") is False
+        assert supports_chamber_temp("A1MINI") is False
+
+    def test_none_model_not_supported(self):
+        """Verify None model returns False."""
+        assert supports_chamber_temp(None) is False
+
+    def test_case_insensitive(self):
+        """Verify model matching is case-insensitive."""
+        assert supports_chamber_temp("x1c") is True
+        assert supports_chamber_temp("X1c") is True
+        assert supports_chamber_temp("p1s") is False
+
 
 class TestGetDerivedStatusName:
     """Tests for get_derived_status_name function."""

+ 2 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v22';
-const STATIC_CACHE = 'bambuddy-static-v22';
+const CACHE_NAME = 'bambuddy-v23';
+const STATIC_CACHE = 'bambuddy-static-v23';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

+ 61 - 19
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -99,11 +99,11 @@ describe('VirtualPrinterSettings', () => {
       });
     });
 
-    it('renders archive mode section', async () => {
+    it('renders mode section', async () => {
       render(<VirtualPrinterSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Archive Mode')).toBeInTheDocument();
+        expect(screen.getByText('Mode')).toBeInTheDocument();
       });
     });
 
@@ -259,28 +259,48 @@ describe('VirtualPrinterSettings', () => {
     });
   });
 
-  describe('archive mode', () => {
-    it('renders immediate mode option', async () => {
+  describe('mode selection', () => {
+    it('renders Archive mode option', async () => {
       render(<VirtualPrinterSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Immediate')).toBeInTheDocument();
-        expect(
-          screen.getByText('Archive files as soon as they are uploaded')
-        ).toBeInTheDocument();
+        expect(screen.getByText('Archive')).toBeInTheDocument();
+        expect(screen.getByText('Archive files immediately')).toBeInTheDocument();
       });
     });
 
-    it('renders queue mode option', async () => {
+    it('renders Review mode option', async () => {
       render(<VirtualPrinterSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Queue for Review')).toBeInTheDocument();
-        expect(screen.getByText('Review and tag files before archiving')).toBeInTheDocument();
+        expect(screen.getByText('Review')).toBeInTheDocument();
+        expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument();
       });
     });
 
-    it('highlights current mode', async () => {
+    it('renders Queue mode option', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+        expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument();
+      });
+    });
+
+    it('highlights current mode (review)', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ mode: 'review' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        const reviewButton = screen.getByText('Review').closest('button');
+        expect(reviewButton?.className).toContain('border-bambu-green');
+      });
+    });
+
+    it('highlights current mode (legacy queue maps to review)', async () => {
       vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
         createMockSettings({ mode: 'queue' })
       );
@@ -288,30 +308,52 @@ describe('VirtualPrinterSettings', () => {
       render(<VirtualPrinterSettings />);
 
       await waitFor(() => {
-        const queueButton = screen.getByText('Queue for Review').closest('button');
-        expect(queueButton?.className).toContain('border-bambu-green');
+        const reviewButton = screen.getByText('Review').closest('button');
+        expect(reviewButton?.className).toContain('border-bambu-green');
       });
     });
 
-    it('changes mode on click', async () => {
+    it('changes mode to review on click', async () => {
       const user = userEvent.setup();
       vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
-        createMockSettings({ mode: 'queue' })
+        createMockSettings({ mode: 'review' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Review')).toBeInTheDocument();
+      });
+
+      const reviewButton = screen.getByText('Review').closest('button');
+      if (reviewButton) {
+        await user.click(reviewButton);
+      }
+
+      await waitFor(() => {
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' });
+      });
+    });
+
+    it('changes mode to print_queue on click', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+        createMockSettings({ mode: 'print_queue' })
       );
 
       render(<VirtualPrinterSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Queue for Review')).toBeInTheDocument();
+        expect(screen.getByText('Queue')).toBeInTheDocument();
       });
 
-      const queueButton = screen.getByText('Queue for Review').closest('button');
+      const queueButton = screen.getByText('Queue').closest('button');
       if (queueButton) {
         await user.click(queueButton);
       }
 
       await waitFor(() => {
-        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'print_queue' });
       });
     });
   });

+ 48 - 14
frontend/src/api/client.ts

@@ -341,17 +341,19 @@ export interface SimilarArchive {
 export interface ProjectStats {
   total_archives: number;
   total_items: number;  // Sum of quantities (total items printed)
-  completed_prints: number;
+  completed_prints: number;  // Sum of quantities for completed prints (parts)
   failed_prints: number;
   queued_prints: number;
   in_progress_prints: number;
   total_print_time_hours: number;
   total_filament_grams: number;
-  progress_percent: number | null;
+  progress_percent: number | null;  // Plates progress (total_archives / target_count)
+  parts_progress_percent: number | null;  // Parts progress (completed_prints / target_parts_count)
   estimated_cost: number;
   total_energy_kwh: number;
   total_energy_cost: number;
-  remaining_prints: number | null;
+  remaining_prints: number | null;  // Remaining plates
+  remaining_parts: number | null;  // Remaining parts
   bom_total_items: number;
   bom_completed_items: number;
 }
@@ -370,7 +372,8 @@ export interface Project {
   description: string | null;
   color: string | null;
   status: string;  // active, completed, archived
-  target_count: number | null;
+  target_count: number | null;  // Target number of plates/print jobs
+  target_parts_count: number | null;  // Target number of parts/objects
   notes: string | null;
   attachments: ProjectAttachment[] | null;
   tags: string | null;
@@ -409,12 +412,15 @@ export interface ProjectListItem {
   description: string | null;
   color: string | null;
   status: string;
-  target_count: number | null;
+  target_count: number | null;  // Target number of plates/print jobs
+  target_parts_count: number | null;  // Target number of parts/objects
   created_at: string;
-  archive_count: number;  // Number of print jobs
-  total_items: number;  // Sum of quantities (total items printed)
+  archive_count: number;  // Number of print jobs (plates)
+  total_items: number;  // Sum of quantities (total items printed, including failed)
+  completed_count: number;  // Sum of quantities for completed prints only (parts)
+  failed_count: number;  // Sum of quantities for failed prints
   queue_count: number;
-  progress_percent: number | null;
+  progress_percent: number | null;  // Plates progress
   archives: ArchivePreview[];
 }
 
@@ -423,6 +429,7 @@ export interface ProjectCreate {
   description?: string;
   color?: string;
   target_count?: number;
+  target_parts_count?: number;
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -437,6 +444,7 @@ export interface ProjectUpdate {
   color?: string;
   status?: string;
   target_count?: number;
+  target_parts_count?: number;
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -569,10 +577,27 @@ export interface AppSettings {
   ftp_retry_count: number;
   ftp_retry_delay: number;
   ftp_timeout: number;
+  // MQTT relay settings
+  mqtt_enabled: boolean;
+  mqtt_broker: string;
+  mqtt_port: number;
+  mqtt_username: string;
+  mqtt_password: string;
+  mqtt_topic_prefix: string;
+  mqtt_use_tls: boolean;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 
+// MQTT relay status
+export interface MQTTStatus {
+  enabled: boolean;
+  connected: boolean;
+  broker: string;
+  port: number;
+  topic_prefix: string;
+}
+
 // Cloud types
 export interface CloudAuthStatus {
   is_authenticated: boolean;
@@ -789,7 +814,7 @@ export interface DiscoveredTasmotaDevice {
 // Print Queue types
 export interface PrintQueueItem {
   id: number;
-  printer_id: number;
+  printer_id: number | null;  // null = unassigned
   archive_id: number;
   position: number;
   scheduled_time: string | null;
@@ -809,7 +834,7 @@ export interface PrintQueueItem {
 }
 
 export interface PrintQueueItemCreate {
-  printer_id: number;
+  printer_id?: number | null;  // null = unassigned
   archive_id: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
@@ -819,7 +844,7 @@ export interface PrintQueueItemCreate {
 }
 
 export interface PrintQueueItemUpdate {
-  printer_id?: number;
+  printer_id?: number | null;  // null = unassign
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
@@ -1783,6 +1808,7 @@ export const api = {
       method: 'PUT',
       body: JSON.stringify(data),
     }),
+  getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
   exportBackup: async (categories?: Record<string, boolean>): Promise<{ blob: Blob; filename: string }> => {
@@ -1798,11 +1824,19 @@ export const api = {
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
       if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
+      if (categories.pending_uploads !== undefined) params.set('include_pending_uploads', String(categories.pending_uploads));
       if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
+      if (categories.api_keys !== undefined) params.set('include_api_keys', String(categories.api_keys));
     }
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
     const response = await fetch(url);
 
+    // Check for errors
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(errorText || `Backup failed with status ${response.status}`);
+    }
+
     // Get filename from Content-Disposition header
     const contentDisposition = response.headers.get('Content-Disposition');
     let filename = 'bambuddy-backup.json';
@@ -2482,7 +2516,7 @@ export const discoveryApi = {
 export interface VirtualPrinterStatus {
   enabled: boolean;
   running: boolean;
-  mode: 'immediate' | 'queue';
+  mode: 'immediate' | 'queue' | 'review' | 'print_queue';  // 'queue' is legacy, normalized to 'review'
   name: string;
   serial: string;
   model: string;
@@ -2493,7 +2527,7 @@ export interface VirtualPrinterStatus {
 export interface VirtualPrinterSettings {
   enabled: boolean;
   access_code_set: boolean;
-  mode: 'immediate' | 'queue';
+  mode: 'immediate' | 'queue' | 'review' | 'print_queue';  // 'queue' is legacy, normalized to 'review'
   model: string;
   status: VirtualPrinterStatus;
 }
@@ -2524,7 +2558,7 @@ export const virtualPrinterApi = {
   updateSettings: (data: {
     enabled?: boolean;
     access_code?: string;
-    mode?: 'immediate' | 'queue';
+    mode?: 'immediate' | 'review' | 'print_queue';
     model?: string;
   }) => {
     const params = new URLSearchParams();

+ 8 - 0
frontend/src/components/BackupModal.tsx

@@ -103,6 +103,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     description: 'Virtual printer uploads awaiting review',
   },
+  {
+    id: 'api_keys',
+    labelKey: 'backup.categories.apiKeys',
+    defaultLabel: 'API Keys',
+    icon: <Key className="w-4 h-4" />,
+    default: false,
+    description: 'Webhook API keys (new keys generated on import)',
+  },
 ];
 
 interface BackupModalProps {

+ 24 - 14
frontend/src/components/EditQueueItemModal.tsx

@@ -17,7 +17,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
-  const [printerId, setPrinterId] = useState<number>(item.printer_id);
+  const [printerId, setPrinterId] = useState<number | null>(item.printer_id);
 
   // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
   const isPlaceholderDate = item.scheduled_time &&
@@ -69,8 +69,8 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
   // Fetch printer status when a printer is selected
   const { data: printerStatus } = useQuery({
     queryKey: ['printer-status', printerId],
-    queryFn: () => api.getPrinterStatus(printerId),
-    enabled: !!printerId,
+    queryFn: () => api.getPrinterStatus(printerId!),
+    enabled: printerId !== null,
   });
 
   // Clear manual mappings when printer changes (but not on initial load)
@@ -370,21 +370,31 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
                   No printers configured
                 </div>
               ) : (
-                <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={printerId}
-                  onChange={(e) => setPrinterId(Number(e.target.value))}
-                  required
-                >
-                  {printers?.map((p) => (
-                    <option key={p.id} value={p.id}>{p.name}</option>
-                  ))}
-                </select>
+                <>
+                  <select
+                    className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
+                      printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
+                    }`}
+                    value={printerId ?? ''}
+                    onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+                  >
+                    <option value="">-- Select a printer --</option>
+                    {printers?.map((p) => (
+                      <option key={p.id} value={p.id}>{p.name}</option>
+                    ))}
+                  </select>
+                  {printerId === null && (
+                    <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
+                      <AlertCircle className="w-3 h-3" />
+                      Assign a printer to enable printing
+                    </p>
+                  )}
+                </>
               )}
             </div>
 
             {/* Filament Mapping Section */}
-            {printerId && hasFilamentReqs && (
+            {printerId !== null && hasFilamentReqs && (
               <div>
                 <button
                   type="button"

+ 36 - 2
frontend/src/components/Layout.tsx

@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
-import { api, supportApi } from '../api/client';
+import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 
@@ -126,6 +126,26 @@ export function Layout() {
     refetchInterval: 60 * 1000, // Refresh every minute
   });
 
+  // Fetch pending queue items count for badge
+  const { data: queueItems } = useQuery({
+    queryKey: ['queue', 'pending'],
+    queryFn: () => api.getQueue(undefined, 'pending'),
+    staleTime: 5 * 1000, // 5 seconds
+    refetchInterval: 5 * 1000, // Refresh every 5 seconds
+    refetchOnWindowFocus: true,
+  });
+  const pendingQueueCount = queueItems?.length ?? 0;
+
+  // Fetch pending uploads count for archive badge (virtual printer review items)
+  const { data: pendingUploadsData } = useQuery({
+    queryKey: ['pending-uploads', 'count'],
+    queryFn: pendingUploadsApi.getCount,
+    staleTime: 5 * 1000, // 5 seconds
+    refetchInterval: 5 * 1000, // Refresh every 5 seconds
+    refetchOnWindowFocus: true,
+  });
+  const pendingUploadsCount = pendingUploadsData?.count ?? 0;
+
   // Calculate debug duration client-side for real-time updates
   const [debugDuration, setDebugDuration] = useState<number | null>(null);
   useEffect(() => {
@@ -418,6 +438,11 @@ export function Layout() {
                 if (!navItem) return null;
 
                 const { to, icon: Icon, labelKey } = navItem;
+                const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
+                const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
+                const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
+                const showBadge = showQueueBadge || showArchiveBadge;
+
                 return (
                   <li
                     key={id}
@@ -449,7 +474,16 @@ export function Layout() {
                       {sidebarExpanded && !isMobile && (
                         <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                       )}
-                      <Icon className="w-5 h-5 flex-shrink-0" />
+                      <div className="relative">
+                        <Icon className="w-5 h-5 flex-shrink-0" />
+                        {showBadge && (
+                          <span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${
+                            showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
+                          }`}>
+                            {badgeCount > 99 ? '99+' : badgeCount}
+                          </span>
+                        )}
+                      </div>
                       {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
                     </NavLink>
                   </li>

+ 55 - 7
frontend/src/components/RestoreModal.tsx

@@ -12,6 +12,7 @@ interface RestoreResult {
   skipped_details?: Record<string, string[]>;
   files_restored?: number;
   total_skipped?: number;
+  new_api_keys?: Array<{ name: string; key: string; key_prefix: string }>;
 }
 
 interface RestoreModalProps {
@@ -34,6 +35,7 @@ const CATEGORY_LABELS: Record<string, string> = {
   projects: 'Projects',
   pending_uploads: 'Pending Uploads',
   external_links: 'External Links',
+  api_keys: 'API Keys',
 };
 
 export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
@@ -46,11 +48,17 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
 
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape' && state !== 'restoring') onClose();
+      if (e.key === 'Escape' && state !== 'restoring') {
+        // Use handleClose for result state to trigger onSuccess
+        if (state === 'result' && result?.success) {
+          onSuccess();
+        }
+        onClose();
+      }
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose, state]);
+  }, [onClose, onSuccess, state, result]);
 
   const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
     const file = e.target.files?.[0];
@@ -67,9 +75,8 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
       const restoreResult = await onRestore(selectedFile, overwrite);
       setResult(restoreResult);
       setState('result');
-      if (restoreResult.success) {
-        onSuccess();
-      }
+      // Don't call onSuccess here - wait until modal closes
+      // This prevents race condition with query cache
     } catch {
       setResult({
         success: false,
@@ -79,6 +86,14 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
     }
   };
 
+  const handleClose = () => {
+    // If restore was successful, trigger refresh before closing
+    if (result?.success) {
+      onSuccess();
+    }
+    onClose();
+  };
+
   const toggleCategory = (category: string) => {
     setExpandedCategories(prev => {
       const next = new Set(prev);
@@ -141,7 +156,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
             </div>
             {state !== 'restoring' && (
               <button
-                onClick={onClose}
+                onClick={handleClose}
                 className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
               >
                 <X className="w-5 h-5" />
@@ -352,6 +367,39 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
                   </div>
                 )}
 
+                {/* Newly Generated API Keys */}
+                {result.new_api_keys && result.new_api_keys.length > 0 && (
+                  <div className="space-y-2">
+                    <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                      <AlertTriangle className="w-4 h-4 text-orange-500" />
+                      New API Keys Generated
+                    </h4>
+                    <div className="p-3 rounded bg-orange-500/10 border border-orange-500/30">
+                      <p className="text-xs text-orange-200 mb-2">
+                        These keys are only shown once. Copy them now!
+                      </p>
+                      <div className="space-y-2">
+                        {result.new_api_keys.map((apiKey: { name: string; key: string; key_prefix: string }, i: number) => (
+                          <div key={i} className="p-2 rounded bg-bambu-dark">
+                            <div className="text-sm text-white font-medium mb-1">{apiKey.name}</div>
+                            <div className="flex items-center gap-2">
+                              <code className="text-xs text-bambu-green bg-bambu-dark-tertiary px-2 py-1 rounded font-mono flex-1 break-all">
+                                {apiKey.key}
+                              </code>
+                              <button
+                                onClick={() => navigator.clipboard.writeText(apiKey.key)}
+                                className="text-xs text-bambu-gray hover:text-white px-2 py-1 rounded bg-bambu-dark-tertiary"
+                              >
+                                Copy
+                              </button>
+                            </div>
+                          </div>
+                        ))}
+                      </div>
+                    </div>
+                  </div>
+                )}
+
                 {totalRestored === 0 && totalSkipped === 0 && (
                   <div className="p-4 text-center text-bambu-gray">
                     No data was found to restore in the backup file.
@@ -361,7 +409,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
 
               {/* Footer */}
               <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
-                <Button onClick={onClose}>
+                <Button onClick={handleClose}>
                   Close
                 </Button>
               </div>

+ 33 - 14
frontend/src/components/VirtualPrinterSettings.tsx

@@ -12,7 +12,7 @@ export function VirtualPrinterSettings() {
 
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
+  const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
@@ -34,14 +34,19 @@ export function VirtualPrinterSettings() {
   useEffect(() => {
     if (settings) {
       setLocalEnabled(settings.enabled);
-      setLocalMode(settings.mode);
+      // Map legacy 'queue' mode to 'review'
+      let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
+      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
+        mode = 'immediate'; // fallback
+      }
+      setLocalMode(mode);
       setLocalModel(settings.model);
     }
   }, [settings]);
 
   // Update mutation
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue'; model?: string }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -53,7 +58,9 @@ export function VirtualPrinterSettings() {
       // Revert local state on error
       if (settings) {
         setLocalEnabled(settings.enabled);
-        setLocalMode(settings.mode);
+        // Map legacy 'queue' mode to 'review'
+        const mode = settings.mode === 'queue' ? 'review' : settings.mode;
+        setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
         setLocalModel(settings.model);
       }
       setPendingAction(null);
@@ -96,7 +103,7 @@ export function VirtualPrinterSettings() {
     setLocalAccessCode(''); // Clear after saving
   };
 
-  const handleModeChange = (mode: 'immediate' | 'queue') => {
+  const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
     setLocalMode(mode);
     setPendingAction('mode');
     updateMutation.mutate({ mode });
@@ -251,10 +258,10 @@ export function VirtualPrinterSettings() {
             </p>
           </div>
 
-          {/* Archive Mode */}
+          {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Archive Mode</div>
-            <div className="grid grid-cols-2 gap-3">
+            <div className="text-white font-medium mb-2">Mode</div>
+            <div className="grid grid-cols-3 gap-3">
               <button
                 onClick={() => handleModeChange('immediate')}
                 disabled={pendingAction === 'mode'}
@@ -264,20 +271,32 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Immediate</div>
-                <div className="text-xs text-bambu-gray">Archive files as soon as they are uploaded</div>
+                <div className="text-white font-medium">Archive</div>
+                <div className="text-xs text-bambu-gray">Archive files immediately</div>
+              </button>
+              <button
+                onClick={() => handleModeChange('review')}
+                disabled={pendingAction === 'mode'}
+                className={`p-3 rounded-lg border text-left transition-colors ${
+                  localMode === 'review'
+                    ? 'border-bambu-green bg-bambu-green/10'
+                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                <div className="text-white font-medium">Review</div>
+                <div className="text-xs text-bambu-gray">Review and tag before archiving</div>
               </button>
               <button
-                onClick={() => handleModeChange('queue')}
+                onClick={() => handleModeChange('print_queue')}
                 disabled={pendingAction === 'mode'}
                 className={`p-3 rounded-lg border text-left transition-colors ${
-                  localMode === 'queue'
+                  localMode === 'print_queue'
                     ? 'border-bambu-green bg-bambu-green/10'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Queue for Review</div>
-                <div className="text-xs text-bambu-gray">Review and tag files before archiving</div>
+                <div className="text-white font-medium">Queue</div>
+                <div className="text-xs text-bambu-gray">Archive and add to print queue</div>
               </button>
             </div>
           </div>

+ 32 - 3
frontend/src/pages/PrintersPage.tsx

@@ -703,6 +703,35 @@ function getWifiStrength(rssi: number | null | undefined): { label: string; colo
   return { label: 'Very weak', color: 'text-red-400', bars: 1 };
 }
 
+/**
+ * Check if a tray contains a Bambu Lab spool.
+ * Uses same logic as backend: tray_info_idx (GF*), tray_uuid, or tag_uid.
+ */
+function isBambuLabSpool(tray: {
+  tray_uuid?: string | null;
+  tag_uid?: string | null;
+  tray_info_idx?: string | null;
+} | null | undefined): boolean {
+  if (!tray) return false;
+
+  // Check tray_info_idx first (most reliable - Bambu preset IDs start with "GF")
+  if (tray.tray_info_idx && tray.tray_info_idx.startsWith('GF')) {
+    return true;
+  }
+
+  // Check tray_uuid (32 hex chars, non-zero)
+  if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') {
+    return true;
+  }
+
+  // Check tag_uid (16 hex chars, non-zero)
+  if (tray.tag_uid && tray.tag_uid !== '0000000000000000') {
+    return true;
+  }
+
+  return false;
+}
+
 function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
   const [loaded, setLoaded] = useState(false);
   const [error, setError] = useState(false);
@@ -1921,7 +1950,7 @@ function PrinterCard({
 
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
-                                  vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                                  vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
@@ -2068,7 +2097,7 @@ function PrinterCard({
 
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
-                          vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                          vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
@@ -2242,7 +2271,7 @@ function PrinterCard({
 
                         // Build filament data for hover card
                         const extFilamentData = {
-                          vendor: (extTray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                          vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,

+ 89 - 47
frontend/src/pages/ProjectDetailPage.tsx

@@ -79,18 +79,20 @@ function StatCard({
   label,
   value,
   subValue,
+  hint,
   color = 'text-bambu-gray',
 }: {
   icon: React.ElementType;
   label: string;
   value: string | number;
   subValue?: string;
+  hint?: string;
   color?: string;
 }) {
   return (
     <Card>
       <CardContent className="p-4">
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-3" title={hint}>
           <div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>
             <Icon className="w-5 h-5" />
           </div>
@@ -434,10 +436,10 @@ export function ProjectDetailPage() {
   }
 
   const stats = project.stats;
-  const progressPercent = stats?.progress_percent ?? 0;
-  const successRate = stats && stats.total_items > 0
-    ? ((stats.completed_prints / stats.total_items) * 100).toFixed(0)
-    : null;
+  // Plates progress: total_archives / target_count
+  const platesProgressPercent = stats?.progress_percent ?? 0;
+  // Parts progress: completed_prints / target_parts_count
+  const partsProgressPercent = stats?.parts_progress_percent ?? 0;
 
   return (
     <div className="p-4 md:p-8 space-y-8">
@@ -479,35 +481,70 @@ export function ProjectDetailPage() {
         </Button>
       </div>
 
-      {/* Progress bar (if target set) */}
-      {project.target_count && (
+      {/* Progress bars (if targets set) */}
+      {(project.target_count || project.target_parts_count) && (
         <Card>
-          <CardContent className="p-4">
-            <div className="flex items-center justify-between mb-2">
-              <span className="text-sm text-bambu-gray">Progress</span>
-              <span className="text-sm font-medium text-white">
-                {stats?.completed_prints || 0} / {project.target_count} items
-              </span>
-            </div>
-            <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
-              <div
-                className="h-full transition-all duration-500"
-                style={{
-                  width: `${Math.min(progressPercent, 100)}%`,
-                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
-                }}
-              />
-            </div>
-            <div className="flex justify-between mt-1">
-              <span className="text-xs text-bambu-gray/70">
-                {progressPercent.toFixed(0)}% complete
-              </span>
-              {project.target_count - (stats?.completed_prints || 0) > 0 && (
-                <span className="text-xs text-bambu-gray/70">
-                  {project.target_count - (stats?.completed_prints || 0)} remaining
-                </span>
-              )}
-            </div>
+          <CardContent className="p-4 space-y-4">
+            {/* Plates progress */}
+            {project.target_count && (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">Plates Progress</span>
+                  <span className="text-sm font-medium text-white">
+                    {stats?.total_archives || 0} / {project.target_count} print jobs
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(platesProgressPercent, 100)}%`,
+                      backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <div className="flex justify-between mt-1">
+                  <span className="text-xs text-bambu-gray/70">
+                    {platesProgressPercent.toFixed(0)}% complete
+                  </span>
+                  {stats?.remaining_prints != null && stats.remaining_prints > 0 && (
+                    <span className="text-xs text-bambu-gray/70">
+                      {stats.remaining_prints} remaining
+                    </span>
+                  )}
+                </div>
+              </div>
+            )}
+            {/* Parts progress */}
+            {project.target_parts_count && (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">Parts Progress</span>
+                  <span className="text-sm font-medium text-white">
+                    {stats?.completed_prints || 0} / {project.target_parts_count} parts
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(partsProgressPercent, 100)}%`,
+                      backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <div className="flex justify-between mt-1">
+                  <span className="text-xs text-bambu-gray/70">
+                    {partsProgressPercent.toFixed(0)}% complete
+                  </span>
+                  {stats?.remaining_parts != null && stats.remaining_parts > 0 && (
+                    <span className="text-xs text-bambu-gray/70">
+                      {stats.remaining_parts} remaining
+                    </span>
+                  )}
+                </div>
+              </div>
+            )}
           </CardContent>
         </Card>
       )}
@@ -515,20 +552,23 @@ export function ProjectDetailPage() {
       {/* Stats grid */}
       {stats && (
         <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
-          <StatCard
-            icon={Package}
-            label="Total Items"
-            value={stats.total_items}
-            subValue={`${stats.total_archives} print job${stats.total_archives !== 1 ? 's' : ''}`}
-            color="text-bambu-green"
-          />
-          <StatCard
-            icon={CheckCircle}
-            label="Completed"
-            value={stats.completed_prints}
-            subValue={stats.failed_prints > 0 ? `${stats.failed_prints} failed` : (successRate ? `${successRate}% success` : undefined)}
-            color="text-blue-400"
-          />
+          <Card>
+            <CardContent className="p-4">
+              <div className="flex items-center gap-3">
+                <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
+                  <Package className="w-5 h-5" />
+                </div>
+                <div>
+                  <p className="text-sm text-bambu-gray">Print Jobs</p>
+                  <p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
+                  {stats.failed_prints > 0 && (
+                    <p className="text-sm text-red-400">{stats.failed_prints} failed</p>
+                  )}
+                  <p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
           <StatCard
             icon={Clock}
             label="Print Time"
@@ -1145,6 +1185,8 @@ export function ProjectDetailPage() {
             ...project,
             archive_count: stats?.total_archives || 0,
             total_items: stats?.total_items || 0,
+            completed_count: stats?.completed_prints || 0,
+            failed_count: stats?.failed_prints || 0,
             queue_count: stats?.queued_prints || 0,
             progress_percent: stats?.progress_percent || null,
             archives: [],

+ 136 - 51
frontend/src/pages/ProjectsPage.tsx

@@ -10,6 +10,7 @@ import {
   Archive,
   ListTodo,
   Package,
+  Layers,
   Clock,
   CheckCircle2,
   AlertTriangle,
@@ -46,6 +47,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
+  const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
   const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
   const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
@@ -58,6 +60,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
       description: description.trim() || undefined,
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
+      target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
       tags: tags.trim() || undefined,
       due_date: dueDate || undefined,
       priority,
@@ -121,18 +124,36 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
             </div>
           </div>
 
-          <div>
-            <label className="block text-sm font-medium text-white mb-1">
-              Target Print Count (optional)
-            </label>
-            <input
-              type="number"
-              value={targetCount}
-              onChange={(e) => setTargetCount(e.target.value)}
-              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-              placeholder="e.g., 50 parts to print"
-              min="1"
-            />
+          {/* Target Counts - Plates and Parts side by side */}
+          <div className="grid grid-cols-2 gap-4">
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Target Plates
+              </label>
+              <input
+                type="number"
+                value={targetCount}
+                onChange={(e) => setTargetCount(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="e.g., 25"
+                min="1"
+              />
+              <p className="text-xs text-bambu-gray mt-1">Number of print jobs</p>
+            </div>
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Target Parts
+              </label>
+              <input
+                type="number"
+                value={targetPartsCount}
+                onChange={(e) => setTargetPartsCount(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="e.g., 150"
+                min="1"
+              />
+              <p className="text-xs text-bambu-gray mt-1">Total objects needed</p>
+            </div>
           </div>
 
           {/* Tags */}
@@ -224,7 +245,14 @@ interface ProjectCardProps {
 }
 
 function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
-  const progressPercent = project.progress_percent ?? 0;
+  // Plates progress: archive_count / target_count
+  const platesProgressPercent = project.target_count
+    ? Math.round((project.archive_count / project.target_count) * 100)
+    : 0;
+  // Parts progress: completed_count / target_parts_count
+  const partsProgressPercent = project.target_parts_count
+    ? Math.round((project.completed_count / project.target_parts_count) * 100)
+    : 0;
   const isCompleted = project.status === 'completed';
   const isArchived = project.status === 'archived';
   const [showActions, setShowActions] = useState(false);
@@ -262,17 +290,25 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
             <div className="min-w-0 flex-1">
               <div className="flex items-center gap-2 flex-wrap">
                 <h3 className="font-semibold text-white truncate">{project.name}</h3>
-                {project.target_count ? (
+                {project.target_parts_count ? (
                   <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
-                    progressPercent >= 100
+                    partsProgressPercent >= 100
                       ? 'bg-bambu-green/20 text-bambu-green'
                       : 'bg-bambu-dark text-bambu-gray'
                   }`}>
-                    {project.total_items}/{project.target_count} items
+                    {project.completed_count}/{project.target_parts_count} parts
                   </span>
-                ) : project.total_items > 0 ? (
+                ) : project.target_count ? (
+                  <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
+                    platesProgressPercent >= 100
+                      ? 'bg-bambu-green/20 text-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray'
+                  }`}>
+                    {project.archive_count}/{project.target_count} plates
+                  </span>
+                ) : project.completed_count > 0 ? (
                   <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
-                    {project.total_items} item{project.total_items !== 1 ? 's' : ''}
+                    {project.completed_count} parts
                   </span>
                 ) : null}
                 {isCompleted && (
@@ -372,36 +408,75 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
 
         {/* Progress section - show for all projects */}
         <div className="mb-4">
-          {project.target_count ? (
-            <>
-              <div className="flex items-center justify-between text-xs mb-2">
-                <span className="text-bambu-gray">Progress</span>
-                <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
-                  {project.total_items} / {project.target_count}
-                </span>
-              </div>
-              <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
-                <div
-                  className="h-full transition-all duration-500 ease-out rounded-full relative"
-                  style={{
-                    width: `${Math.min(progressPercent, 100)}%`,
-                    background: progressPercent >= 100
-                      ? 'linear-gradient(90deg, #22c55e, #4ade80)'
-                      : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
-                    boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
-                  }}
-                />
-              </div>
-              <div className="text-right text-xs text-bambu-gray/60 mt-1">
-                {progressPercent.toFixed(0)}% complete
-              </div>
-            </>
-          ) : project.total_items > 0 ? (
+          {(project.target_count || project.target_parts_count) ? (
+            <div className="space-y-3">
+              {/* Plates progress */}
+              {project.target_count && (
+                <div>
+                  <div className="flex items-center justify-between text-xs mb-1">
+                    <span className="text-bambu-gray">Plates</span>
+                    <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                      {project.archive_count} / {project.target_count}
+                    </span>
+                  </div>
+                  <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
+                    <div
+                      className="h-full transition-all duration-500 ease-out rounded-full relative"
+                      style={{
+                        width: `${Math.min(platesProgressPercent, 100)}%`,
+                        background: platesProgressPercent >= 100
+                          ? 'linear-gradient(90deg, #22c55e, #4ade80)'
+                          : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
+                        boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
+                      }}
+                    />
+                  </div>
+                </div>
+              )}
+              {/* Parts progress */}
+              {project.target_parts_count && (
+                <div>
+                  <div className="flex items-center justify-between text-xs mb-1">
+                    <span className="text-bambu-gray">Parts</span>
+                    <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                      {project.completed_count} / {project.target_parts_count}
+                    </span>
+                  </div>
+                  <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
+                    <div
+                      className="h-full transition-all duration-500 ease-out rounded-full relative"
+                      style={{
+                        width: `${Math.min(partsProgressPercent, 100)}%`,
+                        background: partsProgressPercent >= 100
+                          ? 'linear-gradient(90deg, #22c55e, #4ade80)'
+                          : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
+                        boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
+                      }}
+                    />
+                  </div>
+                </div>
+              )}
+              {/* Failed count */}
+              {project.failed_count > 0 && (
+                <div className="text-xs text-red-400">
+                  {project.failed_count} failed
+                </div>
+              )}
+            </div>
+          ) : project.completed_count > 0 || project.failed_count > 0 ? (
             <div className="flex items-center gap-4 text-xs">
-              <div className="flex items-center gap-1.5 text-bambu-gray">
-                <Archive className="w-3.5 h-3.5" />
-                <span>{project.total_items} item{project.total_items !== 1 ? 's' : ''} completed</span>
-              </div>
+              {project.completed_count > 0 && (
+                <div className="flex items-center gap-1.5 text-bambu-gray">
+                  <Archive className="w-3.5 h-3.5" />
+                  <span>{project.completed_count} completed</span>
+                </div>
+              )}
+              {project.failed_count > 0 && (
+                <div className="flex items-center gap-1.5 text-red-400">
+                  <AlertTriangle className="w-3.5 h-3.5" />
+                  <span>{project.failed_count} failed</span>
+                </div>
+              )}
               {project.queue_count > 0 && (
                 <div className="flex items-center gap-1.5 text-blue-400">
                   <Clock className="w-3.5 h-3.5" />
@@ -456,12 +531,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
         {/* Stats footer */}
         <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
           <div className="flex items-center gap-4 text-xs text-bambu-gray">
-            <div className="flex items-center gap-1.5" title="Total items printed">
-              <Archive className="w-3.5 h-3.5" />
-              <span>{project.total_items}</span>
+            <div className="flex items-center gap-1.5" title="Print jobs (plates)">
+              <Layers className="w-3.5 h-3.5 text-blue-400" />
+              <span>{project.archive_count} plates</span>
             </div>
+            <div className="flex items-center gap-1.5" title="Parts printed">
+              <Package className="w-3.5 h-3.5 text-bambu-green" />
+              <span>{project.completed_count} parts</span>
+            </div>
+            {project.failed_count > 0 && (
+              <div className="flex items-center gap-1.5 text-red-400" title="Failed parts">
+                <AlertTriangle className="w-3.5 h-3.5" />
+                <span>{project.failed_count}</span>
+              </div>
+            )}
             {project.queue_count > 0 && (
-              <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
+              <div className="flex items-center gap-1.5 text-yellow-400" title="In queue">
                 <ListTodo className="w-3.5 h-3.5" />
                 <span>{project.queue_count}</span>
               </div>

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

@@ -195,9 +195,9 @@ function SortableQueueItem({
           </div>
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
-            <span className="flex items-center gap-1.5">
+            <span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
               <Printer className="w-3.5 h-3.5" />
-              {item.printer_name || `Printer #${item.printer_id}`}
+              {item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
             </span>
             {item.print_time_seconds && (
               <span className="flex items-center gap-1.5">
@@ -600,10 +600,16 @@ export function QueuePage() {
       <div className="flex items-center gap-4 mb-6">
         <select
           className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-          value={filterPrinter || ''}
-          onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
+          value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
+          onChange={(e) => {
+            const val = e.target.value;
+            if (val === 'unassigned') setFilterPrinter(-1);
+            else if (val === '') setFilterPrinter(null);
+            else setFilterPrinter(Number(val));
+          }}
         >
           <option value="">All Printers</option>
+          <option value="unassigned">Unassigned</option>
           {printers?.map((p) => (
             <option key={p.id} value={p.id}>{p.name}</option>
           ))}

+ 310 - 100
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { formatDateOnly } from '../utils/date';
@@ -46,7 +46,7 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
-  const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
+  const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -216,6 +216,13 @@ export function SettingsPage() {
     },
   });
 
+  // MQTT status for Network tab
+  const { data: mqttStatus } = useQuery({
+    queryKey: ['mqtt-status'],
+    queryFn: api.getMQTTStatus,
+    refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab
+  });
+
   const applyUpdateMutation = useMutation({
     mutationFn: api.applyUpdate,
     onSuccess: (data) => {
@@ -349,7 +356,14 @@ export function SettingsPage() {
       settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
       settings.ftp_retry_count !== localSettings.ftp_retry_count ||
       settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
-      settings.ftp_timeout !== localSettings.ftp_timeout;
+      settings.ftp_timeout !== localSettings.ftp_timeout ||
+      settings.mqtt_enabled !== localSettings.mqtt_enabled ||
+      settings.mqtt_broker !== localSettings.mqtt_broker ||
+      settings.mqtt_port !== localSettings.mqtt_port ||
+      settings.mqtt_username !== localSettings.mqtt_username ||
+      settings.mqtt_password !== localSettings.mqtt_password ||
+      settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
+      settings.mqtt_use_tls !== localSettings.mqtt_use_tls;
 
     if (!hasChanges) {
       return;
@@ -386,6 +400,13 @@ export function SettingsPage() {
         ftp_retry_count: localSettings.ftp_retry_count,
         ftp_retry_delay: localSettings.ftp_retry_delay,
         ftp_timeout: localSettings.ftp_timeout,
+        mqtt_enabled: localSettings.mqtt_enabled,
+        mqtt_broker: localSettings.mqtt_broker,
+        mqtt_port: localSettings.mqtt_port,
+        mqtt_username: localSettings.mqtt_username,
+        mqtt_password: localSettings.mqtt_password,
+        mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
+        mqtt_use_tls: localSettings.mqtt_use_tls,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -472,6 +493,18 @@ export function SettingsPage() {
           <Cylinder className="w-4 h-4" />
           Filament
         </button>
+        <button
+          onClick={() => setActiveTab('network')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+            activeTab === 'network'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <Wifi className="w-4 h-4" />
+          Network
+          <span className={`w-2 h-2 rounded-full ${mqttStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
+        </button>
         <button
           onClick={() => setActiveTab('apikeys')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
@@ -1015,102 +1048,6 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
-          {/* FTP Retry Settings */}
-          <Card>
-            <CardHeader>
-              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                <RefreshCw className="w-5 h-5 text-blue-400" />
-                FTP Retry
-              </h2>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <p className="text-sm text-bambu-gray">
-                Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
-              </p>
-
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-white">Enable retry</p>
-                  <p className="text-sm text-bambu-gray">
-                    Automatically retry failed FTP operations
-                  </p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={localSettings.ftp_retry_enabled ?? true}
-                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
-                    className="sr-only peer"
-                  />
-                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
-              </div>
-
-              {localSettings.ftp_retry_enabled && (
-                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Retry attempts
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="1"
-                        max="10"
-                        value={localSettings.ftp_retry_count ?? 3}
-                        onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
-                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                      />
-                      <span className="text-bambu-gray">times</span>
-                    </div>
-                    <p className="text-xs text-bambu-gray mt-1">
-                      Number of retry attempts before giving up (1-10)
-                    </p>
-                  </div>
-
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Retry delay
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="1"
-                        max="30"
-                        value={localSettings.ftp_retry_delay ?? 2}
-                        onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
-                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                      />
-                      <span className="text-bambu-gray">seconds</span>
-                    </div>
-                    <p className="text-xs text-bambu-gray mt-1">
-                      Wait time between retries (1-30)
-                    </p>
-                  </div>
-                </div>
-              )}
-
-              <div className="pt-2 border-t border-bambu-dark-tertiary">
-                <label className="block text-sm text-bambu-gray mb-1">
-                  Connection timeout
-                </label>
-                <div className="flex items-center gap-2">
-                  <input
-                    type="number"
-                    min="10"
-                    max="120"
-                    value={localSettings.ftp_timeout ?? 30}
-                    onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
-                    className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  />
-                  <span className="text-bambu-gray">seconds</span>
-                </div>
-                <p className="text-xs text-bambu-gray mt-1">
-                  Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
-                </p>
-              </div>
-            </CardContent>
-          </Card>
         </div>
 
         {/* Third Column - Updates */}
@@ -1359,6 +1296,268 @@ export function SettingsPage() {
       </div>
       )}
 
+      {/* Network Tab */}
+      {activeTab === 'network' && localSettings && (
+      <div className="flex flex-col lg:flex-row gap-6">
+        {/* Left Column - MQTT */}
+        <div className="flex-1 lg:max-w-xl space-y-4">
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Wifi className="w-5 h-5 text-blue-400" />
+                  MQTT Publishing
+                </h2>
+                {mqttStatus?.enabled && (
+                  <div className="flex items-center gap-2">
+                    <span className={`w-2.5 h-2.5 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />
+                    <span className={`text-sm ${mqttStatus.connected ? 'text-green-400' : 'text-red-400'}`}>
+                      {mqttStatus.connected ? 'Connected' : 'Disconnected'}
+                    </span>
+                  </div>
+                )}
+              </div>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Publish BamBuddy events to an external MQTT broker for integration with Node-RED, Home Assistant, and other automation systems.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable MQTT</p>
+                  <p className="text-sm text-bambu-gray">
+                    Publish events to external MQTT broker
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.mqtt_enabled ?? false}
+                    onChange={(e) => updateSetting('mqtt_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {localSettings.mqtt_enabled && (
+                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Broker hostname
+                    </label>
+                    <input
+                      type="text"
+                      value={localSettings.mqtt_broker ?? ''}
+                      onChange={(e) => updateSetting('mqtt_broker', e.target.value)}
+                      placeholder="mqtt.example.com or 192.168.1.100"
+                      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"
+                    />
+                  </div>
+
+                  <div className="flex items-end gap-4">
+                    <div className="flex-1">
+                      <label className="block text-sm text-bambu-gray mb-1">
+                        Port
+                      </label>
+                      <input
+                        type="number"
+                        min="1"
+                        max="65535"
+                        value={localSettings.mqtt_port ?? 1883}
+                        onChange={(e) => updateSetting('mqtt_port', Math.min(65535, Math.max(1, parseInt(e.target.value) || 1883)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="flex items-center gap-3 pb-2">
+                      <label className="relative inline-flex items-center cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={localSettings.mqtt_use_tls ?? false}
+                          onChange={(e) => {
+                            const useTls = e.target.checked;
+                            updateSetting('mqtt_use_tls', useTls);
+                            // Auto-populate port based on TLS selection
+                            const currentPort = localSettings.mqtt_port ?? 1883;
+                            if (useTls && currentPort === 1883) {
+                              updateSetting('mqtt_port', 8883);
+                            } else if (!useTls && currentPort === 8883) {
+                              updateSetting('mqtt_port', 1883);
+                            }
+                          }}
+                          className="sr-only peer"
+                        />
+                        <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                      </label>
+                      <span className="text-white text-sm">Use TLS</span>
+                    </div>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Username (optional)
+                    </label>
+                    <input
+                      type="text"
+                      value={localSettings.mqtt_username ?? ''}
+                      onChange={(e) => updateSetting('mqtt_username', e.target.value)}
+                      placeholder="Leave empty for anonymous"
+                      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"
+                    />
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Password (optional)
+                    </label>
+                    <input
+                      type="password"
+                      value={localSettings.mqtt_password ?? ''}
+                      onChange={(e) => updateSetting('mqtt_password', e.target.value)}
+                      placeholder="Leave empty for anonymous"
+                      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"
+                    />
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Topic prefix
+                    </label>
+                    <input
+                      type="text"
+                      value={localSettings.mqtt_topic_prefix ?? 'bambuddy'}
+                      onChange={(e) => updateSetting('mqtt_topic_prefix', e.target.value)}
+                      placeholder="bambuddy"
+                      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"
+                    />
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Topics will be: {localSettings.mqtt_topic_prefix || 'bambuddy'}/printers/&lt;serial&gt;/status, etc.
+                    </p>
+                  </div>
+
+                  {/* Connection Info */}
+                  {mqttStatus && (
+                    <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary">
+                      <div className="flex items-center gap-2 text-sm">
+                        <span className={`w-2 h-2 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />
+                        <span className="text-bambu-gray">
+                          {mqttStatus.connected ? (
+                            <>Connected to <span className="text-white">{mqttStatus.broker}:{mqttStatus.port}</span></>
+                          ) : (
+                            'Not connected'
+                          )}
+                        </span>
+                      </div>
+                    </div>
+                  )}
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Right Column - FTP Retry */}
+        <div className="flex-1 lg:max-w-xl space-y-4">
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <RefreshCw className="w-5 h-5 text-blue-400" />
+                FTP Retry
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable retry</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically retry failed FTP operations
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.ftp_retry_enabled ?? true}
+                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {localSettings.ftp_retry_enabled && (
+                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry attempts
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="10"
+                        value={localSettings.ftp_retry_count ?? 3}
+                        onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">times</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Number of retry attempts before giving up (1-10)
+                    </p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry delay
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="30"
+                        value={localSettings.ftp_retry_delay ?? 2}
+                        onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">seconds</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Wait time between retries (1-30)
+                    </p>
+                  </div>
+                </div>
+              )}
+
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Connection timeout
+                </label>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="number"
+                    min="10"
+                    max="120"
+                    value={localSettings.ftp_timeout ?? 30}
+                    onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
+                    className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <span className="text-bambu-gray">seconds</span>
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
+                </p>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      </div>
+      )}
+
       {/* Smart Plugs Tab */}
       {activeTab === 'plugs' && (
         <div className="max-w-4xl">
@@ -2236,7 +2435,18 @@ export function SettingsPage() {
             // Reset local settings to force re-sync from restored data
             setLocalSettings(null);
             isInitialLoadRef.current = true;
-            queryClient.invalidateQueries();
+            // Use resetQueries to clear cached data completely
+            // This ensures fresh data is fetched, not stale cache
+            queryClient.resetQueries({ queryKey: ['settings'] });
+            // Invalidate other queries that may have changed
+            queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+            queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
+            queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+            queryClient.invalidateQueries({ queryKey: ['external-links'] });
+            queryClient.invalidateQueries({ queryKey: ['printers'] });
+            queryClient.invalidateQueries({ queryKey: ['filaments'] });
+            queryClient.invalidateQueries({ queryKey: ['maintenance-types'] });
+            queryClient.invalidateQueries({ queryKey: ['api-keys'] });
           }}
         />
       )}

+ 155 - 0
scripts/update_archive_quantities.py

@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""Update archive quantities from 3MF files.
+
+This script updates the quantity field on existing archives by parsing
+their 3MF files to count the number of printable objects.
+
+Run this once after upgrading to add proper parts tracking to your projects.
+
+Usage:
+    # From the bambuddy directory:
+    python scripts/update_archive_quantities.py
+
+    # Or with docker:
+    docker exec -it bambuddy python scripts/update_archive_quantities.py
+
+    # Dry run (show what would be updated without changing anything):
+    python scripts/update_archive_quantities.py --dry-run
+"""
+
+import argparse
+import asyncio
+import sys
+import zipfile
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from sqlalchemy import select
+
+from backend.app.core.config import settings
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+
+
+def extract_object_count_from_3mf(file_path: Path) -> int | None:
+    """Extract the number of printable objects from a 3MF file.
+
+    Returns the count of non-skipped objects, or None if parsing fails.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            # Find the plate (use first plate)
+            plate = root.find(".//plate")
+            if plate is None:
+                return None
+
+            # Count non-skipped objects
+            count = 0
+            for obj in plate.findall("object"):
+                skipped = obj.get("skipped", "false")
+                if skipped.lower() != "true":
+                    count += 1
+
+            return count if count > 0 else None
+
+    except Exception as e:
+        print(f"  Error parsing {file_path.name}: {e}")
+        return None
+
+
+async def update_archive_quantities(dry_run: bool = False):
+    """Update quantity field on archives based on 3MF object count."""
+
+    print("=" * 60)
+    print("Archive Quantity Updater")
+    print("=" * 60)
+    print()
+
+    if dry_run:
+        print("DRY RUN MODE - No changes will be made")
+        print()
+
+    async with async_session() as db:
+        # Get all archives with quantity=1 (the default)
+        result = await db.execute(select(PrintArchive).where(PrintArchive.quantity == 1))
+        archives = result.scalars().all()
+
+        print(f"Found {len(archives)} archives with quantity=1")
+        print()
+
+        updated = 0
+        skipped = 0
+        errors = 0
+
+        for archive in archives:
+            # Skip if no file path
+            if not archive.file_path:
+                skipped += 1
+                continue
+
+            file_path = settings.base_dir / archive.file_path
+
+            # Skip if file doesn't exist
+            if not file_path.exists():
+                print(f"  [{archive.id}] File not found: {archive.file_path}")
+                skipped += 1
+                continue
+
+            # Extract object count
+            object_count = extract_object_count_from_3mf(file_path)
+
+            if object_count is None:
+                skipped += 1
+                continue
+
+            if object_count == 1:
+                # No change needed
+                skipped += 1
+                continue
+
+            # Update the archive
+            print(f"  [{archive.id}] {archive.print_name}: 1 -> {object_count} parts")
+
+            if not dry_run:
+                archive.quantity = object_count
+                updated += 1
+            else:
+                updated += 1
+
+        if not dry_run:
+            await db.commit()
+
+        print()
+        print("-" * 60)
+        print(f"Updated: {updated}")
+        print(f"Skipped: {skipped} (no change needed or file not found)")
+        print(f"Errors:  {errors}")
+        print()
+
+        if dry_run and updated > 0:
+            print("Run without --dry-run to apply these changes.")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Update archive quantities from 3MF files")
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Show what would be updated without making changes",
+    )
+    args = parser.parse_args()
+
+    asyncio.run(update_archive_quantities(dry_run=args.dry_run))
+
+
+if __name__ == "__main__":
+    main()

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-DSlUhPr3.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Dzh7xD3q.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Yr6VeTyb.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-rSeEpV8h.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DSlUhPr3.css">
+    <script type="module" crossorigin src="/assets/index-Yr6VeTyb.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Dzh7xD3q.css">
   </head>
   <body>
     <div id="root"></div>

+ 2 - 2
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v21';
-const STATIC_CACHE = 'bambuddy-static-v21';
+const CACHE_NAME = 'bambuddy-v23';
+const STATIC_CACHE = 'bambuddy-static-v23';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است