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
This commit is contained in:
MartinNYHC
2026-01-02 11:28:26 +01:00
committed by GitHub
37 changed files with 4085 additions and 691 deletions
+68
View File
@@ -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
+21 -3
View File
@@ -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).
<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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
@@ -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])
@@ -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
View File
@@ -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
View File
@@ -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}"
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 Load Diff
+8 -7
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>