Bladeren bron

Merge pull request #47 from maziggy/0.1.6b5

v0.1.6b5

###  🚀 Highlights

  ## Pre-built Docker Images

  No more building from source! Pull directly from GitHub Container Registry:
  docker pull ghcr.io/maziggy/bambuddy:latest
  - Multi-architecture: linux/amd64 and linux/arm64 (Raspberry Pi 4/5)
  - Docker automatically pulls the correct image for your system
  - Just docker compose up -d — no --build required

  ## Printer Controls

  New stop and pause/resume buttons on printer cards while printing. Includes confirmation modals to prevent accidental clicks.

  ## Skip Objects

  Skip individual objects during a print without canceling the entire job:
  - Skip button appears when printing with 2+ objects
  - Preview image with object ID markers to match your printer display
  - Requires "Exclude Objects" enabled in slicer

  ## Spoolman Improvements

  - Link Spool: Manually link existing Spoolman spools to AMS trays
  - UUID Display: View and copy Bambu Lab spool UUID from AMS hover card
  - Sync Feedback: See which spools synced and which were skipped (with reasons)

  
  ### ✨ What's New

  ## Added

  - Pre-built Docker images on GitHub Container Registry (ghcr.io)
  - Printer control buttons — Stop and Pause/Resume on printer cards
  - Skip objects — Skip individual objects mid-print
  - AMS slot RFID re-read — Re-read filament info via hover menu
  - Spoolman Link Spool — Connect existing Spoolman spools to AMS trays
  - Spool UUID display — View/copy Bambu spool UUID in AMS hover card
  - Spoolman sync feedback — Shows synced count and skipped spools with reasons
  - Print quantity tracking — Track items per print for project progress

  ## Changed

  - Temperature cards layout refactored for control buttons
  - Cover image now shown when paused (for skip objects modal)
  - Spoolman info banner updated with clearer documentation

  ## Fixed

  - Spoolman 400 Bad Request when creating spools (extra field JSON encoding)


 ### 📦 Installation

  Docker (Recommended):
  mkdir bambuddy && cd bambuddy
  curl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
  docker compose up -d

  Updating from 0.1.6b4:
  docker compose pull && docker compose up -d

  ---
  
  Full Changelog: https://github.com/maziggy/bambuddy/compare/0.1.6b4...0.1.6b5
MartinNYHC 4 maanden geleden
bovenliggende
commit
4b5d88436e
37 gewijzigde bestanden met toevoegingen van 3621 en 269 verwijderingen
  1. 68 0
      CHANGELOG.md
  2. 20 2
      README.md
  3. 3 0
      backend/app/api/routes/archives.py
  4. 275 18
      backend/app/api/routes/printers.py
  5. 28 14
      backend/app/api/routes/projects.py
  6. 2 0
      backend/app/api/routes/settings.py
  7. 152 12
      backend/app/api/routes/spoolman.py
  8. 1 1
      backend/app/core/config.py
  9. 6 0
      backend/app/core/database.py
  10. 44 0
      backend/app/main.py
  11. 5 7
      backend/app/models/archive.py
  12. 2 0
      backend/app/schemas/archive.py
  13. 2 0
      backend/app/schemas/printer.py
  14. 6 4
      backend/app/schemas/project.py
  15. 115 3
      backend/app/services/archive.py
  16. 60 0
      backend/app/services/bambu_mqtt.py
  17. 19 18
      backend/app/services/export.py
  18. 4 1
      backend/app/services/printer_manager.py
  19. 20 36
      backend/app/services/spoolman.py
  20. 420 0
      backend/tests/integration/test_printers_api.py
  21. 423 0
      backend/tests/integration/test_spoolman_api.py
  22. 4 0
      docker-compose.yml
  23. 196 0
      docker-publish.sh
  24. 314 0
      frontend/src/__tests__/components/SpoolmanSettings.test.tsx
  25. 71 1
      frontend/src/api/client.ts
  26. 22 1
      frontend/src/components/EditArchiveModal.tsx
  27. 88 2
      frontend/src/components/FilamentHoverCard.tsx
  28. 183 0
      frontend/src/components/LinkSpoolModal.tsx
  29. 87 11
      frontend/src/components/SpoolmanSettings.tsx
  30. 11 0
      frontend/src/hooks/useWebSocket.ts
  31. 949 120
      frontend/src/pages/PrintersPage.tsx
  32. 8 7
      frontend/src/pages/ProjectDetailPage.tsx
  33. 11 9
      frontend/src/pages/ProjectsPage.tsx
  34. 0 0
      static/assets/index-BaU_6oPV.js
  35. 0 0
      static/assets/index-CCbBv2VC.css
  36. 0 0
      static/assets/index-Da3qKIoX.css
  37. 2 2
      static/index.html

+ 68 - 0
CHANGELOG.md

@@ -2,6 +2,74 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b5] - 2026-01-02
+
+### Added
+- **Pre-built Docker images** - Ready-to-use container images on GitHub Container Registry:
+  - Pull directly: `docker pull ghcr.io/maziggy/bambuddy:latest`
+  - Multi-architecture support: `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5)
+  - No build required - just `docker compose up -d`
+  - Automatic architecture detection - Docker pulls the right image for your system
+- **Spoolman Link Spool feature** - Manually link existing Spoolman spools to AMS trays:
+  - Hover over any AMS slot to see "Link to Spoolman" button (when Spoolman enabled)
+  - Select from list of unlinked Spoolman spools
+  - Automatically sets the `extra.tag` field in Spoolman for proper sync
+  - Useful for connecting existing Spoolman inventory to physical spools
+- **Spool UUID display** - View and copy Bambu Lab spool UUID in AMS hover card:
+  - Shows first 8 chars of UUID with copy button
+  - Full UUID copied to clipboard (with fallback for HTTP contexts)
+  - Only visible when Spoolman integration is enabled
+- **Spoolman sync feedback** - Improved sync result display:
+  - Shows count of successfully synced spools
+  - Lists skipped spools with reasons (e.g., "Non-Bambu Lab spool")
+  - Expandable list when more than 5 spools skipped
+  - Color swatches for easy identification
+- **Printer control buttons** - Stop and Pause/Resume buttons on printer cards when printing:
+  - Stop button cancels the current print job
+  - Pause/Resume toggle for pausing and resuming prints
+  - Confirmation modals for all actions to prevent accidental clicks
+  - Toast notifications for action feedback
+- **Skip objects** - Skip individual objects during a print:
+  - Skip button in print status section (top right) when printing with 2+ objects
+  - Modal shows preview image with object ID markers overlaid
+  - Large ID badges to easily match with printer display
+  - Click to skip any object - it will not be printed
+  - Skipped objects shown with strikethrough styling and red badge on button
+  - Skip only available after layer 1 (printer limitation) with warning message
+  - Objects automatically loaded when print starts from 3MF metadata
+  - Parses skipped objects from printer MQTT for state persistence
+  - Light and dark theme support
+  - Close with ESC key or click outside
+  - Requires "Exclude Objects" option enabled in slicer
+- **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
+  - Menu button (⋮) appears on hover over AMS slots
+  - "Re-read RFID" option triggers filament info refresh
+  - Loading indicator shows while re-read is in progress
+  - Automatically tracks printer status to clear indicator when complete
+  - Menu hidden when printer is busy (printing)
+- **Print quantity tracking** - Track number of items per print job for project progress:
+  - Set "Items Printed" quantity when editing archived prints
+  - Project stats now show total items vs print jobs
+  - Progress bar tracks items toward target count
+  - Useful for batch printing (e.g., 10 copies in one print = 10 items)
+  - Default quantity of 1 for backwards compatibility
+
+### Changed
+- **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
+- **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
+- **Spoolman info banner** - Updated settings UI with clearer sync documentation
+
+### Fixed
+- **Spoolman spool creation** - Fixed 400 Bad Request error when creating new spools:
+  - Spoolman `extra` field values must be valid JSON
+  - Now properly JSON-encodes the `tag` value
+  - Affects both auto-sync and manual link operations
+
+### Tests
+- Added integration tests for printer control endpoints (stop, pause, resume)
+- Added integration tests for AMS slot refresh endpoint
+- Added integration tests for skip objects endpoints (get objects, skip objects, objects with positions)
+
 ## [0.1.6b4] - 2026-01-01
 
 ### Added

+ 20 - 2
README.md

@@ -52,9 +52,12 @@
 - Re-print to any connected printer with AMS filament preview
 - Archive comparison (side-by-side diff)
 
-### 📊 Monitoring & Stats
+### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
+- Printer control (stop, pause, resume)
+- Skip objects during print
+- AMS slot RFID re-read
 - HMS error monitoring with history
 - Print success rates & trends
 - Filament usage tracking
@@ -72,6 +75,7 @@
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Track progress with target counts
+- Quantity tracking for batch prints
 - Color-coded project badges
 - Assign archives via context menu
 
@@ -233,14 +237,24 @@
 
 #### Docker (Recommended)
 
+**Option A: Pre-built image (fastest)**
+```bash
+mkdir bambuddy && cd bambuddy
+curl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
+docker compose up -d
+```
+
+**Option B: Build from source**
 ```bash
 git clone https://github.com/maziggy/bambuddy.git
 cd bambuddy
-docker compose up -d
+docker compose up -d --build
 ```
 
 Open **http://localhost:8000** in your browser.
 
+> **Multi-architecture support:** Pre-built images are available for `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5).
+
 <details>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 
@@ -263,6 +277,10 @@ Open **http://localhost:8000** in your browser.
 **Updating:**
 
 ```bash
+# Pre-built image: just pull the latest
+docker compose pull && docker compose up -d
+
+# From source: rebuild after pulling changes
 cd bambuddy && git pull && docker compose up -d --build
 ```
 

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

@@ -89,6 +89,9 @@ def archive_to_response(
         "cost": archive.cost,
         "photos": archive.photos,
         "failure_reason": archive.failure_reason,
+        "quantity": archive.quantity,
+        "energy_kwh": archive.energy_kwh,
+        "energy_cost": archive.energy_cost,
         "created_at": archive.created_at,
     }
 

+ 275 - 18
backend/app/api/routes/printers.py

@@ -152,9 +152,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
             connected=False,
         )
 
-    # Determine cover URL if there's an active print
+    # Determine cover URL if there's an active print (including paused)
     cover_url = None
-    if state.state == "RUNNING" and state.gcode_file:
+    if state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file:
         cover_url = f"/api/v1/printers/{printer_id}/cover"
 
     # Convert HMS errors to response format
@@ -359,6 +359,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         ams_status_sub=state.ams_status_sub,
         mc_print_sub_stage=state.mc_print_sub_stage,
         last_ams_update=state.last_ams_update,
+        printable_objects_count=len(state.printable_objects),
     )
 
 
@@ -416,13 +417,22 @@ async def test_printer_connection(
     return result
 
 
-# Cache for cover images (printer_id -> (gcode_file, image_bytes))
-_cover_cache: dict[int, tuple[str, bytes]] = {}
+# Cache for cover images (printer_id -> {(gcode_file, view) -> image_bytes})
+_cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 
 
 @router.get("/{printer_id}/cover")
-async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the cover image for the current print job."""
+async def get_printer_cover(
+    printer_id: int,
+    view: str | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the cover image for the current print job.
+
+    Args:
+        view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
+              Default returns angled 3D perspective view.
+    """
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     if not printer:
@@ -437,11 +447,14 @@ async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db))
     if not subtask_name:
         raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
 
+    # Normalize view parameter
+    view_key = view or "default"
+
     # Check cache
     if printer_id in _cover_cache:
-        cached_file, cached_image = _cover_cache[printer_id]
-        if cached_file == subtask_name:
-            return Response(content=cached_image, media_type="image/png")
+        cache_key = (subtask_name, view_key)
+        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"
@@ -500,19 +513,33 @@ async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db))
 
         try:
             # Try common thumbnail paths in 3MF files
-            thumbnail_paths = [
-                "Metadata/plate_1.png",
-                "Metadata/thumbnail.png",
-                "Metadata/plate_1_small.png",
-                "Thumbnails/thumbnail.png",
-                "thumbnail.png",
-            ]
+            # Use top-down view if requested (better for skip objects modal)
+            if view == "top":
+                thumbnail_paths = [
+                    "Metadata/top_1.png",
+                    "Metadata/top_2.png",
+                    "Metadata/top_3.png",
+                    "Metadata/top_4.png",
+                    # Fall back to regular views if no top view
+                    "Metadata/plate_1.png",
+                    "Metadata/thumbnail.png",
+                ]
+            else:
+                thumbnail_paths = [
+                    "Metadata/plate_1.png",
+                    "Metadata/thumbnail.png",
+                    "Metadata/plate_1_small.png",
+                    "Thumbnails/thumbnail.png",
+                    "thumbnail.png",
+                ]
 
             for thumb_path in thumbnail_paths:
                 try:
                     image_data = zf.read(thumb_path)
                     # Cache the result
-                    _cover_cache[printer_id] = (subtask_name, image_data)
+                    if printer_id not in _cover_cache:
+                        _cover_cache[printer_id] = {}
+                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
                 except KeyError:
                     continue
@@ -521,7 +548,9 @@ async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db))
             for name in zf.namelist():
                 if name.startswith("Metadata/") and name.endswith(".png"):
                     image_data = zf.read(name)
-                    _cover_cache[printer_id] = (subtask_name, image_data)
+                    if printer_id not in _cover_cache:
+                        _cover_cache[printer_id] = {}
+                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
 
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -1011,3 +1040,231 @@ async def debug_simulate_print_complete(
     await on_print_complete(printer_id, data)
 
     return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
+
+
+# =============================================================================
+# Print Control Endpoints
+# =============================================================================
+
+
+@router.post("/{printer_id}/print/stop")
+async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Stop/cancel the current print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.stop_print()
+    if not success:
+        raise HTTPException(500, "Failed to stop print")
+
+    return {"success": True, "message": "Print stop command sent"}
+
+
+@router.post("/{printer_id}/print/pause")
+async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Pause the current print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.pause_print()
+    if not success:
+        raise HTTPException(500, "Failed to pause print")
+
+    return {"success": True, "message": "Print pause command sent"}
+
+
+@router.post("/{printer_id}/print/resume")
+async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Resume a paused print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.resume_print()
+    if not success:
+        raise HTTPException(500, "Failed to resume print")
+
+    return {"success": True, "message": "Print resume command sent"}
+
+
+@router.get("/{printer_id}/print/objects")
+async def get_printable_objects(
+    printer_id: int,
+    reload: bool = False,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the list of printable objects for the current print.
+
+    Returns a list of objects with id, name, position (if available), and skip status.
+    Objects that have already been skipped are marked in the skipped_objects list.
+
+    Args:
+        reload: If True, reload objects from the archive file (useful after restart)
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    # Reload objects from 3MF if requested or no objects loaded
+    if reload or not client.state.printable_objects:
+        subtask_name = client.state.subtask_name
+        if subtask_name:
+            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"
+
+            # Download 3MF from printer
+            temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{filename}"
+            temp_path.parent.mkdir(parents=True, exist_ok=True)
+
+            remote_paths = [f"/{filename}", f"/cache/{filename}", f"/model/{filename}"]
+
+            try:
+                downloaded = await download_file_try_paths_async(
+                    printer.ip_address, printer.access_code, remote_paths, temp_path
+                )
+                if downloaded and temp_path.exists():
+                    with open(temp_path, "rb") as f:
+                        data = f.read()
+                    objects = extract_printable_objects_from_3mf(data, include_positions=True)
+                    if objects:
+                        client.state.printable_objects = objects
+                        logger.info(f"Reloaded {len(objects)} objects for printer {printer_id}")
+            except Exception as e:
+                logger.debug(f"Failed to reload objects from printer: {e}")
+            finally:
+                if temp_path.exists():
+                    temp_path.unlink()
+
+    # Return objects with their skip status and position data
+    objects = []
+    for obj_id, obj_data in client.state.printable_objects.items():
+        # Handle both old format (string name) and new format (dict with name, x, y)
+        if isinstance(obj_data, dict):
+            obj_entry = {
+                "id": obj_id,
+                "name": obj_data.get("name", f"Object {obj_id}"),
+                "x": obj_data.get("x"),
+                "y": obj_data.get("y"),
+                "skipped": obj_id in client.state.skipped_objects,
+            }
+        else:
+            # Legacy format: obj_data is just the name string
+            obj_entry = {
+                "id": obj_id,
+                "name": obj_data,
+                "x": None,
+                "y": None,
+                "skipped": obj_id in client.state.skipped_objects,
+            }
+        objects.append(obj_entry)
+
+    return {
+        "objects": objects,
+        "total": len(objects),
+        "skipped_count": len(client.state.skipped_objects),
+        "is_printing": client.state.state in ("RUNNING", "PAUSE"),
+    }
+
+
+@router.post("/{printer_id}/print/skip-objects")
+async def skip_objects(
+    printer_id: int,
+    object_ids: list[int],
+    db: AsyncSession = Depends(get_db),
+):
+    """Skip specific objects during the current print.
+
+    Args:
+        object_ids: List of object identify_id values to skip
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    if not object_ids:
+        raise HTTPException(400, "No object IDs provided")
+
+    # Validate object IDs exist in printable_objects
+    invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
+    if invalid_ids:
+        raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
+
+    success = client.skip_objects(object_ids)
+    if not success:
+        raise HTTPException(500, "Failed to skip objects")
+
+    # Get names of skipped objects for response (handle both old and new format)
+    skipped_names = []
+    for oid in object_ids:
+        obj_data = client.state.printable_objects.get(oid, str(oid))
+        if isinstance(obj_data, dict):
+            skipped_names.append(obj_data.get("name", str(oid)))
+        else:
+            skipped_names.append(obj_data)
+
+    return {
+        "success": True,
+        "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
+        "skipped_objects": object_ids,
+    }
+
+
+# =============================================================================
+# AMS Control Endpoints
+# =============================================================================
+
+
+@router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
+async def refresh_ams_slot(
+    printer_id: int,
+    ams_id: int,
+    slot_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Re-read RFID for an AMS slot (triggers filament info refresh)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success, message = client.ams_refresh_tray(ams_id, slot_id)
+    if not success:
+        raise HTTPException(400, message)
+
+    return {"success": True, "message": message}

+ 28 - 14
backend/app/api/routes/projects.py

@@ -39,21 +39,27 @@ router = APIRouter(prefix="/projects", tags=["projects"])
 
 async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
     """Compute statistics for a project."""
-    # Count total archives
+    # Count total archives (distinct print jobs)
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
     total_archives = total_result.scalar() or 0
 
-    # Count completed archives
+    # Sum total items (using quantity field)
+    total_items_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
+    )
+    total_items = total_items_result.scalar() or 0
+
+    # Sum completed items (using quantity field)
     completed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             PrintArchive.project_id == project_id, PrintArchive.status == "completed"
         )
     )
     completed_prints = completed_result.scalar() or 0
 
-    # Count failed archives
+    # Sum failed items (using quantity field)
     failed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             PrintArchive.project_id == project_id, PrintArchive.status == "failed"
         )
     )
@@ -107,8 +113,9 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
 
     return ProjectStats(
         total_archives=total_archives,
-        completed_prints=completed_prints,
-        failed_prints=failed_prints,
+        total_items=int(total_items),
+        completed_prints=int(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),
@@ -141,12 +148,18 @@ async def list_projects(
     # Compute quick stats for each project
     response = []
     for project in projects:
-        # Get archive count
+        # Get archive count (number of print jobs)
         archive_count_result = await db.execute(
             select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
         )
         archive_count = archive_count_result.scalar() or 0
 
+        # Get total items (sum of quantities)
+        total_items_result = await db.execute(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
+        )
+        total_items = int(total_items_result.scalar() or 0)
+
         # Get queue count
         queue_count_result = await db.execute(
             select(func.count(PrintQueueItem.id)).where(
@@ -156,14 +169,14 @@ async def list_projects(
         )
         queue_count = queue_count_result.scalar() or 0
 
-        # Get completed count for progress
+        # Get completed count for progress (sum of quantities)
         completed_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
                 PrintArchive.status == "completed",
             )
         )
-        completed_count = completed_result.scalar() or 0
+        completed_count = int(completed_result.scalar() or 0)
 
         progress_percent = None
         if project.target_count and project.target_count > 0:
@@ -199,6 +212,7 @@ async def list_projects(
                 target_count=project.target_count,
                 created_at=project.created_at,
                 archive_count=archive_count,
+                total_items=total_items,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
                 archives=archive_previews,
@@ -392,9 +406,9 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
 
     previews = []
     for child in children:
-        # Get completed count for progress
+        # Get completed count for progress (sum of quantities)
         completed_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == child.id,
                 PrintArchive.status == "completed",
             )
@@ -402,7 +416,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
         completed_count = completed_result.scalar() or 0
         progress = None
         if child.target_count and child.target_count > 0:
-            progress = round((completed_count / child.target_count) * 100, 1)
+            progress = round((int(completed_count) / child.target_count) * 100, 1)
 
         previews.append(
             ProjectChildPreview(

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

@@ -546,6 +546,7 @@ async def export_backup(
                 "notes": a.notes,
                 "cost": a.cost,
                 "failure_reason": a.failure_reason,
+                "quantity": a.quantity,
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "extra_data": a.extra_data,
@@ -1306,6 +1307,7 @@ async def import_backup(
                     notes=archive_data.get("notes"),
                     cost=archive_data.get("cost"),
                     failure_reason=archive_data.get("failure_reason"),
+                    quantity=archive_data.get("quantity", 1),
                     energy_kwh=archive_data.get("energy_kwh"),
                     energy_cost=archive_data.get("energy_cost"),
                     extra_data=archive_data.get("extra_data"),

+ 152 - 12
backend/app/api/routes/spoolman.py

@@ -1,21 +1,21 @@
 """Spoolman integration API routes."""
 
 import logging
+
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
 from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
-    SpoolmanClient,
+    close_spoolman_client,
     get_spoolman_client,
     init_spoolman_client,
-    close_spoolman_client,
 )
-from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
 
@@ -30,11 +30,22 @@ class SpoolmanStatus(BaseModel):
     url: str | None
 
 
+class SkippedSpool(BaseModel):
+    """Information about a skipped spool during sync."""
+
+    location: str  # e.g., "AMS A1" or "External Spool"
+    reason: str  # e.g., "Not a Bambu Lab spool", "Empty tray"
+    filament_type: str | None = None  # e.g., "PLA", "PETG"
+    color: str | None = None  # Hex color
+
+
 class SyncResult(BaseModel):
     """Result of a Spoolman sync operation."""
 
     success: bool
     synced_count: int
+    skipped_count: int = 0
+    skipped: list[SkippedSpool] = []
     errors: list[str]
 
 
@@ -156,6 +167,7 @@ async def sync_printer_ams(
 
     # Sync each AMS tray to Spoolman
     synced = 0
+    skipped: list[SkippedSpool] = []
     errors = []
 
     # Handle different AMS data structures
@@ -193,19 +205,28 @@ async def sync_printer_ams(
 
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
-                continue  # Empty tray
+                continue  # Empty tray - nothing to sync
+
+            # Build location string for reporting
+            location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
 
-            # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+            # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
             if not client.is_bambu_lab_spool(tray.tray_uuid):
+                skipped.append(
+                    SkippedSpool(
+                        location=location,
+                        reason="Non-Bambu Lab spool (no RFID tag)",
+                        filament_type=tray.tray_type if tray.tray_type else None,
+                        color=tray.tray_color[:6] if tray.tray_color else None,
+                    )
+                )
                 continue
 
             try:
                 sync_result = await client.sync_ams_tray(tray, printer.name)
                 if sync_result:
                     synced += 1
-                    logger.info(
-                        f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}"
-                    )
+                    logger.info(f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}")
                 else:
                     # Bambu Lab spool that wasn't synced (not found in Spoolman)
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
@@ -217,6 +238,8 @@ async def sync_printer_ams(
     return SyncResult(
         success=len(errors) == 0,
         synced_count=synced,
+        skipped_count=len(skipped),
+        skipped=skipped,
         errors=errors,
     )
 
@@ -240,10 +263,11 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
     # Get all active printers
-    result = await db.execute(select(Printer).where(Printer.is_active == True))
+    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     printers = result.scalars().all()
 
     total_synced = 0
+    all_skipped: list[SkippedSpool] = []
     all_errors = []
 
     for printer in printers:
@@ -291,8 +315,19 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                 if not tray:
                     continue
 
-                # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+                # Build location string for reporting
+                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):
+                    all_skipped.append(
+                        SkippedSpool(
+                            location=location,
+                            reason="Non-Bambu Lab spool (no RFID tag)",
+                            filament_type=tray.tray_type if tray.tray_type else None,
+                            color=tray.tray_color[:6] if tray.tray_color else None,
+                        )
+                    )
                     continue
 
                 try:
@@ -305,6 +340,8 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
     return SyncResult(
         success=len(all_errors) == 0,
         synced_count=total_synced,
+        skipped_count=len(all_skipped),
+        skipped=all_skipped,
         errors=all_errors,
     )
 
@@ -349,3 +386,106 @@ async def get_filaments(db: AsyncSession = Depends(get_db)):
 
     filaments = await client.get_filaments()
     return {"filaments": filaments}
+
+
+class UnlinkedSpool(BaseModel):
+    """A Spoolman spool that is not linked to any AMS tray."""
+
+    id: int
+    filament_name: str | None
+    filament_material: str | None
+    filament_color_hex: str | None
+    remaining_weight: float | None
+    location: str | None
+
+
+@router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
+async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
+    """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    spools = await client.get_spools()
+    unlinked = []
+
+    for spool in spools:
+        # Check if spool has a tag in extra field
+        extra = spool.get("extra", {}) or {}
+        tag = extra.get("tag", "")
+        if not tag:
+            filament = spool.get("filament", {}) or {}
+            unlinked.append(
+                UnlinkedSpool(
+                    id=spool["id"],
+                    filament_name=filament.get("name"),
+                    filament_material=filament.get("material"),
+                    filament_color_hex=filament.get("color_hex"),
+                    remaining_weight=spool.get("remaining_weight"),
+                    location=spool.get("location"),
+                )
+            )
+
+    return unlinked
+
+
+class LinkSpoolRequest(BaseModel):
+    """Request to link a Spoolman spool to an AMS tray."""
+
+    tray_uuid: str
+
+
+@router.post("/spools/{spool_id}/link")
+async def link_spool(
+    spool_id: int,
+    request: LinkSpoolRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    # Validate tray_uuid format (32 hex characters)
+    tray_uuid = request.tray_uuid.strip()
+    if len(tray_uuid) != 32:
+        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
+    try:
+        int(tray_uuid, 16)
+    except ValueError:
+        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
+
+    # Update spool with tag
+    # Note: Spoolman extra field values must be valid JSON, so we encode the string
+    import json
+
+    result = await client.update_spool(
+        spool_id=spool_id,
+        extra={"tag": json.dumps(tray_uuid)},
+    )
+
+    if result:
+        logger.info(f"Linked Spoolman spool {spool_id} to tray_uuid {tray_uuid}")
+        return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
+    else:
+        raise HTTPException(status_code=500, detail="Failed to update spool")

+ 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.6b4"
+APP_VERSION = "0.1.6b5"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

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

@@ -375,6 +375,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add quantity column to print_archives for tracking item count
+    try:
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 44 - 0
backend/app/main.py

@@ -342,6 +342,27 @@ async def _send_print_start_notification(
         logger.warning(f"Notification on_print_start failed: {e}")
 
 
+def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
+    """Extract printable objects from an archive's 3MF file and store in printer state."""
+    try:
+        from backend.app.services.archive import extract_printable_objects_from_3mf
+
+        file_path = app_settings.base_dir / archive.file_path
+        if file_path.exists() and str(file_path).endswith(".3mf"):
+            with open(file_path, "rb") as f:
+                threemf_data = f.read()
+            # Extract with positions for UI overlay
+            printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+            if printable_objects:
+                client = printer_manager.get_client(printer_id)
+                if client:
+                    client.state.printable_objects = printable_objects
+                    client.state.skipped_objects = []
+                    logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
+    except Exception as e:
+        logger.debug(f"Failed to extract printable objects from archive: {e}")
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     import logging
@@ -470,6 +491,9 @@ async def on_print_start(printer_id: int, data: dict):
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
 
+                # Extract printable objects from the archived 3MF file
+                _load_objects_from_archive(archive, printer_id, logger)
+
             return  # Skip creating a new archive
 
         # Check if there's already a "printing" archive for this printer/file
@@ -508,6 +532,8 @@ async def on_print_start(printer_id: int, data: dict):
             if not notification_sent:
                 archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
                 await _send_print_start_notification(printer_id, data, archive_data, logger)
+            # Extract printable objects from the archived 3MF file
+            _load_objects_from_archive(existing_archive, printer_id, logger)
             return
 
         # Build list of possible 3MF filenames to try
@@ -662,6 +688,24 @@ async def on_print_start(printer_id: int, data: dict):
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                     notification_sent = True
+
+                # Extract printable objects for skip object functionality
+                try:
+                    from backend.app.services.archive import extract_printable_objects_from_3mf
+
+                    with open(temp_path, "rb") as f:
+                        threemf_data = f.read()
+                    # Extract with positions for UI overlay
+                    printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+                    if printable_objects:
+                        # Store objects in printer state
+                        client = printer_manager.get_client(printer_id)
+                        if client:
+                            client.state.printable_objects = printable_objects
+                            client.state.skipped_objects = []  # Reset skipped objects for new print
+                            logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
+                except Exception as e:
+                    logger.debug(f"Failed to extract printable objects: {e}")
         finally:
             if temp_path and temp_path.exists():
                 temp_path.unlink()

+ 5 - 7
backend/app/models/archive.py

@@ -1,5 +1,6 @@
 from datetime import datetime
-from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, JSON, Boolean, func
+
+from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -10,9 +11,7 @@ class PrintArchive(Base):
 
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
-    project_id: Mapped[int | None] = mapped_column(
-        ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
-    )
+    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
     # File info
     filename: Mapped[str] = mapped_column(String(255))
@@ -54,15 +53,14 @@ class PrintArchive(Base):
     cost: Mapped[float | None] = mapped_column(Float)
     photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames
     failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
+    quantity: Mapped[int] = mapped_column(Integer, default=1)  # Number of items printed
 
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
 
     # Timestamps
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")

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

@@ -10,6 +10,7 @@ class ArchiveBase(BaseModel):
     notes: str | None = None
     cost: float | None = None
     failure_reason: str | None = None
+    quantity: int | None = None  # Number of items printed
 
 
 class ArchiveUpdate(ArchiveBase):
@@ -72,6 +73,7 @@ class ArchiveResponse(BaseModel):
     cost: float | None
     photos: list | None
     failure_reason: str | None
+    quantity: int = 1  # Number of items printed
 
     # Energy tracking
     energy_kwh: float | None = None

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

@@ -151,3 +151,5 @@ class PrinterStatus(BaseModel):
     mc_print_sub_stage: int = 0
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
+    # Number of printable objects in current print (for skip objects feature)
+    printable_objects_count: int = 0

+ 6 - 4
backend/app/schemas/project.py

@@ -37,9 +37,10 @@ class ProjectUpdate(BaseModel):
 class ProjectStats(BaseModel):
     """Statistics for a project."""
 
-    total_archives: int = 0
-    completed_prints: int = 0
-    failed_prints: int = 0
+    total_archives: int = 0  # Number of archive records
+    total_items: int = 0  # Sum of quantities (total items printed)
+    completed_prints: int = 0  # Sum of quantities for completed prints
+    failed_prints: int = 0  # Sum of quantities for failed prints
     queued_prints: int = 0
     in_progress_prints: int = 0
     total_print_time_hours: float = 0.0
@@ -115,7 +116,8 @@ class ProjectListResponse(BaseModel):
     target_count: int | None
     created_at: datetime
     # Quick stats
-    archive_count: int = 0
+    archive_count: int = 0  # Number of print jobs
+    total_items: int = 0  # Sum of quantities (total items printed)
     queue_count: int = 0
     progress_percent: float | None = None
     # Preview of archives (up to 5)

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

@@ -49,14 +49,21 @@ class ThreeMFParser:
         return self.metadata
 
     def _parse_slice_info(self, zf: zipfile.ZipFile):
-        """Parse slice_info.config for print settings."""
+        """Parse slice_info.config for print settings and printable objects."""
         try:
             if "Metadata/slice_info.config" in zf.namelist():
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
 
-                # Get first plate's metadata
-                plate = root.find(".//plate")
+                # Get the correct plate's metadata (use plate_number if specified)
+                if self.plate_number:
+                    plate = root.find(f".//plate[@plate_idx='{self.plate_number}']")
+                    if plate is None:
+                        # Fallback to first plate if specific plate not found
+                        plate = root.find(".//plate")
+                else:
+                    plate = root.find(".//plate")
+
                 if plate is not None:
                     # Get prediction and weight from metadata elements
                     for meta in plate.findall("metadata"):
@@ -67,6 +74,24 @@ class ThreeMFParser:
                         elif key == "weight" and value:
                             self.metadata["filament_used_grams"] = float(value)
 
+                    # Extract printable objects for skip object functionality
+                    # Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
+                    printable_objects = {}
+                    for obj in plate.findall("object"):
+                        identify_id = obj.get("identify_id")
+                        name = obj.get("name")
+                        skipped = obj.get("skipped", "false")
+
+                        # Only include objects that are not pre-skipped
+                        if identify_id and name and skipped.lower() != "true":
+                            try:
+                                printable_objects[int(identify_id)] = name
+                            except ValueError:
+                                pass
+
+                    if printable_objects:
+                        self.metadata["printable_objects"] = printable_objects
+
                 # Get filament info from filaments ACTUALLY USED in the print
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
                 # Only include filaments where used_g > 0
@@ -315,6 +340,93 @@ class ThreeMFParser:
                 break
 
 
+def extract_printable_objects_from_3mf(
+    data: bytes, plate_number: int | None = None, include_positions: bool = False
+) -> dict[int, str] | dict[int, dict]:
+    """Extract printable objects from 3MF file bytes.
+
+    This is a lightweight function used during print start to get the list
+    of objects that can be skipped.
+
+    Args:
+        data: Raw bytes of the 3MF file
+        plate_number: Which plate was printed (1-based), or None for first plate
+        include_positions: If True, return dict with name and position info
+
+    Returns:
+        If include_positions=False: Dictionary mapping identify_id (int) to object name (str)
+        If include_positions=True: Dictionary mapping identify_id to {name, x, y} dict
+    """
+    import json
+    from io import BytesIO
+
+    printable_objects: dict = {}
+
+    try:
+        with zipfile.ZipFile(BytesIO(data), "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return printable_objects
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            # Find the correct plate
+            plate_idx = plate_number or 1
+            if plate_number:
+                plate = root.find(f".//plate[@plate_idx='{plate_number}']")
+                if plate is None:
+                    plate = root.find(".//plate")
+            else:
+                plate = root.find(".//plate")
+
+            if plate is None:
+                return printable_objects
+
+            # Load position data from plate_N.json if we need positions
+            bbox_objects = []
+            if include_positions:
+                plate_json_path = f"Metadata/plate_{plate_idx}.json"
+                if plate_json_path in zf.namelist():
+                    try:
+                        plate_json = json.loads(zf.read(plate_json_path).decode())
+                        bbox_objects = plate_json.get("bbox_objects", [])
+                    except (json.JSONDecodeError, KeyError):
+                        pass
+
+            # Extract objects from slice_info.config
+            objects_list = []
+            for obj in plate.findall("object"):
+                identify_id = obj.get("identify_id")
+                name = obj.get("name")
+                skipped = obj.get("skipped", "false")
+
+                if identify_id and name and skipped.lower() != "true":
+                    try:
+                        obj_id = int(identify_id)
+                        objects_list.append((obj_id, name))
+                    except ValueError:
+                        pass
+
+            # Match objects with positions by index (both lists are in same order)
+            for idx, (obj_id, name) in enumerate(objects_list):
+                if include_positions:
+                    x, y = None, None
+                    if idx < len(bbox_objects):
+                        bbox = bbox_objects[idx].get("bbox", [])
+                        if len(bbox) >= 4:
+                            # Calculate center from bbox [x_min, y_min, x_max, y_max]
+                            x = (bbox[0] + bbox[2]) / 2
+                            y = (bbox[1] + bbox[3]) / 2
+                    printable_objects[obj_id] = {"name": name, "x": x, "y": y}
+                else:
+                    printable_objects[obj_id] = name
+
+    except Exception:
+        pass
+
+    return printable_objects
+
+
 class ProjectPageParser:
     """Parser for extracting project page data from Bambu Lab 3MF files."""
 

+ 60 - 0
backend/app/services/bambu_mqtt.py

@@ -146,6 +146,10 @@ class PrinterState:
     ams_extruder_map: dict = field(default_factory=dict)
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
+    # Printable objects for skip object functionality: {identify_id: object_name}
+    printable_objects: dict = field(default_factory=dict)
+    # Objects that have been skipped during the current print
+    skipped_objects: list = field(default_factory=list)
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -1415,6 +1419,17 @@ class BambuMQTTClient:
                 logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}")
             self.state.speed_level = new_speed
 
+        # Parse skipped objects from printer status (s_obj field)
+        # This allows us to restore skipped objects state after reconnection
+        if "s_obj" in data:
+            s_obj = data["s_obj"]
+            if isinstance(s_obj, list):
+                # Update skipped objects from printer's list
+                new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))]
+                if new_skipped != self.state.skipped_objects:
+                    logger.info(f"[{self.serial_number}] skipped_objects updated from printer: {new_skipped}")
+                    self.state.skipped_objects = new_skipped
+
         # Parse chamber light status from lights_report
         if "lights_report" in data:
             lights = data["lights_report"]
@@ -2385,6 +2400,51 @@ class BambuMQTTClient:
         logger.info(f"[{self.serial_number}] Sent resume print command")
         return True
 
+    def skip_objects(self, object_ids: list[int]) -> bool:
+        """Skip specific objects during a print.
+
+        This command tells the printer to skip printing the specified objects.
+        The object IDs come from the slice_info.config file in the 3MF.
+
+        Args:
+            object_ids: List of identify_id values from slice_info.config
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot skip objects: not connected")
+            return False
+
+        if self.state.state != "RUNNING" and self.state.state != "PAUSE":
+            logger.warning(
+                f"[{self.serial_number}] Cannot skip objects: printer not printing (state={self.state.state})"
+            )
+            return False
+
+        if not object_ids:
+            logger.warning(f"[{self.serial_number}] Cannot skip objects: no object IDs provided")
+            return False
+
+        # Validate all IDs are integers
+        try:
+            obj_list = [int(oid) for oid in object_ids]
+        except (ValueError, TypeError) as e:
+            logger.warning(f"[{self.serial_number}] Invalid object IDs: {e}")
+            return False
+
+        self._sequence_id += 1
+        command = {"print": {"sequence_id": str(self._sequence_id), "command": "skip_objects", "obj_list": obj_list}}
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Sent skip_objects command: {obj_list}")
+
+        # Track skipped objects in state
+        for oid in obj_list:
+            if oid not in self.state.skipped_objects:
+                self.state.skipped_objects.append(oid)
+
+        return True
+
     def send_gcode(self, gcode: str) -> bool:
         """Send G-code command(s) to the printer.
 

+ 19 - 18
backend/app/services/export.py

@@ -3,12 +3,11 @@ import io
 from datetime import datetime
 from typing import Any
 
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.models.archive import PrintArchive
-from backend.app.models.project import Project
 
 
 class ExportService:
@@ -20,6 +19,7 @@ class ExportService:
         "print_name",
         "filename",
         "status",
+        "quantity",
         "printer_id",
         "project_name",
         "filament_type",
@@ -46,6 +46,7 @@ class ExportService:
         "print_name": "Print Name",
         "filename": "Filename",
         "status": "Status",
+        "quantity": "Items Printed",
         "printer_id": "Printer ID",
         "project_name": "Project",
         "filament_type": "Filament Type",
@@ -97,9 +98,7 @@ class ExportService:
         """
         # Build query
         query = (
-            select(PrintArchive)
-            .options(selectinload(PrintArchive.project))
-            .order_by(PrintArchive.created_at.desc())
+            select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
         )
 
         # Apply filters
@@ -116,11 +115,11 @@ class ExportService:
         if search:
             like_pattern = f"%{search}%"
             query = query.where(
-                (PrintArchive.print_name.ilike(like_pattern)) |
-                (PrintArchive.filename.ilike(like_pattern)) |
-                (PrintArchive.tags.ilike(like_pattern)) |
-                (PrintArchive.notes.ilike(like_pattern)) |
-                (PrintArchive.designer.ilike(like_pattern))
+                (PrintArchive.print_name.ilike(like_pattern))
+                | (PrintArchive.filename.ilike(like_pattern))
+                | (PrintArchive.tags.ilike(like_pattern))
+                | (PrintArchive.notes.ilike(like_pattern))
+                | (PrintArchive.designer.ilike(like_pattern))
             )
 
         # Execute query
@@ -212,12 +211,14 @@ class ExportService:
         rows.append(["Week", "Total", "Failed", "Rate (%)"])
 
         for week in analysis["trend"]:
-            rows.append([
-                week["week_start"],
-                week["total_prints"],
-                week["failed_prints"],
-                week["failure_rate"],
-            ])
+            rows.append(
+                [
+                    week["week_start"],
+                    week["total_prints"],
+                    week["failed_prints"],
+                    week["failure_rate"],
+                ]
+            )
 
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
@@ -266,7 +267,7 @@ class ExportService:
         """Generate Excel file content."""
         try:
             from openpyxl import Workbook
-            from openpyxl.styles import Font, PatternFill, Alignment
+            from openpyxl.styles import Alignment, Font, PatternFill
             from openpyxl.utils import get_column_letter
         except ImportError:
             raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
@@ -293,7 +294,7 @@ class ExportService:
                 ws.cell(row=row_idx, column=col_idx, value=value)
 
         # Auto-adjust column widths
-        for col_idx, field in enumerate(fields, 1):
+        for col_idx, _field in enumerate(fields, 1):
             column_letter = get_column_letter(col_idx)
             max_length = len(headers[col_idx - 1])
             for row in rows:

+ 4 - 1
backend/app/services/printer_manager.py

@@ -475,9 +475,12 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state),
+        # Printable objects count for skip objects feature
+        "printable_objects_count": len(state.printable_objects),
     }
     # Add cover URL if there's an active print and printer_id is provided
-    if printer_id and state.state == "RUNNING" and state.gcode_file:
+    # Include PAUSE/PAUSED states so skip objects modal can show cover
+    if printer_id and state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file:
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
     else:
         result["cover_url"] = None

+ 20 - 36
backend/app/services/spoolman.py

@@ -2,7 +2,7 @@
 
 import logging
 from dataclasses import dataclass
-from datetime import datetime, timezone
+from datetime import UTC, datetime
 
 import httpx
 
@@ -170,9 +170,7 @@ class SpoolmanClient:
         """
         try:
             client = await self._get_client()
-            response = await client.post(
-                f"{self.api_url}/vendor", json={"name": name}
-            )
+            response = await client.post(f"{self.api_url}/vendor", json={"name": name})
             response.raise_for_status()
             return response.json()
         except Exception as e:
@@ -314,7 +312,9 @@ class SpoolmanClient:
             client = await self._get_client()
             response = await client.post(f"{self.api_url}/spool", json=data)
             response.raise_for_status()
-            return response.json()
+            result = response.json()
+            logger.info(f"Created spool {result.get('id')} in Spoolman")
+            return result
         except httpx.HTTPStatusError as e:
             logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
             return None
@@ -350,12 +350,10 @@ class SpoolmanClient:
                 data["extra"] = extra
 
             # Always update last_used
-            data["last_used"] = datetime.now(timezone.utc).isoformat()
+            data["last_used"] = datetime.now(UTC).isoformat()
 
             client = await self._get_client()
-            response = await client.patch(
-                f"{self.api_url}/spool/{spool_id}", json=data
-            )
+            response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)
             response.raise_for_status()
             return response.json()
         except Exception as e:
@@ -519,9 +517,7 @@ class SpoolmanClient:
         except ValueError:
             return False
 
-    def calculate_remaining_weight(
-        self, remain_percent: int, spool_weight: int
-    ) -> float:
+    def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
         """Calculate remaining weight from percentage.
 
         Args:
@@ -533,9 +529,7 @@ class SpoolmanClient:
         """
         return (remain_percent / 100.0) * spool_weight
 
-    async def sync_ams_tray(
-        self, tray: AMSTray, printer_name: str
-    ) -> dict | None:
+    async def sync_ams_tray(self, tray: AMSTray, printer_name: str) -> dict | None:
         """Sync a single AMS tray to Spoolman.
 
         Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
@@ -564,9 +558,7 @@ class SpoolmanClient:
                     f"(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}"
-                )
+                logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}")
             return None
 
         # Calculate remaining weight
@@ -577,9 +569,7 @@ class SpoolmanClient:
         existing = await self.find_spool_by_tag(tray.tray_uuid)
         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 tray_uuid {tray.tray_uuid}")
             return await self.update_spool(
                 spool_id=existing["id"],
                 remaining_weight=remaining,
@@ -588,8 +578,7 @@ class SpoolmanClient:
 
         # Spool not found - auto-create it
         logger.info(
-            f"Creating new spool in Spoolman for {tray.tray_sub_brands} "
-            f"(tray_uuid: {tray.tray_uuid[:16]}...)"
+            f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)"
         )
 
         # First find or create the filament type
@@ -599,12 +588,15 @@ class SpoolmanClient:
             return None
 
         # Create the spool with tray_uuid stored as "tag" in extra field
+        # Note: Spoolman extra field values must be valid JSON, so we encode the string
+        import json
+
         return await self.create_spool(
             filament_id=filament["id"],
             remaining_weight=remaining,
             location=location,
-            comment=f"Auto-created from {printer_name} AMS",
-            extra={"tag": tray.tray_uuid},
+            comment="Created by Bambuddy",
+            extra={"tag": json.dumps(tray.tray_uuid)},
         )
 
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
@@ -624,10 +616,7 @@ class SpoolmanClient:
             # Match by material and color (handle None values)
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
-            if (
-                fil_material.upper() == tray.tray_type.upper()
-                and fil_color.upper() == color_hex.upper()
-            ):
+            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
                 return filament
 
         # Search external filaments (Bambu library)
@@ -635,10 +624,7 @@ class SpoolmanClient:
         for filament in external:
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
-            if (
-                fil_material.upper() == tray.tray_type.upper()
-                and fil_color.upper() == color_hex.upper()
-            ):
+            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
                 # Found in external library - need to create internal copy
                 return await self._create_filament_from_external(filament, tray)
 
@@ -652,9 +638,7 @@ class SpoolmanClient:
             weight=tray.tray_weight,
         )
 
-    async def _create_filament_from_external(
-        self, external: dict, tray: AMSTray
-    ) -> dict | None:
+    async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
         """Create internal filament from external library entry.
 
         Args:

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

@@ -291,3 +291,423 @@ class TestPrinterDataIntegrity:
             assert response.status_code == 200
             assert response.json()["status"] == "refresh_requested"
             mock_pm.request_status_update.assert_called_once_with(printer.id)
+
+
+class TestPrintControlAPI:
+    """Integration tests for print control endpoints (stop, pause, resume)."""
+
+    # ========================================================================
+    # Stop print endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_print_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/stop")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful stop print request."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.stop_print.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+            mock_client.stop_print.assert_called_once()
+
+    # ========================================================================
+    # Pause print endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pause_print_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/pause")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful pause print request."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.pause_print.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+            mock_client.pause_print.assert_called_once()
+
+    # ========================================================================
+    # Resume print endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_print_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/resume")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful resume print request."""
+        printer = await printer_factory(name="Paused Printer")
+
+        mock_client = MagicMock()
+        mock_client.resume_print.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+            mock_client.resume_print.assert_called_once()
+
+
+class TestAMSRefreshAPI:
+    """Integration tests for AMS slot refresh endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful AMS refresh request."""
+        printer = await printer_factory(name="Printer with AMS")
+
+        mock_client = MagicMock()
+        mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
+        """Verify error when filament is loaded (can't refresh while loaded)."""
+        printer = await printer_factory(name="Printer with AMS")
+
+        mock_client = MagicMock()
+        mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
+
+            assert response.status_code == 400
+            assert "unload" in response.json()["detail"].lower()
+
+
+class TestSkipObjectsAPI:
+    """Integration tests for skip objects endpoints."""
+
+    # ========================================================================
+    # Get printable objects endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/print/objects")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
+        """Verify empty objects list when no print is active."""
+        printer = await printer_factory(name="Idle Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {}
+        mock_client.state.skipped_objects = []
+        mock_client.state.state = "IDLE"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["objects"] == []
+            assert result["total"] == 0
+            assert result["skipped_count"] == 0
+            assert result["is_printing"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
+        """Verify objects list when print is active."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
+        mock_client.state.skipped_objects = [200]
+        mock_client.state.state = "RUNNING"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["total"] == 3
+            assert result["skipped_count"] == 1
+            assert result["is_printing"] is True
+
+            # Check objects have correct structure
+            objects_by_id = {obj["id"]: obj for obj in result["objects"]}
+            assert objects_by_id[100]["name"] == "Part A"
+            assert objects_by_id[100]["skipped"] is False
+            assert objects_by_id[200]["name"] == "Part B"
+            assert objects_by_id[200]["skipped"] is True
+            assert objects_by_id[300]["name"] == "Part C"
+            assert objects_by_id[300]["skipped"] is False
+
+    # ========================================================================
+    # Skip objects endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
+        """Verify objects list includes position data when available."""
+        printer = await printer_factory(name="Printing Printer")
+
+        # New format with position data
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {
+            100: {"name": "Part A", "x": 50.0, "y": 100.0},
+            200: {"name": "Part B", "x": 150.0, "y": 100.0},
+        }
+        mock_client.state.skipped_objects = []
+        mock_client.state.state = "RUNNING"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["total"] == 2
+
+            # Check objects have position data
+            objects_by_id = {obj["id"]: obj for obj in result["objects"]}
+            assert objects_by_id[100]["name"] == "Part A"
+            assert objects_by_id[100]["x"] == 50.0
+            assert objects_by_id[100]["y"] == 100.0
+            assert objects_by_id[200]["name"] == "Part B"
+            assert objects_by_id[200]["x"] == 150.0
+            assert objects_by_id[200]["y"] == 100.0
+
+    # ========================================================================
+    # Skip objects endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
+        """Verify error when no object IDs provided."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A"}
+        mock_client.state.skipped_objects = []
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
+
+            assert response.status_code == 400
+            assert "no object" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
+        """Verify error when object ID doesn't exist."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A"}
+        mock_client.state.skipped_objects = []
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
+
+            assert response.status_code == 400
+            assert "invalid" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful skip objects request."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
+        mock_client.state.skipped_objects = []
+        mock_client.skip_objects.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert 100 in result["skipped_objects"]
+            mock_client.skip_objects.assert_called_once_with([100])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
+        """Verify skipping multiple objects at once."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
+        mock_client.state.skipped_objects = []
+        mock_client.skip_objects.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert 100 in result["skipped_objects"]
+            assert 200 in result["skipped_objects"]
+            mock_client.skip_objects.assert_called_once_with([100, 200])

+ 423 - 0
backend/tests/integration/test_spoolman_api.py

@@ -0,0 +1,423 @@
+"""Integration tests for Spoolman API endpoints."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSpoolmanAPI:
+    """Integration tests for /api/v1/spoolman/ endpoints."""
+
+    @pytest.fixture
+    async def spoolman_settings(self, db_session):
+        """Create Spoolman settings in the database (enabled with URL)."""
+        from backend.app.models.settings import Settings
+
+        # Both settings are required for Spoolman to work
+        enabled_setting = Settings(key="spoolman_enabled", value="true")
+        url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
+        db_session.add(enabled_setting)
+        db_session.add(url_setting)
+        await db_session.commit()
+        return {"enabled": enabled_setting, "url": url_setting}
+
+    @pytest.fixture
+    async def spoolman_url_only(self, db_session):
+        """Create only the URL setting (not enabled)."""
+        from backend.app.models.settings import Settings
+
+        setting = Settings(key="spoolman_url", value="http://localhost:7912")
+        db_session.add(setting)
+        await db_session.commit()
+        return setting
+
+    @pytest.fixture
+    def mock_spoolman_client(self):
+        """Mock the Spoolman client functions."""
+        mock_client = MagicMock()
+        mock_client.is_connected = True
+        mock_client.base_url = "http://localhost:7912"
+        mock_client.health_check = AsyncMock(return_value=True)
+        mock_client.get_spools = AsyncMock(return_value=[])
+        mock_client.get_filaments = AsyncMock(return_value=[])
+        mock_client.create_spool = AsyncMock(return_value={"id": 1})
+        mock_client.update_spool = AsyncMock(return_value={"id": 1})
+        mock_client.close = AsyncMock()
+
+        with (
+            patch(
+                "backend.app.api.routes.spoolman.get_spoolman_client",
+                AsyncMock(return_value=mock_client),
+            ),
+            patch(
+                "backend.app.api.routes.spoolman.init_spoolman_client",
+                AsyncMock(return_value=mock_client),
+            ),
+            patch(
+                "backend.app.api.routes.spoolman.close_spoolman_client",
+                AsyncMock(),
+            ),
+        ):
+            yield mock_client
+
+    @pytest.fixture
+    def mock_spoolman_disconnected(self):
+        """Mock the Spoolman client as disconnected (returns None)."""
+        with (
+            patch(
+                "backend.app.api.routes.spoolman.get_spoolman_client",
+                AsyncMock(return_value=None),
+            ),
+            patch(
+                "backend.app.api.routes.spoolman.init_spoolman_client",
+                AsyncMock(return_value=None),
+            ),
+        ):
+            yield
+
+    # =========================================================================
+    # Status Endpoint Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_status_not_configured(self, async_client: AsyncClient):
+        """Verify status shows not enabled when no settings exist."""
+        response = await async_client.get("/api/v1/spoolman/status")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["enabled"] is False
+        assert data["connected"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_status_url_only_not_enabled(self, async_client: AsyncClient, spoolman_url_only):
+        """Verify status shows not enabled when only URL is set."""
+        response = await async_client.get("/api/v1/spoolman/status")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["enabled"] is False
+        assert data["url"] == "http://localhost:7912"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_status_enabled_and_connected(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify status shows enabled and connected when properly configured."""
+        response = await async_client.get("/api/v1/spoolman/status")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["enabled"] is True
+        assert data["connected"] is True
+        assert data["url"] == "http://localhost:7912"
+
+    # =========================================================================
+    # Connect/Disconnect Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_connect_not_enabled(self, async_client: AsyncClient):
+        """Verify connect fails when not enabled."""
+        response = await async_client.post("/api/v1/spoolman/connect")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_connect_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify successful connection to Spoolman."""
+        response = await async_client.post("/api/v1/spoolman/connect")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        assert "connected" in data["message"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disconnect(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify disconnect works."""
+        response = await async_client.post("/api/v1/spoolman/disconnect")
+        assert response.status_code == 200
+        assert "disconnected" in response.json()["message"].lower()
+
+    # =========================================================================
+    # Spools Endpoint Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_spools_not_enabled(self, async_client: AsyncClient):
+        """Verify get spools fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/spools")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify get spools returns data in expected format."""
+        mock_spool = {
+            "id": 1,
+            "remaining_weight": 500,
+            "used_weight": 500,
+            "filament": {
+                "id": 1,
+                "name": "PLA Basic",
+                "material": "PLA",
+                "color_hex": "FF0000",
+            },
+            "first_used": "2024-01-01",
+            "last_used": "2024-01-15",
+            "location": "AMS1",
+            "lot_nr": "LOT123",
+            "comment": "Test spool",
+            "extra": {"tag": '"ABC123"'},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools")
+        assert response.status_code == 200
+        data = response.json()
+        assert "spools" in data
+        assert isinstance(data["spools"], list)
+        assert len(data["spools"]) == 1
+        assert data["spools"][0]["id"] == 1
+
+    # =========================================================================
+    # Unlinked Spools Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_unlinked_spools_not_enabled(self, async_client: AsyncClient):
+        """Verify get unlinked spools fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/spools/unlinked")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_unlinked_spools_success(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify get unlinked spools returns spools without tags."""
+        # Mock spool without extra.tag (unlinked)
+        mock_spool = {
+            "id": 1,
+            "remaining_weight": 800,
+            "used_weight": 200,
+            "extra": {},  # No tag = unlinked
+            "filament": {
+                "id": 1,
+                "name": "PLA Basic",
+                "material": "PLA",
+                "color_hex": "FF0000",
+            },
+            "location": "Shelf A",
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/unlinked")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 1
+        assert data[0]["id"] == 1
+        assert data[0]["filament_name"] == "PLA Basic"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_unlinked_spools_excludes_linked(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools (with tag) are excluded."""
+        # Mock spool with extra.tag (linked)
+        mock_spool_linked = {
+            "id": 1,
+            "remaining_weight": 800,
+            "used_weight": 200,
+            "extra": {"tag": '"ABC123"'},  # Has tag = linked
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"},
+        }
+
+        # Mock spool without tag (unlinked)
+        mock_spool_unlinked = {
+            "id": 2,
+            "remaining_weight": 900,
+            "used_weight": 100,
+            "extra": {},  # No tag = unlinked
+            "filament": {"id": 2, "name": "PLA Blue", "material": "PLA", "color_hex": "0000FF"},
+        }
+
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])
+
+        response = await async_client.get("/api/v1/spoolman/spools/unlinked")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["id"] == 2  # Only unlinked spool
+
+    # =========================================================================
+    # Link Spool Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_not_enabled(self, async_client: AsyncClient):
+        """Verify link spool fails when not enabled."""
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_invalid_uuid_length(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify link spool fails with invalid UUID length."""
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "ABC123"},  # Too short
+        )
+        assert response.status_code == 400
+        assert "32 hex characters" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_invalid_uuid_format(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify link spool fails with non-hex UUID."""
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"},  # Not hex
+        )
+        assert response.status_code == 400
+        assert "hex" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify successfully linking a spool to AMS tray."""
+        mock_spoolman_client.update_spool = AsyncMock(
+            return_value={"id": 1, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
+        )
+
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        assert "linked" in data["message"].lower()
+
+        # Verify update_spool was called
+        mock_spoolman_client.update_spool.assert_called_once()
+
+    # =========================================================================
+    # Sync Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_printer_not_enabled(self, async_client: AsyncClient, printer_factory):
+        """Verify sync fails when Spoolman not enabled."""
+        printer = await printer_factory()
+        response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_printer_not_found(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify sync fails for non-existent printer."""
+        response = await async_client.post("/api/v1/spoolman/sync/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_returns_result_structure(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+        printer_factory,
+    ):
+        """Verify sync returns proper result structure."""
+        printer = await printer_factory()
+
+        # Mock printer manager to return AMS data
+        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
+            mock_state = MagicMock()
+            mock_state.raw_data = {"ams": [{"id": 0, "tray": []}]}
+            pm_mock.get_status = MagicMock(return_value=mock_state)
+
+            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
+            assert response.status_code == 200
+            data = response.json()
+            # Verify SyncResult structure
+            assert "success" in data
+            assert "synced_count" in data
+            assert "skipped_count" in data
+            assert "skipped" in data
+            assert "errors" in data
+            assert isinstance(data["skipped"], list)
+            assert isinstance(data["errors"], list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_printer_not_connected(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+        printer_factory,
+    ):
+        """Verify sync fails when printer is not connected (no status)."""
+        printer = await printer_factory()
+
+        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
+            assert response.status_code == 404
+            assert "not connected" in response.json()["detail"].lower()
+
+    # =========================================================================
+    # Filaments Endpoint Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_filaments_not_enabled(self, async_client: AsyncClient):
+        """Verify get filaments fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/filaments")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_filaments_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify get filaments returns data in expected format."""
+        mock_filament = {
+            "id": 1,
+            "name": "PLA Basic",
+            "material": "PLA",
+            "color_hex": "FF0000",
+            "vendor_id": 1,
+            "weight": 1000,
+        }
+        mock_spoolman_client.get_filaments = AsyncMock(return_value=[mock_filament])
+
+        response = await async_client.get("/api/v1/spoolman/filaments")
+        assert response.status_code == 200
+        data = response.json()
+        assert "filaments" in data
+        assert isinstance(data["filaments"], list)
+        assert len(data["filaments"]) == 1
+        assert data["filaments"][0]["name"] == "PLA Basic"

+ 4 - 0
docker-compose.yml

@@ -1,6 +1,10 @@
 services:
   bambuddy:
+    image: ghcr.io/maziggy/bambuddy:latest
     build: .
+    # Usage:
+    #   docker compose up -d          → pulls pre-built image from ghcr.io
+    #   docker compose up -d --build  → builds locally from source
     container_name: bambuddy
     # Network mode options:
     # - Default (bridge): Works for basic usage, but printer discovery and

+ 196 - 0
docker-publish.sh

@@ -0,0 +1,196 @@
+#!/bin/bash
+# Build and push multi-architecture Docker image to GitHub Container Registry
+#
+# Usage:
+#   ./scripts/docker-publish.sh [version] [--parallel]
+#
+# Examples:
+#   ./scripts/docker-publish.sh 0.1.6           # Sequential build (default)
+#   ./scripts/docker-publish.sh 0.1.6 --parallel # Build both archs simultaneously
+#   ./scripts/docker-publish.sh 0.1.6-beta      # Pre-release (no latest tag)
+#
+# Prerequisites:
+#   1. Log in to ghcr.io first:
+#      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
+#
+#   2. Create a GitHub Personal Access Token with 'write:packages' scope:
+#      https://github.com/settings/tokens/new?scopes=write:packages
+#
+# Supported architectures:
+#   - linux/amd64 (x86_64, most servers/desktops)
+#   - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)
+
+set -e
+
+# Configuration
+REGISTRY="ghcr.io"
+IMAGE_NAME="maziggy/bambuddy"
+FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}"
+PLATFORMS="linux/amd64,linux/arm64"
+BUILDER_NAME="bambuddy-builder"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Parse arguments
+VERSION=""
+PARALLEL=false
+for arg in "$@"; do
+    case $arg in
+        --parallel)
+            PARALLEL=true
+            ;;
+        *)
+            if [ -z "$VERSION" ]; then
+                VERSION="$arg"
+            fi
+            ;;
+    esac
+done
+
+if [ -z "$VERSION" ]; then
+    echo -e "${YELLOW}Usage: $0 <version> [--parallel]${NC}"
+    echo "Example: $0 0.1.6"
+    echo "         $0 0.1.6 --parallel  # Build both architectures simultaneously"
+    exit 1
+fi
+
+# Get CPU count
+CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
+
+echo -e "${GREEN}================================================${NC}"
+echo -e "${GREEN}  Building multi-arch image${NC}"
+echo -e "${GREEN}  ${FULL_IMAGE}:${VERSION}${NC}"
+echo -e "${GREEN}  Platforms: ${PLATFORMS}${NC}"
+echo -e "${GREEN}  CPU cores: ${CPU_COUNT}${NC}"
+if [ "$PARALLEL" = true ]; then
+    echo -e "${GREEN}  Mode: PARALLEL (both archs simultaneously)${NC}"
+else
+    echo -e "${GREEN}  Mode: Sequential (amd64 → arm64)${NC}"
+fi
+echo -e "${GREEN}================================================${NC}"
+echo ""
+
+# Check if logged in to registry
+if ! docker info 2>/dev/null | grep -q "Username"; then
+    echo -e "${YELLOW}Warning: You may not be logged in to Docker registry${NC}"
+    echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
+    echo ""
+fi
+
+# Determine if this is a release version (no pre-release suffix)
+IS_RELEASE=false
+if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    IS_RELEASE=true
+fi
+
+# Setup buildx builder if not exists
+echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
+if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
+    echo "Creating new buildx builder: $BUILDER_NAME"
+    docker buildx create \
+        --name "$BUILDER_NAME" \
+        --driver docker-container \
+        --driver-opt network=host \
+        --buildkitd-flags '--allow-insecure-entitlement network.host' \
+        --bootstrap
+fi
+docker buildx use "$BUILDER_NAME"
+
+# Verify builder supports multi-platform
+echo -e "${BLUE}[2/4] Verifying multi-platform support...${NC}"
+if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
+    echo -e "${YELLOW}Installing QEMU for cross-platform builds...${NC}"
+    docker run --privileged --rm tonistiigi/binfmt --install all
+fi
+
+# Build tags
+TAGS="-t ${FULL_IMAGE}:${VERSION}"
+if [ "$IS_RELEASE" = true ]; then
+    TAGS="$TAGS -t ${FULL_IMAGE}:latest"
+    echo -e "${BLUE}[3/4] Building and pushing (version + latest)...${NC}"
+else
+    echo -e "${BLUE}[3/4] Building and pushing (version only, no latest)...${NC}"
+fi
+
+if [ "$PARALLEL" = true ]; then
+    # Parallel build: Build each architecture separately then combine
+    echo -e "${YELLOW}Building amd64 and arm64 in parallel...${NC}"
+
+    # Build amd64 in background
+    (
+        echo -e "${BLUE}[amd64] Starting build...${NC}"
+        docker buildx build \
+            --platform linux/amd64 \
+            -t "${FULL_IMAGE}:${VERSION}-amd64" \
+            --provenance=false \
+            --push \
+            . 2>&1 | sed 's/^/[amd64] /'
+        echo -e "${GREEN}[amd64] Complete!${NC}"
+    ) &
+    PID_AMD64=$!
+
+    # Build arm64 in background
+    (
+        echo -e "${BLUE}[arm64] Starting build...${NC}"
+        docker buildx build \
+            --platform linux/arm64 \
+            -t "${FULL_IMAGE}:${VERSION}-arm64" \
+            --provenance=false \
+            --push \
+            . 2>&1 | sed 's/^/[arm64] /'
+        echo -e "${GREEN}[arm64] Complete!${NC}"
+    ) &
+    PID_ARM64=$!
+
+    # Wait for both builds
+    echo "Waiting for parallel builds to complete..."
+    wait $PID_AMD64
+    wait $PID_ARM64
+
+    # Create and push multi-arch manifest
+    echo -e "${BLUE}Creating multi-arch manifest...${NC}"
+    docker buildx imagetools create \
+        -t "${FULL_IMAGE}:${VERSION}" \
+        "${FULL_IMAGE}:${VERSION}-amd64" \
+        "${FULL_IMAGE}:${VERSION}-arm64"
+
+    if [ "$IS_RELEASE" = true ]; then
+        docker buildx imagetools create \
+            -t "${FULL_IMAGE}:latest" \
+            "${FULL_IMAGE}:${VERSION}-amd64" \
+            "${FULL_IMAGE}:${VERSION}-arm64"
+    fi
+else
+    # Sequential build (default): Build both platforms in one command
+    DOCKER_BUILDKIT=1 docker buildx build \
+        --platform "$PLATFORMS" \
+        --build-arg BUILDKIT_INLINE_CACHE=1 \
+        --provenance=false \
+        $TAGS \
+        --push \
+        .
+fi
+
+echo -e "${BLUE}[4/4] Verifying manifest...${NC}"
+docker buildx imagetools inspect "${FULL_IMAGE}:${VERSION}"
+
+echo ""
+echo -e "${GREEN}================================================${NC}"
+echo -e "${GREEN}✓ Successfully pushed multi-arch image:${NC}"
+echo -e "${GREEN}================================================${NC}"
+echo "  - ${FULL_IMAGE}:${VERSION}"
+if [ "$IS_RELEASE" = true ]; then
+    echo "  - ${FULL_IMAGE}:latest"
+fi
+echo ""
+echo -e "${BLUE}Supported platforms:${NC}"
+echo "  - linux/amd64 (Intel/AMD servers, desktops)"
+echo "  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
+echo ""
+echo -e "${GREEN}Users can now run:${NC}"
+echo "  docker pull ${FULL_IMAGE}:${VERSION}"

+ 314 - 0
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -0,0 +1,314 @@
+/**
+ * Tests for the SpoolmanSettings component.
+ *
+ * Tests the Spoolman integration UI including:
+ * - Enable/disable toggle
+ * - URL configuration
+ * - Connection status
+ * - Sync functionality
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolmanSettings } from '../../components/SpoolmanSettings';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+    getSpoolmanStatus: vi.fn(),
+    connectSpoolman: vi.fn(),
+    disconnectSpoolman: vi.fn(),
+    syncAllPrintersAms: vi.fn(),
+    syncPrinterAms: vi.fn(),
+    getPrinters: vi.fn(),
+  },
+}));
+
+// Import mocked module
+import { api } from '../../api/client';
+
+// Mock fetch for Spoolman settings endpoints
+const mockFetchResponse = (data: object) => ({
+  ok: true,
+  json: () => Promise.resolve(data),
+});
+
+describe('SpoolmanSettings', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    // Default API mocks
+    vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+      enabled: false,
+      connected: false,
+      url: null,
+    });
+    vi.mocked(api.getPrinters).mockResolvedValue([]);
+    vi.mocked(api.connectSpoolman).mockResolvedValue({ success: true, message: 'Connected' });
+    vi.mocked(api.disconnectSpoolman).mockResolvedValue({ success: true, message: 'Disconnected' });
+    vi.mocked(api.syncAllPrintersAms).mockResolvedValue({
+      success: true,
+      synced_count: 3,
+      skipped_count: 1,
+      skipped: [],
+      errors: [],
+    });
+
+    // Default fetch mock for settings - disabled state
+    global.fetch = vi.fn().mockImplementation((url: string) => {
+      if (url.includes('/api/v1/settings/spoolman')) {
+        return Promise.resolve(
+          mockFetchResponse({
+            spoolman_enabled: 'false',
+            spoolman_url: '',
+            spoolman_sync_mode: 'auto',
+          })
+        );
+      }
+      return Promise.reject(new Error('Unknown URL'));
+    }) as any;
+  });
+
+  describe('rendering', () => {
+    it('renders loading state initially', () => {
+      // Delay the fetch response to catch loading state
+      global.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as any;
+      render(<SpoolmanSettings />);
+
+      // Should show loading spinner
+      expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+    });
+
+    it('renders component title', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+      });
+    });
+
+    it('renders enable toggle', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Enable Spoolman')).toBeInTheDocument();
+      });
+    });
+
+    it('renders URL input', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Spoolman URL')).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
+      });
+    });
+
+    it('renders sync mode selector', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sync Mode')).toBeInTheDocument();
+      });
+    });
+
+    it('renders info banner about sync', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('How Sync Works')).toBeInTheDocument();
+        expect(screen.getByText(/Only official Bambu Lab spools/)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('disabled state', () => {
+    it('URL input is disabled when Spoolman is disabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
+        expect(urlInput).toBeDisabled();
+      });
+    });
+
+    it('sync mode selector is disabled when Spoolman is disabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        // Find the select by its display value
+        const selectElement = screen.getByDisplayValue('Automatic');
+        expect(selectElement).toBeDisabled();
+      });
+    });
+
+    it('does not show connection status when disabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+      });
+
+      // Status section should not be visible when disabled
+      expect(screen.queryByText('Status:')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('enabled state', () => {
+    beforeEach(() => {
+      global.fetch = vi.fn().mockImplementation((url: string) => {
+        if (url.includes('/api/v1/settings/spoolman')) {
+          if (url.includes('PUT') || (url as any).method === 'PUT') {
+            return Promise.resolve(
+              mockFetchResponse({
+                spoolman_enabled: 'true',
+                spoolman_url: 'http://localhost:7912',
+                spoolman_sync_mode: 'auto',
+              })
+            );
+          }
+          return Promise.resolve(
+            mockFetchResponse({
+              spoolman_enabled: 'true',
+              spoolman_url: 'http://localhost:7912',
+              spoolman_sync_mode: 'auto',
+            })
+          );
+        }
+        return Promise.reject(new Error('Unknown URL'));
+      }) as any;
+    });
+
+    it('URL input is enabled when Spoolman is enabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
+        expect(urlInput).not.toBeDisabled();
+      });
+    });
+
+    it('shows connection status section when enabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Status:')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Disconnected when not connected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: false,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Disconnected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Connect button when disconnected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: false,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Connect')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Connected and Disconnect button when connected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Connected')).toBeInTheDocument();
+        expect(screen.getByText('Disconnect')).toBeInTheDocument();
+      });
+    });
+
+    it('shows sync section when connected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
+        expect(screen.getByText('Sync')).toBeInTheDocument();
+      });
+    });
+
+    it('shows All Printers option in sync dropdown', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('sync mode options', () => {
+    it('shows Automatic option', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument();
+      });
+    });
+
+    it('shows Manual Only option', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('info text', () => {
+    it('shows URL help text', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('URL of your Spoolman server (e.g., http://localhost:7912)')
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows sync mode description for auto mode', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('AMS data syncs automatically when changes are detected')
+        ).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 71 - 1
frontend/src/api/client.ts

@@ -163,6 +163,8 @@ export interface PrinterStatus {
   mc_print_sub_stage: number;
   // Timestamp of last AMS data update (for RFID refresh detection)
   last_ams_update: number;
+  // Number of printable objects in current print (for skip objects feature)
+  printable_objects_count: number;
 }
 
 export interface PrinterCreate {
@@ -221,6 +223,7 @@ export interface Archive {
   cost: number | null;
   photos: string[] | null;
   failure_reason: string | null;
+  quantity: number;
   energy_kwh: number | null;
   energy_cost: number | null;
   created_at: string;
@@ -331,6 +334,7 @@ export interface SimilarArchive {
 // Project types
 export interface ProjectStats {
   total_archives: number;
+  total_items: number;  // Sum of quantities (total items printed)
   completed_prints: number;
   failed_prints: number;
   queued_prints: number;
@@ -401,7 +405,8 @@ export interface ProjectListItem {
   status: string;
   target_count: number | null;
   created_at: string;
-  archive_count: number;
+  archive_count: number;  // Number of print jobs
+  total_items: number;  // Sum of quantities (total items printed)
   queue_count: number;
   progress_percent: number | null;
   archives: ArchivePreview[];
@@ -1117,12 +1122,30 @@ export interface SpoolmanStatus {
   url: string | null;
 }
 
+export interface SkippedSpool {
+  location: string;
+  reason: string;
+  filament_type: string | null;
+  color: string | null;
+}
+
 export interface SpoolmanSyncResult {
   success: boolean;
   synced_count: number;
+  skipped_count: number;
+  skipped: SkippedSpool[];
   errors: string[];
 }
 
+export interface UnlinkedSpool {
+  id: number;
+  filament_name: string | null;
+  filament_material: string | null;
+  filament_color_hex: string | null;
+  remaining_weight: number | null;
+  location: string | null;
+}
+
 // Update types
 export interface VersionInfo {
   version: string;
@@ -1275,6 +1298,45 @@ export const api = {
       method: 'POST',
     }),
 
+  // Print Control
+  stopPrint: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/print/stop`, {
+      method: 'POST',
+    }),
+  pausePrint: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/print/pause`, {
+      method: 'POST',
+    }),
+  resumePrint: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
+      method: 'POST',
+    }),
+
+  // Skip Objects
+  getPrintableObjects: (printerId: number) =>
+    request<{
+      objects: Array<{ id: number; name: string; x: number | null; y: number | null; skipped: boolean }>;
+      total: number;
+      skipped_count: number;
+      is_printing: boolean;
+    }>(`/printers/${printerId}/print/objects`),
+
+  skipObjects: (printerId: number, objectIds: number[]) =>
+    request<{ success: boolean; message: string; skipped_objects: number[] }>(
+      `/printers/${printerId}/print/skip-objects`,
+      {
+        method: 'POST',
+        body: JSON.stringify(objectIds),
+      }
+    ),
+
+  // AMS Control
+  refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/ams/${amsId}/slot/${slotId}/refresh`,
+      { method: 'POST' }
+    ),
+
   // MQTT Debug Logging
   enableMQTTLogging: (printerId: number) =>
     request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
@@ -1348,6 +1410,7 @@ export const api = {
     cost?: number;
     failure_reason?: string | null;
     status?: string;
+    quantity?: number;
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',
@@ -1996,6 +2059,13 @@ export const api = {
     request<{ spools: unknown[] }>('/spoolman/spools'),
   getSpoolmanFilaments: () =>
     request<{ filaments: unknown[] }>('/spoolman/filaments'),
+  getUnlinkedSpools: () =>
+    request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
+  linkSpool: (spoolId: number, trayUuid: string) =>
+    request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {
+      method: 'POST',
+      body: JSON.stringify({ tray_uuid: trayUuid }),
+    }),
 
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),

+ 22 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban } from 'lucide-react';
+import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
@@ -49,6 +49,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [tags, setTags] = useState(archive.tags || '');
   const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
   const [status, setStatus] = useState(archive.status);
+  const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
   const [uploadingPhoto, setUploadingPhoto] = useState(false);
   const [showTagSuggestions, setShowTagSuggestions] = useState(false);
@@ -153,6 +154,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       project_id: projectId,
       notes: notes || undefined,
       tags: tags || undefined,
+      quantity: quantity,
     };
 
     // Only include status if changed
@@ -242,6 +244,25 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             </select>
           </div>
 
+          {/* Quantity - number of items printed */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <Hash className="w-4 h-4 inline mr-1" />
+              Items Printed
+            </label>
+            <input
+              type="number"
+              min={1}
+              value={quantity}
+              onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
+              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"
+              placeholder="1"
+            />
+            <p className="text-xs text-bambu-gray mt-1">
+              Number of items produced in this print job
+            </p>
+          </div>
+
           {/* Notes */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1">Notes</label>

+ 88 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { Droplets } from 'lucide-react';
+import { Droplets, Link2, Copy, Check } from 'lucide-react';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -8,6 +8,12 @@ interface FilamentData {
   colorHex: string | null;
   kFactor: string;
   fillLevel: number | null; // null = unknown
+  trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
+}
+
+interface SpoolmanConfig {
+  enabled: boolean;
+  onLinkSpool?: (trayUuid: string) => void;
 }
 
 interface FilamentHoverCardProps {
@@ -15,19 +21,56 @@ interface FilamentHoverCardProps {
   children: ReactNode;
   disabled?: boolean;
   className?: string;
+  spoolman?: SpoolmanConfig;
 }
 
 /**
  * A hover card that displays filament details when hovering over AMS slots.
  * Replaces the basic browser tooltip with a styled popover.
  */
-export function FilamentHoverCard({ data, children, disabled, className = '' }: FilamentHoverCardProps) {
+export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) {
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
+  const [copied, setCopied] = useState(false);
   const triggerRef = useRef<HTMLDivElement>(null);
   const cardRef = useRef<HTMLDivElement>(null);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
+  const handleCopyUuid = () => {
+    const uuid = data.trayUuid;
+    if (!uuid) return;
+
+    // Try modern clipboard API first, fallback to execCommand
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard.writeText(uuid).then(() => {
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
+      }).catch(() => {
+        // Fallback on error
+        fallbackCopy(uuid);
+      });
+    } else {
+      fallbackCopy(uuid);
+    }
+  };
+
+  const fallbackCopy = (text: string) => {
+    const textarea = document.createElement('textarea');
+    textarea.value = text;
+    textarea.style.position = 'fixed';
+    textarea.style.opacity = '0';
+    document.body.appendChild(textarea);
+    textarea.select();
+    try {
+      document.execCommand('copy');
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      console.error('Failed to copy to clipboard');
+    }
+    document.body.removeChild(textarea);
+  };
+
   // Calculate position when showing
   useEffect(() => {
     if (isVisible && triggerRef.current && cardRef.current) {
@@ -192,6 +235,49 @@ export function FilamentHoverCard({ data, children, disabled, className = '' }:
                   )}
                 </div>
               </div>
+
+              {/* Spoolman section - only show if enabled */}
+              {spoolman?.enabled && data.trayUuid && (
+                <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
+                  {/* Tray UUID with copy button */}
+                  <div className="flex items-center justify-between">
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                      Spool ID
+                    </span>
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        handleCopyUuid();
+                      }}
+                      className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                      title="Copy spool UUID"
+                    >
+                      <span className="font-mono text-[10px] truncate max-w-[80px]">
+                        {data.trayUuid.slice(0, 8)}...
+                      </span>
+                      {copied ? (
+                        <Check className="w-3 h-3 text-bambu-green" />
+                      ) : (
+                        <Copy className="w-3 h-3" />
+                      )}
+                    </button>
+                  </div>
+
+                  {/* Link Spool button */}
+                  {spoolman.onLinkSpool && (
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        spoolman.onLinkSpool?.(data.trayUuid!);
+                      }}
+                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green text-xs font-medium rounded transition-colors"
+                    >
+                      <Link2 className="w-3.5 h-3.5" />
+                      Link to Spoolman
+                    </button>
+                  )}
+                </div>
+              )}
             </div>
           </div>
 

+ 183 - 0
frontend/src/components/LinkSpoolModal.tsx

@@ -0,0 +1,183 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Loader2, Link2, Check } from 'lucide-react';
+import { api } from '../api/client';
+import { Button } from './Button';
+
+interface LinkSpoolModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  trayUuid: string;
+  trayInfo?: {
+    type: string;
+    color: string;
+    location: string;
+  };
+}
+
+export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
+  const queryClient = useQueryClient();
+  const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
+
+  // Fetch unlinked spools
+  const { data: unlinkedSpools, isLoading } = useQuery({
+    queryKey: ['unlinked-spools'],
+    queryFn: api.getUnlinkedSpools,
+    enabled: isOpen,
+  });
+
+  // Link mutation
+  const linkMutation = useMutation({
+    mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      onClose();
+    },
+  });
+
+  if (!isOpen) return null;
+
+  const handleLink = () => {
+    if (selectedSpoolId) {
+      linkMutation.mutate(selectedSpoolId);
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      {/* Modal */}
+      <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <Link2 className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 space-y-4">
+          {/* Tray info */}
+          {trayInfo && (
+            <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+              <p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
+              <div className="flex items-center gap-2">
+                {trayInfo.color && (
+                  <span
+                    className="w-4 h-4 rounded-full border border-white/20"
+                    style={{ backgroundColor: `#${trayInfo.color}` }}
+                  />
+                )}
+                <span className="text-white font-medium">{trayInfo.type}</span>
+                <span className="text-bambu-gray">({trayInfo.location})</span>
+              </div>
+            </div>
+          )}
+
+          {/* Spool UUID */}
+          <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
+            <code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
+          </div>
+
+          {/* Spool list */}
+          <div>
+            <p className="text-sm text-bambu-gray mb-2">
+              Select a Spoolman spool to link:
+            </p>
+
+            {isLoading ? (
+              <div className="flex justify-center py-8">
+                <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+              </div>
+            ) : unlinkedSpools && unlinkedSpools.length > 0 ? (
+              <div className="max-h-64 overflow-y-auto space-y-2">
+                {unlinkedSpools.map((spool) => (
+                  <button
+                    key={spool.id}
+                    onClick={() => setSelectedSpoolId(spool.id)}
+                    className={`w-full p-3 rounded-lg border text-left transition-colors ${
+                      selectedSpoolId === spool.id
+                        ? 'bg-bambu-green/20 border-bambu-green'
+                        : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
+                    }`}
+                  >
+                    <div className="flex items-center gap-2">
+                      {spool.filament_color_hex && (
+                        <span
+                          className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
+                          style={{ backgroundColor: `#${spool.filament_color_hex}` }}
+                        />
+                      )}
+                      <div className="flex-1 min-w-0">
+                        <p className="text-white font-medium truncate">
+                          {spool.filament_name || 'Unknown filament'}
+                        </p>
+                        <p className="text-xs text-bambu-gray">
+                          {spool.filament_material || 'Unknown'}
+                          {spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
+                          {spool.location && ` - ${spool.location}`}
+                        </p>
+                      </div>
+                      {selectedSpoolId === spool.id && (
+                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </div>
+                  </button>
+                ))}
+              </div>
+            ) : (
+              <div className="text-center py-8 text-bambu-gray">
+                <p>No unlinked spools found in Spoolman.</p>
+                <p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose}>
+            Cancel
+          </Button>
+          <Button
+            onClick={handleLink}
+            disabled={!selectedSpoolId || linkMutation.isPending}
+          >
+            {linkMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                Linking...
+              </>
+            ) : (
+              <>
+                <Link2 className="w-4 h-4" />
+                Link Spool
+              </>
+            )}
+          </Button>
+        </div>
+
+        {/* Error */}
+        {linkMutation.isError && (
+          <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+            {(linkMutation.error as Error).message}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 87 - 11
frontend/src/components/SpoolmanSettings.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react';
+import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react';
 import { api } from '../api/client';
 import type { SpoolmanSyncResult, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
@@ -40,6 +40,7 @@ export function SpoolmanSettings() {
   const [showSaved, setShowSaved] = useState(false);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
   const [isInitialized, setIsInitialized] = useState(false);
+  const [showAllSkipped, setShowAllSkipped] = useState(false);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -192,6 +193,25 @@ export function SpoolmanSettings() {
           Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
         </p>
 
+        {/* Info banner about sync requirements */}
+        <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+          <div className="flex gap-2">
+            <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+            <div className="text-xs text-blue-300">
+              <p className="font-medium mb-1">How Sync Works</p>
+              <ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
+                <li>Only official Bambu Lab spools with RFID are synced</li>
+                <li>New spools are auto-created in Spoolman on first sync</li>
+                <li>Non-Bambu Lab spools (third-party, refilled) are skipped</li>
+              </ul>
+              <p className="font-medium mt-2 mb-1">Linking Existing Spools</p>
+              <p className="text-blue-300/80">
+                To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".
+              </p>
+            </div>
+          </div>
+        </div>
+
         {/* Enable toggle */}
         <div className="flex items-center justify-between">
           <div>
@@ -355,16 +375,72 @@ export function SpoolmanSettings() {
 
             {/* Sync result */}
             {syncSuccess && syncResult && (
-              <div
-                className={`mt-2 p-2 rounded text-sm ${
-                  syncResult.success
-                    ? 'bg-green-500/20 border border-green-500/50 text-green-400'
-                    : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
-                }`}
-              >
-                {syncResult.success
-                  ? `Synced ${syncResult.synced_count} trays successfully`
-                  : `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`}
+              <div className="mt-3 space-y-2">
+                {/* Main result */}
+                <div
+                  className={`p-2 rounded text-sm ${
+                    syncResult.success
+                      ? 'bg-green-500/20 border border-green-500/50 text-green-400'
+                      : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
+                  }`}
+                >
+                  {syncResult.success
+                    ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
+                    : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
+                </div>
+
+                {/* Skipped spools */}
+                {syncResult.skipped_count > 0 && (
+                  <div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
+                    <div className="flex items-center justify-between text-amber-400 mb-1">
+                      <div className="flex items-center gap-1.5">
+                        <AlertTriangle className="w-3.5 h-3.5" />
+                        <span className="font-medium">
+                          {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
+                        </span>
+                      </div>
+                      {syncResult.skipped_count > 5 && (
+                        <button
+                          onClick={() => setShowAllSkipped(!showAllSkipped)}
+                          className="text-xs text-amber-400 hover:text-amber-300 underline"
+                        >
+                          {showAllSkipped ? 'Show less' : 'Show all'}
+                        </button>
+                      )}
+                    </div>
+                    <ul className="text-xs text-amber-300/80 space-y-0.5">
+                      {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
+                        <li key={i} className="flex items-center gap-2">
+                          {s.color && (
+                            <span
+                              className="w-3 h-3 rounded-full border border-white/20"
+                              style={{ backgroundColor: `#${s.color}` }}
+                            />
+                          )}
+                          <span>{s.location}</span>
+                          <span className="text-amber-300/60">- {s.reason}</span>
+                        </li>
+                      ))}
+                      {!showAllSkipped && syncResult.skipped_count > 5 && (
+                        <li className="text-amber-300/60 italic">
+                          ...and {syncResult.skipped_count - 5} more
+                        </li>
+                      )}
+                    </ul>
+                  </div>
+                )}
+
+                {/* Errors */}
+                {syncResult.errors.length > 0 && (
+                  <div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
+                    <div className="text-red-400 font-medium mb-1">Errors:</div>
+                    <ul className="text-xs text-red-300/80 space-y-0.5">
+                      {syncResult.errors.map((err, i) => (
+                        <li key={i}>{err}</li>
+                      ))}
+                    </ul>
+                  </div>
+                )}
               </div>
             )}
           </div>

+ 11 - 0
frontend/src/hooks/useWebSocket.ts

@@ -188,7 +188,18 @@ export function useWebSocket() {
         }
         break;
 
+      case 'print_start':
+        // Refetch printer status immediately when print starts to get printable_objects_count
+        if (message.printer_id !== undefined) {
+          queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
+        }
+        break;
+
       case 'print_complete':
+        // Refetch printer status when print completes to clear printable_objects_count
+        if (message.printer_id !== undefined) {
+          queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
+        }
         debouncedInvalidate('archives');
         debouncedInvalidate('archiveStats');
         break;

File diff suppressed because it is too large
+ 949 - 120
frontend/src/pages/PrintersPage.tsx


+ 8 - 7
frontend/src/pages/ProjectDetailPage.tsx

@@ -434,8 +434,8 @@ export function ProjectDetailPage() {
 
   const stats = project.stats;
   const progressPercent = stats?.progress_percent ?? 0;
-  const successRate = stats && stats.total_archives > 0
-    ? ((stats.completed_prints / stats.total_archives) * 100).toFixed(0)
+  const successRate = stats && stats.total_items > 0
+    ? ((stats.completed_prints / stats.total_items) * 100).toFixed(0)
     : null;
 
   return (
@@ -485,7 +485,7 @@ export function ProjectDetailPage() {
             <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} prints
+                {stats?.completed_prints || 0} / {project.target_count} items
               </span>
             </div>
             <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
@@ -516,16 +516,16 @@ export function ProjectDetailPage() {
         <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
           <StatCard
             icon={Package}
-            label="Total Prints"
-            value={stats.total_archives}
-            subValue={successRate ? `${successRate}% success rate` : undefined}
+            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` : undefined}
+            subValue={stats.failed_prints > 0 ? `${stats.failed_prints} failed` : (successRate ? `${successRate}% success` : undefined)}
             color="text-blue-400"
           />
           <StatCard
@@ -1143,6 +1143,7 @@ export function ProjectDetailPage() {
           project={{
             ...project,
             archive_count: stats?.total_archives || 0,
+            total_items: stats?.total_items || 0,
             queue_count: stats?.queued_prints || 0,
             progress_percent: stats?.progress_percent || null,
             archives: [],

+ 11 - 9
frontend/src/pages/ProjectsPage.tsx

@@ -268,11 +268,11 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                       ? 'bg-bambu-green/20 text-bambu-green'
                       : 'bg-bambu-dark text-bambu-gray'
                   }`}>
-                    {project.archive_count}/{project.target_count} parts
+                    {project.total_items}/{project.target_count} items
                   </span>
-                ) : project.archive_count > 0 ? (
+                ) : project.total_items > 0 ? (
                   <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
-                    {project.archive_count} print{project.archive_count !== 1 ? 's' : ''}
+                    {project.total_items} item{project.total_items !== 1 ? 's' : ''}
                   </span>
                 ) : null}
                 {isCompleted && (
@@ -365,7 +365,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
               <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.archive_count} / {project.target_count}
+                  {project.total_items} / {project.target_count}
                 </span>
               </div>
               <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
@@ -384,11 +384,11 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                 {progressPercent.toFixed(0)}% complete
               </div>
             </>
-          ) : project.archive_count > 0 ? (
+          ) : project.total_items > 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.archive_count} print{project.archive_count !== 1 ? 's' : ''} completed</span>
+                <span>{project.total_items} item{project.total_items !== 1 ? 's' : ''} completed</span>
               </div>
               {project.queue_count > 0 && (
                 <div className="flex items-center gap-1.5 text-blue-400">
@@ -444,9 +444,9 @@ 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="Completed prints">
+            <div className="flex items-center gap-1.5" title="Total items printed">
               <Archive className="w-3.5 h-3.5" />
-              <span>{project.archive_count}</span>
+              <span>{project.total_items}</span>
             </div>
             {project.queue_count > 0 && (
               <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
@@ -505,11 +505,13 @@ export function ProjectsPage() {
   const deleteMutation = useMutation({
     mutationFn: (id: number) => api.deleteProject(id),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['projects'] });
       setDeleteConfirm(null);
       showToast('Project deleted', 'success');
+      // Reload to refresh the list (React Query cache invalidation not working reliably)
+      setTimeout(() => window.location.reload(), 100);
     },
     onError: (error: Error) => {
+      setDeleteConfirm(null);
       showToast(error.message, 'error');
     },
   });

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


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


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


+ 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-3umWYOC3.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CCbBv2VC.css">
+    <script type="module" crossorigin src="/assets/index-BaU_6oPV.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Da3qKIoX.css">
   </head>
   <body>
     <div id="root"></div>

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