diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6e83468a..f2e7a1cfa 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/README.md b/README.md
index e0ac935ee..f1bad46e1 100644
--- a/README.md
+++ b/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
-git clone https://github.com/maziggy/bambuddy.git
-cd bambuddy
+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 --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).
+
+ Number of items produced in this print job
+ Linking AMS tray: Spool UUID:
+ Select a Spoolman spool to link:
+ No unlinked spools found in Spoolman. All spools are already linked to AMS trays.Docker Configuration & Commands
@@ -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
```
diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py
index 9ec23f944..d16f2ba6b 100644
--- a/backend/app/api/routes/archives.py
+++ b/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,
}
diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py
index 30ecdbf34..914572f46 100644
--- a/backend/app/api/routes/printers.py
+++ b/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}
diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py
index c43a4a575..a2d0b12b6 100644
--- a/backend/app/api/routes/projects.py
+++ b/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(
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index fc266f05f..7ec9d22d5 100644
--- a/backend/app/api/routes/settings.py
+++ b/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"),
diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py
index a616f1804..9ee600704 100644
--- a/backend/app/api/routes/spoolman.py
+++ b/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
- # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+ # 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) - 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")
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 743f474a1..f2987d049 100644
--- a/backend/app/core/config.py
+++ b/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)
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 5f9798c3c..cba9269c4 100644
--- a/backend/app/core/database.py
+++ b/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."""
diff --git a/backend/app/main.py b/backend/app/main.py
index 64e5d87c4..ffb708250 100644
--- a/backend/app/main.py
+++ b/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()
diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py
index d8971009b..88e26ac2a 100644
--- a/backend/app/models/archive.py
+++ b/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")
diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py
index 5e76382c9..eb842a055 100644
--- a/backend/app/schemas/archive.py
+++ b/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
diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py
index f46065646..1459907c5 100644
--- a/backend/app/schemas/printer.py
+++ b/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
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
index 2fa8a5c14..49c5c87c6 100644
--- a/backend/app/schemas/project.py
+++ b/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)
diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py
index e4a913280..4416d0f0b 100644
--- a/backend/app/services/archive.py
+++ b/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
+ 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 Link to Spoolman
+ {trayUuid}
+
How Sync Works
+Linking Existing Spools
++ To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman". +
+Left / Right
-- {Math.round(status.temperatures.nozzle || 0)}°C / {Math.round(status.temperatures.nozzle_2 || 0)}°C -
- > - ) : ( - <> -Nozzle
-- {Math.round(status.temperatures.nozzle || 0)}°C -
- > - )} -Bed
-- {Math.round(status.temperatures.bed || 0)}°C -
-Chamber
-
- {Math.round(status.temperatures.chamber || 0)}°C
+
L / R
++ {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}° +
+ > + ) : ( + <> +Nozzle
++ {Math.round(status.temperatures.nozzle || 0)}°C +
+ > + )} +Bed
++ {Math.round(status.temperatures.bed || 0)}°C
Chamber
++ {Math.round(status.temperatures.chamber || 0)}°C +
+No objects found
+Objects are loaded when a print starts
+Match IDs with your printer display
+The printer screen shows object IDs on the build plate
++ Wait for layer 2+ to skip objects (currently layer {status?.layer_num ?? 0}) +
+sn?(Sn=It,It=null):Sn=It.sibling;var jn=tt($e,It,Qe[sn],ht);if(jn===null){It===null&&(It=Sn);break}h&&It&&jn.alternate===null&&m($e,It),Re=D(jn,Re,sn),Pn===null?Bt=jn:Pn.sibling=jn,Pn=jn,It=Sn}if(sn===Qe.length)return _($e,It),Tn&&Ul($e,sn),Bt;if(It===null){for(;sn