mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Executable
+196
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+979
-122
File diff suppressed because it is too large
Load Diff
@@ -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: [],
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user