diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e83468a..f2e7a1cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,74 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.6b5] - 2026-01-02 + +### Added +- **Pre-built Docker images** - Ready-to-use container images on GitHub Container Registry: + - Pull directly: `docker pull ghcr.io/maziggy/bambuddy:latest` + - Multi-architecture support: `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5) + - No build required - just `docker compose up -d` + - Automatic architecture detection - Docker pulls the right image for your system +- **Spoolman Link Spool feature** - Manually link existing Spoolman spools to AMS trays: + - Hover over any AMS slot to see "Link to Spoolman" button (when Spoolman enabled) + - Select from list of unlinked Spoolman spools + - Automatically sets the `extra.tag` field in Spoolman for proper sync + - Useful for connecting existing Spoolman inventory to physical spools +- **Spool UUID display** - View and copy Bambu Lab spool UUID in AMS hover card: + - Shows first 8 chars of UUID with copy button + - Full UUID copied to clipboard (with fallback for HTTP contexts) + - Only visible when Spoolman integration is enabled +- **Spoolman sync feedback** - Improved sync result display: + - Shows count of successfully synced spools + - Lists skipped spools with reasons (e.g., "Non-Bambu Lab spool") + - Expandable list when more than 5 spools skipped + - Color swatches for easy identification +- **Printer control buttons** - Stop and Pause/Resume buttons on printer cards when printing: + - Stop button cancels the current print job + - Pause/Resume toggle for pausing and resuming prints + - Confirmation modals for all actions to prevent accidental clicks + - Toast notifications for action feedback +- **Skip objects** - Skip individual objects during a print: + - Skip button in print status section (top right) when printing with 2+ objects + - Modal shows preview image with object ID markers overlaid + - Large ID badges to easily match with printer display + - Click to skip any object - it will not be printed + - Skipped objects shown with strikethrough styling and red badge on button + - Skip only available after layer 1 (printer limitation) with warning message + - Objects automatically loaded when print starts from 3MF metadata + - Parses skipped objects from printer MQTT for state persistence + - Light and dark theme support + - Close with ESC key or click outside + - Requires "Exclude Objects" option enabled in slicer +- **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots: + - Menu button (⋮) appears on hover over AMS slots + - "Re-read RFID" option triggers filament info refresh + - Loading indicator shows while re-read is in progress + - Automatically tracks printer status to clear indicator when complete + - Menu hidden when printer is busy (printing) +- **Print quantity tracking** - Track number of items per print job for project progress: + - Set "Items Printed" quantity when editing archived prints + - Project stats now show total items vs print jobs + - Progress bar tracks items toward target count + - Useful for batch printing (e.g., 10 copies in one print = 10 items) + - Default quantity of 1 for backwards compatibility + +### Changed +- **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons +- **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal +- **Spoolman info banner** - Updated settings UI with clearer sync documentation + +### Fixed +- **Spoolman spool creation** - Fixed 400 Bad Request error when creating new spools: + - Spoolman `extra` field values must be valid JSON + - Now properly JSON-encodes the `tag` value + - Affects both auto-sync and manual link operations + +### Tests +- Added integration tests for printer control endpoints (stop, pause, resume) +- Added integration tests for AMS slot refresh endpoint +- Added integration tests for skip objects endpoints (get objects, skip objects, objects with positions) + ## [0.1.6b4] - 2026-01-01 ### Added diff --git a/README.md b/README.md index e0ac935ee..f1bad46e1 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,12 @@ - Re-print to any connected printer with AMS filament preview - Archive comparison (side-by-side diff) -### 📊 Monitoring & Stats +### 📊 Monitoring & Control - Real-time printer status via WebSocket - Live camera streaming (MJPEG) & snapshots +- Printer control (stop, pause, resume) +- Skip objects during print +- AMS slot RFID re-read - HMS error monitoring with history - Print success rates & trends - Filament usage tracking @@ -72,6 +75,7 @@ ### 📁 Projects - Group related prints (e.g., "Voron Build") - Track progress with target counts +- Quantity tracking for batch prints - Color-coded project badges - Assign archives via context menu @@ -233,14 +237,24 @@ #### Docker (Recommended) +**Option A: Pre-built image (fastest)** ```bash -git clone https://github.com/maziggy/bambuddy.git -cd bambuddy +mkdir bambuddy && cd bambuddy +curl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml docker compose up -d ``` +**Option B: Build from source** +```bash +git clone https://github.com/maziggy/bambuddy.git +cd bambuddy +docker compose up -d --build +``` + Open **http://localhost:8000** in your browser. +> **Multi-architecture support:** Pre-built images are available for `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5). +
Docker Configuration & Commands @@ -263,6 +277,10 @@ Open **http://localhost:8000** in your browser. **Updating:** ```bash +# Pre-built image: just pull the latest +docker compose pull && docker compose up -d + +# From source: rebuild after pulling changes cd bambuddy && git pull && docker compose up -d --build ``` diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 9ec23f944..d16f2ba6b 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -89,6 +89,9 @@ def archive_to_response( "cost": archive.cost, "photos": archive.photos, "failure_reason": archive.failure_reason, + "quantity": archive.quantity, + "energy_kwh": archive.energy_kwh, + "energy_cost": archive.energy_cost, "created_at": archive.created_at, } diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 30ecdbf34..914572f46 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -152,9 +152,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) connected=False, ) - # Determine cover URL if there's an active print + # Determine cover URL if there's an active print (including paused) cover_url = None - if state.state == "RUNNING" and state.gcode_file: + if state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file: cover_url = f"/api/v1/printers/{printer_id}/cover" # Convert HMS errors to response format @@ -359,6 +359,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) ams_status_sub=state.ams_status_sub, mc_print_sub_stage=state.mc_print_sub_stage, last_ams_update=state.last_ams_update, + printable_objects_count=len(state.printable_objects), ) @@ -416,13 +417,22 @@ async def test_printer_connection( return result -# Cache for cover images (printer_id -> (gcode_file, image_bytes)) -_cover_cache: dict[int, tuple[str, bytes]] = {} +# Cache for cover images (printer_id -> {(gcode_file, view) -> image_bytes}) +_cover_cache: dict[int, dict[tuple[str, str], bytes]] = {} @router.get("/{printer_id}/cover") -async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)): - """Get the cover image for the current print job.""" +async def get_printer_cover( + printer_id: int, + view: str | None = None, + db: AsyncSession = Depends(get_db), +): + """Get the cover image for the current print job. + + Args: + view: Optional view type. Use "top" for top-down build plate view (useful for skip objects). + Default returns angled 3D perspective view. + """ result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() if not printer: @@ -437,11 +447,14 @@ async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)) if not subtask_name: raise HTTPException(404, f"No subtask_name in printer state (state={state.state})") + # Normalize view parameter + view_key = view or "default" + # Check cache if printer_id in _cover_cache: - cached_file, cached_image = _cover_cache[printer_id] - if cached_file == subtask_name: - return Response(content=cached_image, media_type="image/png") + cache_key = (subtask_name, view_key) + if cache_key in _cover_cache[printer_id]: + return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png") # Build 3MF filename from subtask_name # Bambu printers store files as "name.gcode.3mf" @@ -500,19 +513,33 @@ async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)) try: # Try common thumbnail paths in 3MF files - thumbnail_paths = [ - "Metadata/plate_1.png", - "Metadata/thumbnail.png", - "Metadata/plate_1_small.png", - "Thumbnails/thumbnail.png", - "thumbnail.png", - ] + # Use top-down view if requested (better for skip objects modal) + if view == "top": + thumbnail_paths = [ + "Metadata/top_1.png", + "Metadata/top_2.png", + "Metadata/top_3.png", + "Metadata/top_4.png", + # Fall back to regular views if no top view + "Metadata/plate_1.png", + "Metadata/thumbnail.png", + ] + else: + thumbnail_paths = [ + "Metadata/plate_1.png", + "Metadata/thumbnail.png", + "Metadata/plate_1_small.png", + "Thumbnails/thumbnail.png", + "thumbnail.png", + ] for thumb_path in thumbnail_paths: try: image_data = zf.read(thumb_path) # Cache the result - _cover_cache[printer_id] = (subtask_name, image_data) + if printer_id not in _cover_cache: + _cover_cache[printer_id] = {} + _cover_cache[printer_id][(subtask_name, view_key)] = image_data return Response(content=image_data, media_type="image/png") except KeyError: continue @@ -521,7 +548,9 @@ async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)) for name in zf.namelist(): if name.startswith("Metadata/") and name.endswith(".png"): image_data = zf.read(name) - _cover_cache[printer_id] = (subtask_name, image_data) + if printer_id not in _cover_cache: + _cover_cache[printer_id] = {} + _cover_cache[printer_id][(subtask_name, view_key)] = image_data return Response(content=image_data, media_type="image/png") raise HTTPException(404, "No thumbnail found in 3MF file") @@ -1011,3 +1040,231 @@ async def debug_simulate_print_complete( await on_print_complete(printer_id, data) return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"} + + +# ============================================================================= +# Print Control Endpoints +# ============================================================================= + + +@router.post("/{printer_id}/print/stop") +async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)): + """Stop/cancel the current print job.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(400, "Printer not connected") + + success = client.stop_print() + if not success: + raise HTTPException(500, "Failed to stop print") + + return {"success": True, "message": "Print stop command sent"} + + +@router.post("/{printer_id}/print/pause") +async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)): + """Pause the current print job.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(400, "Printer not connected") + + success = client.pause_print() + if not success: + raise HTTPException(500, "Failed to pause print") + + return {"success": True, "message": "Print pause command sent"} + + +@router.post("/{printer_id}/print/resume") +async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)): + """Resume a paused print job.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(400, "Printer not connected") + + success = client.resume_print() + if not success: + raise HTTPException(500, "Failed to resume print") + + return {"success": True, "message": "Print resume command sent"} + + +@router.get("/{printer_id}/print/objects") +async def get_printable_objects( + printer_id: int, + reload: bool = False, + db: AsyncSession = Depends(get_db), +): + """Get the list of printable objects for the current print. + + Returns a list of objects with id, name, position (if available), and skip status. + Objects that have already been skipped are marked in the skipped_objects list. + + Args: + reload: If True, reload objects from the archive file (useful after restart) + """ + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(400, "Printer not connected") + + # Reload objects from 3MF if requested or no objects loaded + if reload or not client.state.printable_objects: + subtask_name = client.state.subtask_name + if subtask_name: + from backend.app.services.archive import extract_printable_objects_from_3mf + from backend.app.services.bambu_ftp import download_file_try_paths_async + + # Build 3MF filename + filename = subtask_name + if not filename.endswith(".3mf"): + filename = filename + ".gcode.3mf" + + # Download 3MF from printer + temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{filename}" + temp_path.parent.mkdir(parents=True, exist_ok=True) + + remote_paths = [f"/{filename}", f"/cache/{filename}", f"/model/{filename}"] + + try: + downloaded = await download_file_try_paths_async( + printer.ip_address, printer.access_code, remote_paths, temp_path + ) + if downloaded and temp_path.exists(): + with open(temp_path, "rb") as f: + data = f.read() + objects = extract_printable_objects_from_3mf(data, include_positions=True) + if objects: + client.state.printable_objects = objects + logger.info(f"Reloaded {len(objects)} objects for printer {printer_id}") + except Exception as e: + logger.debug(f"Failed to reload objects from printer: {e}") + finally: + if temp_path.exists(): + temp_path.unlink() + + # Return objects with their skip status and position data + objects = [] + for obj_id, obj_data in client.state.printable_objects.items(): + # Handle both old format (string name) and new format (dict with name, x, y) + if isinstance(obj_data, dict): + obj_entry = { + "id": obj_id, + "name": obj_data.get("name", f"Object {obj_id}"), + "x": obj_data.get("x"), + "y": obj_data.get("y"), + "skipped": obj_id in client.state.skipped_objects, + } + else: + # Legacy format: obj_data is just the name string + obj_entry = { + "id": obj_id, + "name": obj_data, + "x": None, + "y": None, + "skipped": obj_id in client.state.skipped_objects, + } + objects.append(obj_entry) + + return { + "objects": objects, + "total": len(objects), + "skipped_count": len(client.state.skipped_objects), + "is_printing": client.state.state in ("RUNNING", "PAUSE"), + } + + +@router.post("/{printer_id}/print/skip-objects") +async def skip_objects( + printer_id: int, + object_ids: list[int], + db: AsyncSession = Depends(get_db), +): + """Skip specific objects during the current print. + + Args: + object_ids: List of object identify_id values to skip + """ + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(400, "Printer not connected") + + if not object_ids: + raise HTTPException(400, "No object IDs provided") + + # Validate object IDs exist in printable_objects + invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects] + if invalid_ids: + raise HTTPException(400, f"Invalid object IDs: {invalid_ids}") + + success = client.skip_objects(object_ids) + if not success: + raise HTTPException(500, "Failed to skip objects") + + # Get names of skipped objects for response (handle both old and new format) + skipped_names = [] + for oid in object_ids: + obj_data = client.state.printable_objects.get(oid, str(oid)) + if isinstance(obj_data, dict): + skipped_names.append(obj_data.get("name", str(oid))) + else: + skipped_names.append(obj_data) + + return { + "success": True, + "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}", + "skipped_objects": object_ids, + } + + +# ============================================================================= +# AMS Control Endpoints +# ============================================================================= + + +@router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh") +async def refresh_ams_slot( + printer_id: int, + ams_id: int, + slot_id: int, + db: AsyncSession = Depends(get_db), +): + """Re-read RFID for an AMS slot (triggers filament info refresh).""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(400, "Printer not connected") + + success, message = client.ams_refresh_tray(ams_id, slot_id) + if not success: + raise HTTPException(400, message) + + return {"success": True, "message": message} diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index c43a4a575..a2d0b12b6 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -39,21 +39,27 @@ router = APIRouter(prefix="/projects", tags=["projects"]) async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats: """Compute statistics for a project.""" - # Count total archives + # Count total archives (distinct print jobs) total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)) total_archives = total_result.scalar() or 0 - # Count completed archives + # Sum total items (using quantity field) + total_items_result = await db.execute( + select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id) + ) + total_items = total_items_result.scalar() or 0 + + # Sum completed items (using quantity field) completed_result = await db.execute( - select(func.count(PrintArchive.id)).where( + select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( PrintArchive.project_id == project_id, PrintArchive.status == "completed" ) ) completed_prints = completed_result.scalar() or 0 - # Count failed archives + # Sum failed items (using quantity field) failed_result = await db.execute( - select(func.count(PrintArchive.id)).where( + select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( PrintArchive.project_id == project_id, PrintArchive.status == "failed" ) ) @@ -107,8 +113,9 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count: return ProjectStats( total_archives=total_archives, - completed_prints=completed_prints, - failed_prints=failed_prints, + total_items=int(total_items), + completed_prints=int(completed_prints), + failed_prints=int(failed_prints), queued_prints=queued_prints, in_progress_prints=in_progress_prints, total_print_time_hours=round((sums.total_time or 0) / 3600, 2), @@ -141,12 +148,18 @@ async def list_projects( # Compute quick stats for each project response = [] for project in projects: - # Get archive count + # Get archive count (number of print jobs) archive_count_result = await db.execute( select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id) ) archive_count = archive_count_result.scalar() or 0 + # Get total items (sum of quantities) + total_items_result = await db.execute( + select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id) + ) + total_items = int(total_items_result.scalar() or 0) + # Get queue count queue_count_result = await db.execute( select(func.count(PrintQueueItem.id)).where( @@ -156,14 +169,14 @@ async def list_projects( ) queue_count = queue_count_result.scalar() or 0 - # Get completed count for progress + # Get completed count for progress (sum of quantities) completed_result = await db.execute( - select(func.count(PrintArchive.id)).where( + select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( PrintArchive.project_id == project.id, PrintArchive.status == "completed", ) ) - completed_count = completed_result.scalar() or 0 + completed_count = int(completed_result.scalar() or 0) progress_percent = None if project.target_count and project.target_count > 0: @@ -199,6 +212,7 @@ async def list_projects( target_count=project.target_count, created_at=project.created_at, archive_count=archive_count, + total_items=total_items, queue_count=queue_count, progress_percent=progress_percent, archives=archive_previews, @@ -392,9 +406,9 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh previews = [] for child in children: - # Get completed count for progress + # Get completed count for progress (sum of quantities) completed_result = await db.execute( - select(func.count(PrintArchive.id)).where( + select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where( PrintArchive.project_id == child.id, PrintArchive.status == "completed", ) @@ -402,7 +416,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh completed_count = completed_result.scalar() or 0 progress = None if child.target_count and child.target_count > 0: - progress = round((completed_count / child.target_count) * 100, 1) + progress = round((int(completed_count) / child.target_count) * 100, 1) previews.append( ProjectChildPreview( diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index fc266f05f..7ec9d22d5 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -546,6 +546,7 @@ async def export_backup( "notes": a.notes, "cost": a.cost, "failure_reason": a.failure_reason, + "quantity": a.quantity, "energy_kwh": a.energy_kwh, "energy_cost": a.energy_cost, "extra_data": a.extra_data, @@ -1306,6 +1307,7 @@ async def import_backup( notes=archive_data.get("notes"), cost=archive_data.get("cost"), failure_reason=archive_data.get("failure_reason"), + quantity=archive_data.get("quantity", 1), energy_kwh=archive_data.get("energy_kwh"), energy_cost=archive_data.get("energy_cost"), extra_data=archive_data.get("extra_data"), diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index a616f1804..9ee600704 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -1,21 +1,21 @@ """Spoolman integration API routes.""" import logging + from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db from backend.app.models.printer import Printer from backend.app.models.settings import Settings +from backend.app.services.printer_manager import printer_manager from backend.app.services.spoolman import ( - SpoolmanClient, + close_spoolman_client, get_spoolman_client, init_spoolman_client, - close_spoolman_client, ) -from backend.app.services.printer_manager import printer_manager logger = logging.getLogger(__name__) @@ -30,11 +30,22 @@ class SpoolmanStatus(BaseModel): url: str | None +class SkippedSpool(BaseModel): + """Information about a skipped spool during sync.""" + + location: str # e.g., "AMS A1" or "External Spool" + reason: str # e.g., "Not a Bambu Lab spool", "Empty tray" + filament_type: str | None = None # e.g., "PLA", "PETG" + color: str | None = None # Hex color + + class SyncResult(BaseModel): """Result of a Spoolman sync operation.""" success: bool synced_count: int + skipped_count: int = 0 + skipped: list[SkippedSpool] = [] errors: list[str] @@ -156,6 +167,7 @@ async def sync_printer_ams( # Sync each AMS tray to Spoolman synced = 0 + skipped: list[SkippedSpool] = [] errors = [] # Handle different AMS data structures @@ -193,19 +205,28 @@ async def sync_printer_ams( tray = client.parse_ams_tray(ams_id, tray_data) if not tray: - continue # Empty tray + continue # Empty tray - nothing to sync - # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error + # Build location string for reporting + location = client.convert_ams_slot_to_location(ams_id, tray.tray_id) + + # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped if not client.is_bambu_lab_spool(tray.tray_uuid): + skipped.append( + SkippedSpool( + location=location, + reason="Non-Bambu Lab spool (no RFID tag)", + filament_type=tray.tray_type if tray.tray_type else None, + color=tray.tray_color[:6] if tray.tray_color else None, + ) + ) continue try: sync_result = await client.sync_ams_tray(tray, printer.name) if sync_result: synced += 1 - logger.info( - f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}" - ) + logger.info(f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}") else: # Bambu Lab spool that wasn't synced (not found in Spoolman) errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}") @@ -217,6 +238,8 @@ async def sync_printer_ams( return SyncResult( success=len(errors) == 0, synced_count=synced, + skipped_count=len(skipped), + skipped=skipped, errors=errors, ) @@ -240,10 +263,11 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=503, detail="Spoolman is not reachable") # Get all active printers - result = await db.execute(select(Printer).where(Printer.is_active == True)) + result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) printers = result.scalars().all() total_synced = 0 + all_skipped: list[SkippedSpool] = [] all_errors = [] for printer in printers: @@ -291,8 +315,19 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)): if not tray: continue - # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error + # Build location string for reporting + location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}" + + # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped if not client.is_bambu_lab_spool(tray.tray_uuid): + all_skipped.append( + SkippedSpool( + location=location, + reason="Non-Bambu Lab spool (no RFID tag)", + filament_type=tray.tray_type if tray.tray_type else None, + color=tray.tray_color[:6] if tray.tray_color else None, + ) + ) continue try: @@ -305,6 +340,8 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)): return SyncResult( success=len(all_errors) == 0, synced_count=total_synced, + skipped_count=len(all_skipped), + skipped=all_skipped, errors=all_errors, ) @@ -349,3 +386,106 @@ async def get_filaments(db: AsyncSession = Depends(get_db)): filaments = await client.get_filaments() return {"filaments": filaments} + + +class UnlinkedSpool(BaseModel): + """A Spoolman spool that is not linked to any AMS tray.""" + + id: int + filament_name: str | None + filament_material: str | None + filament_color_hex: str | None + remaining_weight: float | None + location: str | None + + +@router.get("/spools/unlinked", response_model=list[UnlinkedSpool]) +async def get_unlinked_spools(db: AsyncSession = Depends(get_db)): + """Get all Spoolman spools that don't have a tag (not linked to AMS).""" + enabled, url, _ = await get_spoolman_settings(db) + if not enabled: + raise HTTPException(status_code=400, detail="Spoolman integration is not enabled") + + client = await get_spoolman_client() + if not client: + if url: + client = await init_spoolman_client(url) + else: + raise HTTPException(status_code=400, detail="Spoolman URL is not configured") + + if not await client.health_check(): + raise HTTPException(status_code=503, detail="Spoolman is not reachable") + + spools = await client.get_spools() + unlinked = [] + + for spool in spools: + # Check if spool has a tag in extra field + extra = spool.get("extra", {}) or {} + tag = extra.get("tag", "") + if not tag: + filament = spool.get("filament", {}) or {} + unlinked.append( + UnlinkedSpool( + id=spool["id"], + filament_name=filament.get("name"), + filament_material=filament.get("material"), + filament_color_hex=filament.get("color_hex"), + remaining_weight=spool.get("remaining_weight"), + location=spool.get("location"), + ) + ) + + return unlinked + + +class LinkSpoolRequest(BaseModel): + """Request to link a Spoolman spool to an AMS tray.""" + + tray_uuid: str + + +@router.post("/spools/{spool_id}/link") +async def link_spool( + spool_id: int, + request: LinkSpoolRequest, + db: AsyncSession = Depends(get_db), +): + """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid.""" + enabled, url, _ = await get_spoolman_settings(db) + if not enabled: + raise HTTPException(status_code=400, detail="Spoolman integration is not enabled") + + client = await get_spoolman_client() + if not client: + if url: + client = await init_spoolman_client(url) + else: + raise HTTPException(status_code=400, detail="Spoolman URL is not configured") + + if not await client.health_check(): + raise HTTPException(status_code=503, detail="Spoolman is not reachable") + + # Validate tray_uuid format (32 hex characters) + tray_uuid = request.tray_uuid.strip() + if len(tray_uuid) != 32: + raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)") + try: + int(tray_uuid, 16) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)") + + # Update spool with tag + # Note: Spoolman extra field values must be valid JSON, so we encode the string + import json + + result = await client.update_spool( + spool_id=spool_id, + extra={"tag": json.dumps(tray_uuid)}, + ) + + if result: + logger.info(f"Linked Spoolman spool {spool_id} to tray_uuid {tray_uuid}") + return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"} + else: + raise HTTPException(status_code=500, detail="Failed to update spool") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 743f474a1..f2987d049 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.1.6b4" +APP_VERSION = "0.1.6b5" GITHUB_REPO = "maziggy/bambuddy" # App directory - where the application is installed (for static files) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5f9798c3c..cba9269c4 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -375,6 +375,12 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add quantity column to print_archives for tracking item count + try: + await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1")) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/main.py b/backend/app/main.py index 64e5d87c4..ffb708250 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -342,6 +342,27 @@ async def _send_print_start_notification( logger.warning(f"Notification on_print_start failed: {e}") +def _load_objects_from_archive(archive, printer_id: int, logger) -> None: + """Extract printable objects from an archive's 3MF file and store in printer state.""" + try: + from backend.app.services.archive import extract_printable_objects_from_3mf + + file_path = app_settings.base_dir / archive.file_path + if file_path.exists() and str(file_path).endswith(".3mf"): + with open(file_path, "rb") as f: + threemf_data = f.read() + # Extract with positions for UI overlay + printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True) + if printable_objects: + client = printer_manager.get_client(printer_id) + if client: + client.state.printable_objects = printable_objects + client.state.skipped_objects = [] + logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}") + except Exception as e: + logger.debug(f"Failed to extract printable objects from archive: {e}") + + async def on_print_start(printer_id: int, data: dict): """Handle print start - archive the 3MF file immediately.""" import logging @@ -470,6 +491,9 @@ async def on_print_start(printer_id: int, data: dict): archive_data = {"print_time_seconds": archive.print_time_seconds} await _send_print_start_notification(printer_id, data, archive_data, logger) + # Extract printable objects from the archived 3MF file + _load_objects_from_archive(archive, printer_id, logger) + return # Skip creating a new archive # Check if there's already a "printing" archive for this printer/file @@ -508,6 +532,8 @@ async def on_print_start(printer_id: int, data: dict): if not notification_sent: archive_data = {"print_time_seconds": existing_archive.print_time_seconds} await _send_print_start_notification(printer_id, data, archive_data, logger) + # Extract printable objects from the archived 3MF file + _load_objects_from_archive(existing_archive, printer_id, logger) return # Build list of possible 3MF filenames to try @@ -662,6 +688,24 @@ async def on_print_start(printer_id: int, data: dict): archive_data = {"print_time_seconds": archive.print_time_seconds} await _send_print_start_notification(printer_id, data, archive_data, logger) notification_sent = True + + # Extract printable objects for skip object functionality + try: + from backend.app.services.archive import extract_printable_objects_from_3mf + + with open(temp_path, "rb") as f: + threemf_data = f.read() + # Extract with positions for UI overlay + printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True) + if printable_objects: + # Store objects in printer state + client = printer_manager.get_client(printer_id) + if client: + client.state.printable_objects = printable_objects + client.state.skipped_objects = [] # Reset skipped objects for new print + logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}") + except Exception as e: + logger.debug(f"Failed to extract printable objects: {e}") finally: if temp_path and temp_path.exists(): temp_path.unlink() diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index d8971009b..88e26ac2a 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, JSON, Boolean, func + +from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -10,9 +11,7 @@ class PrintArchive(Base): id: Mapped[int] = mapped_column(primary_key=True) printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True) - project_id: Mapped[int | None] = mapped_column( - ForeignKey("projects.id", ondelete="SET NULL"), nullable=True - ) + project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) # File info filename: Mapped[str] = mapped_column(String(255)) @@ -54,15 +53,14 @@ class PrintArchive(Base): cost: Mapped[float | None] = mapped_column(Float) photos: Mapped[list | None] = mapped_column(JSON) # List of photo filenames failure_reason: Mapped[str | None] = mapped_column(String(100)) # For failed prints + quantity: Mapped[int] = mapped_column(Integer, default=1) # Number of items printed # Energy tracking energy_kwh: Mapped[float | None] = mapped_column(Float) # Energy consumed in kWh energy_cost: Mapped[float | None] = mapped_column(Float) # Cost of energy consumed # Timestamps - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) # Relationships printer: Mapped["Printer | None"] = relationship(back_populates="archives") diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 5e76382c9..eb842a055 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -10,6 +10,7 @@ class ArchiveBase(BaseModel): notes: str | None = None cost: float | None = None failure_reason: str | None = None + quantity: int | None = None # Number of items printed class ArchiveUpdate(ArchiveBase): @@ -72,6 +73,7 @@ class ArchiveResponse(BaseModel): cost: float | None photos: list | None failure_reason: str | None + quantity: int = 1 # Number of items printed # Energy tracking energy_kwh: float | None = None diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index f46065646..1459907c5 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -151,3 +151,5 @@ class PrinterStatus(BaseModel): mc_print_sub_stage: int = 0 # Timestamp of last AMS data update (for RFID refresh detection) last_ams_update: float = 0.0 + # Number of printable objects in current print (for skip objects feature) + printable_objects_count: int = 0 diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 2fa8a5c14..49c5c87c6 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -37,9 +37,10 @@ class ProjectUpdate(BaseModel): class ProjectStats(BaseModel): """Statistics for a project.""" - total_archives: int = 0 - completed_prints: int = 0 - failed_prints: int = 0 + total_archives: int = 0 # Number of archive records + total_items: int = 0 # Sum of quantities (total items printed) + completed_prints: int = 0 # Sum of quantities for completed prints + failed_prints: int = 0 # Sum of quantities for failed prints queued_prints: int = 0 in_progress_prints: int = 0 total_print_time_hours: float = 0.0 @@ -115,7 +116,8 @@ class ProjectListResponse(BaseModel): target_count: int | None created_at: datetime # Quick stats - archive_count: int = 0 + archive_count: int = 0 # Number of print jobs + total_items: int = 0 # Sum of quantities (total items printed) queue_count: int = 0 progress_percent: float | None = None # Preview of archives (up to 5) diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index e4a913280..4416d0f0b 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -49,14 +49,21 @@ class ThreeMFParser: return self.metadata def _parse_slice_info(self, zf: zipfile.ZipFile): - """Parse slice_info.config for print settings.""" + """Parse slice_info.config for print settings and printable objects.""" try: if "Metadata/slice_info.config" in zf.namelist(): content = zf.read("Metadata/slice_info.config").decode() root = ET.fromstring(content) - # Get first plate's metadata - plate = root.find(".//plate") + # Get the correct plate's metadata (use plate_number if specified) + if self.plate_number: + plate = root.find(f".//plate[@plate_idx='{self.plate_number}']") + if plate is None: + # Fallback to first plate if specific plate not found + plate = root.find(".//plate") + else: + plate = root.find(".//plate") + if plate is not None: # Get prediction and weight from metadata elements for meta in plate.findall("metadata"): @@ -67,6 +74,24 @@ class ThreeMFParser: elif key == "weight" and value: self.metadata["filament_used_grams"] = float(value) + # Extract printable objects for skip object functionality + # Objects are stored as + printable_objects = {} + for obj in plate.findall("object"): + identify_id = obj.get("identify_id") + name = obj.get("name") + skipped = obj.get("skipped", "false") + + # Only include objects that are not pre-skipped + if identify_id and name and skipped.lower() != "true": + try: + printable_objects[int(identify_id)] = name + except ValueError: + pass + + if printable_objects: + self.metadata["printable_objects"] = printable_objects + # Get filament info from filaments ACTUALLY USED in the print # slice_info has # 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.""" diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 358698138..378ee6cb6 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -146,6 +146,10 @@ class PrinterState: ams_extruder_map: dict = field(default_factory=dict) # Timestamp of last AMS data update (for RFID refresh detection) last_ams_update: float = 0.0 + # Printable objects for skip object functionality: {identify_id: object_name} + printable_objects: dict = field(default_factory=dict) + # Objects that have been skipped during the current print + skipped_objects: list = field(default_factory=list) # Stage name mapping from BambuStudio DeviceManager.cpp @@ -1415,6 +1419,17 @@ class BambuMQTTClient: logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}") self.state.speed_level = new_speed + # Parse skipped objects from printer status (s_obj field) + # This allows us to restore skipped objects state after reconnection + if "s_obj" in data: + s_obj = data["s_obj"] + if isinstance(s_obj, list): + # Update skipped objects from printer's list + new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))] + if new_skipped != self.state.skipped_objects: + logger.info(f"[{self.serial_number}] skipped_objects updated from printer: {new_skipped}") + self.state.skipped_objects = new_skipped + # Parse chamber light status from lights_report if "lights_report" in data: lights = data["lights_report"] @@ -2385,6 +2400,51 @@ class BambuMQTTClient: logger.info(f"[{self.serial_number}] Sent resume print command") return True + def skip_objects(self, object_ids: list[int]) -> bool: + """Skip specific objects during a print. + + This command tells the printer to skip printing the specified objects. + The object IDs come from the slice_info.config file in the 3MF. + + Args: + object_ids: List of identify_id values from slice_info.config + + Returns: + True if command was sent, False otherwise + """ + if not self._client or not self.state.connected: + logger.warning(f"[{self.serial_number}] Cannot skip objects: not connected") + return False + + if self.state.state != "RUNNING" and self.state.state != "PAUSE": + logger.warning( + f"[{self.serial_number}] Cannot skip objects: printer not printing (state={self.state.state})" + ) + return False + + if not object_ids: + logger.warning(f"[{self.serial_number}] Cannot skip objects: no object IDs provided") + return False + + # Validate all IDs are integers + try: + obj_list = [int(oid) for oid in object_ids] + except (ValueError, TypeError) as e: + logger.warning(f"[{self.serial_number}] Invalid object IDs: {e}") + return False + + self._sequence_id += 1 + command = {"print": {"sequence_id": str(self._sequence_id), "command": "skip_objects", "obj_list": obj_list}} + self._client.publish(self.topic_publish, json.dumps(command), qos=1) + logger.info(f"[{self.serial_number}] Sent skip_objects command: {obj_list}") + + # Track skipped objects in state + for oid in obj_list: + if oid not in self.state.skipped_objects: + self.state.skipped_objects.append(oid) + + return True + def send_gcode(self, gcode: str) -> bool: """Send G-code command(s) to the printer. diff --git a/backend/app/services/export.py b/backend/app/services/export.py index 8b953d3df..82855477c 100644 --- a/backend/app/services/export.py +++ b/backend/app/services/export.py @@ -3,12 +3,11 @@ import io from datetime import datetime from typing import Any -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.models.archive import PrintArchive -from backend.app.models.project import Project class ExportService: @@ -20,6 +19,7 @@ class ExportService: "print_name", "filename", "status", + "quantity", "printer_id", "project_name", "filament_type", @@ -46,6 +46,7 @@ class ExportService: "print_name": "Print Name", "filename": "Filename", "status": "Status", + "quantity": "Items Printed", "printer_id": "Printer ID", "project_name": "Project", "filament_type": "Filament Type", @@ -97,9 +98,7 @@ class ExportService: """ # Build query query = ( - select(PrintArchive) - .options(selectinload(PrintArchive.project)) - .order_by(PrintArchive.created_at.desc()) + select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc()) ) # Apply filters @@ -116,11 +115,11 @@ class ExportService: if search: like_pattern = f"%{search}%" query = query.where( - (PrintArchive.print_name.ilike(like_pattern)) | - (PrintArchive.filename.ilike(like_pattern)) | - (PrintArchive.tags.ilike(like_pattern)) | - (PrintArchive.notes.ilike(like_pattern)) | - (PrintArchive.designer.ilike(like_pattern)) + (PrintArchive.print_name.ilike(like_pattern)) + | (PrintArchive.filename.ilike(like_pattern)) + | (PrintArchive.tags.ilike(like_pattern)) + | (PrintArchive.notes.ilike(like_pattern)) + | (PrintArchive.designer.ilike(like_pattern)) ) # Execute query @@ -212,12 +211,14 @@ class ExportService: rows.append(["Week", "Total", "Failed", "Rate (%)"]) for week in analysis["trend"]: - rows.append([ - week["week_start"], - week["total_prints"], - week["failed_prints"], - week["failure_rate"], - ]) + rows.append( + [ + week["week_start"], + week["total_prints"], + week["failed_prints"], + week["failure_rate"], + ] + ) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -266,7 +267,7 @@ class ExportService: """Generate Excel file content.""" try: from openpyxl import Workbook - from openpyxl.styles import Font, PatternFill, Alignment + from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter except ImportError: raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl") @@ -293,7 +294,7 @@ class ExportService: ws.cell(row=row_idx, column=col_idx, value=value) # Auto-adjust column widths - for col_idx, field in enumerate(fields, 1): + for col_idx, _field in enumerate(fields, 1): column_letter = get_column_letter(col_idx) max_length = len(headers[col_idx - 1]) for row in rows: diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index f4e115c87..fad7762dc 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -475,9 +475,12 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> # Calibration stage tracking "stg_cur": state.stg_cur, "stg_cur_name": get_derived_status_name(state), + # Printable objects count for skip objects feature + "printable_objects_count": len(state.printable_objects), } # Add cover URL if there's an active print and printer_id is provided - if printer_id and state.state == "RUNNING" and state.gcode_file: + # Include PAUSE/PAUSED states so skip objects modal can show cover + if printer_id and state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file: result["cover_url"] = f"/api/v1/printers/{printer_id}/cover" else: result["cover_url"] = None diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 88d4e3460..52190e645 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -2,7 +2,7 @@ import logging from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime import httpx @@ -170,9 +170,7 @@ class SpoolmanClient: """ try: client = await self._get_client() - response = await client.post( - f"{self.api_url}/vendor", json={"name": name} - ) + response = await client.post(f"{self.api_url}/vendor", json={"name": name}) response.raise_for_status() return response.json() except Exception as e: @@ -314,7 +312,9 @@ class SpoolmanClient: client = await self._get_client() response = await client.post(f"{self.api_url}/spool", json=data) response.raise_for_status() - return response.json() + result = response.json() + logger.info(f"Created spool {result.get('id')} in Spoolman") + return result except httpx.HTTPStatusError as e: logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}") return None @@ -350,12 +350,10 @@ class SpoolmanClient: data["extra"] = extra # Always update last_used - data["last_used"] = datetime.now(timezone.utc).isoformat() + data["last_used"] = datetime.now(UTC).isoformat() client = await self._get_client() - response = await client.patch( - f"{self.api_url}/spool/{spool_id}", json=data - ) + response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data) response.raise_for_status() return response.json() except Exception as e: @@ -519,9 +517,7 @@ class SpoolmanClient: except ValueError: return False - def calculate_remaining_weight( - self, remain_percent: int, spool_weight: int - ) -> float: + def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float: """Calculate remaining weight from percentage. Args: @@ -533,9 +529,7 @@ class SpoolmanClient: """ return (remain_percent / 100.0) * spool_weight - async def sync_ams_tray( - self, tray: AMSTray, printer_name: str - ) -> dict | None: + async def sync_ams_tray(self, tray: AMSTray, printer_name: str) -> dict | None: """Sync a single AMS tray to Spoolman. Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters). @@ -564,9 +558,7 @@ class SpoolmanClient: f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})" ) else: - logger.debug( - f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}" - ) + logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}") return None # Calculate remaining weight @@ -577,9 +569,7 @@ class SpoolmanClient: existing = await self.find_spool_by_tag(tray.tray_uuid) if existing: # Update existing spool - logger.info( - f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}" - ) + logger.info(f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}") return await self.update_spool( spool_id=existing["id"], remaining_weight=remaining, @@ -588,8 +578,7 @@ class SpoolmanClient: # Spool not found - auto-create it logger.info( - f"Creating new spool in Spoolman for {tray.tray_sub_brands} " - f"(tray_uuid: {tray.tray_uuid[:16]}...)" + f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)" ) # First find or create the filament type @@ -599,12 +588,15 @@ class SpoolmanClient: return None # Create the spool with tray_uuid stored as "tag" in extra field + # Note: Spoolman extra field values must be valid JSON, so we encode the string + import json + return await self.create_spool( filament_id=filament["id"], remaining_weight=remaining, location=location, - comment=f"Auto-created from {printer_name} AMS", - extra={"tag": tray.tray_uuid}, + comment="Created by Bambuddy", + extra={"tag": json.dumps(tray.tray_uuid)}, ) async def _find_or_create_filament(self, tray: AMSTray) -> dict | None: @@ -624,10 +616,7 @@ class SpoolmanClient: # Match by material and color (handle None values) fil_material = filament.get("material") or "" fil_color = filament.get("color_hex") or "" - if ( - fil_material.upper() == tray.tray_type.upper() - and fil_color.upper() == color_hex.upper() - ): + if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper(): return filament # Search external filaments (Bambu library) @@ -635,10 +624,7 @@ class SpoolmanClient: for filament in external: fil_material = filament.get("material") or "" fil_color = filament.get("color_hex") or "" - if ( - fil_material.upper() == tray.tray_type.upper() - and fil_color.upper() == color_hex.upper() - ): + if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper(): # Found in external library - need to create internal copy return await self._create_filament_from_external(filament, tray) @@ -652,9 +638,7 @@ class SpoolmanClient: weight=tray.tray_weight, ) - async def _create_filament_from_external( - self, external: dict, tray: AMSTray - ) -> dict | None: + async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None: """Create internal filament from external library entry. Args: diff --git a/backend/tests/integration/test_printers_api.py b/backend/tests/integration/test_printers_api.py index 74a4796f7..4fe4ad67f 100644 --- a/backend/tests/integration/test_printers_api.py +++ b/backend/tests/integration/test_printers_api.py @@ -291,3 +291,423 @@ class TestPrinterDataIntegrity: assert response.status_code == 200 assert response.json()["status"] == "refresh_requested" mock_pm.request_status_update.assert_called_once_with(printer.id) + + +class TestPrintControlAPI: + """Integration tests for print control endpoints (stop, pause, resume).""" + + # ======================================================================== + # Stop print endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_stop_print_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/print/stop") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop") + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_stop_print_success(self, async_client: AsyncClient, printer_factory): + """Verify successful stop print request.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.stop_print.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop") + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_client.stop_print.assert_called_once() + + # ======================================================================== + # Pause print endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_pause_print_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/print/pause") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause") + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_pause_print_success(self, async_client: AsyncClient, printer_factory): + """Verify successful pause print request.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.pause_print.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause") + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_client.pause_print.assert_called_once() + + # ======================================================================== + # Resume print endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_print_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/print/resume") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume") + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_print_success(self, async_client: AsyncClient, printer_factory): + """Verify successful resume print request.""" + printer = await printer_factory(name="Paused Printer") + + mock_client = MagicMock() + mock_client.resume_print.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume") + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_client.resume_print.assert_called_once() + + +class TestAMSRefreshAPI: + """Integration tests for AMS slot refresh endpoint.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_ams_refresh_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh") + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory): + """Verify successful AMS refresh request.""" + printer = await printer_factory(name="Printer with AMS") + + mock_client = MagicMock() + mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh") + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + mock_client.ams_refresh_tray.assert_called_once_with(0, 1) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory): + """Verify error when filament is loaded (can't refresh while loaded).""" + printer = await printer_factory(name="Printer with AMS") + + mock_client = MagicMock() + mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh") + + assert response.status_code == 400 + assert "unload" in response.json()["detail"].lower() + + +class TestSkipObjectsAPI: + """Integration tests for skip objects endpoints.""" + + # ======================================================================== + # Get printable objects endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_objects_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.get("/api/v1/printers/99999/print/objects") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects") + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory): + """Verify empty objects list when no print is active.""" + printer = await printer_factory(name="Idle Printer") + + mock_client = MagicMock() + mock_client.state.printable_objects = {} + mock_client.state.skipped_objects = [] + mock_client.state.state = "IDLE" + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects") + + assert response.status_code == 200 + result = response.json() + assert result["objects"] == [] + assert result["total"] == 0 + assert result["skipped_count"] == 0 + assert result["is_printing"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory): + """Verify objects list when print is active.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"} + mock_client.state.skipped_objects = [200] + mock_client.state.state = "RUNNING" + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects") + + assert response.status_code == 200 + result = response.json() + assert result["total"] == 3 + assert result["skipped_count"] == 1 + assert result["is_printing"] is True + + # Check objects have correct structure + objects_by_id = {obj["id"]: obj for obj in result["objects"]} + assert objects_by_id[100]["name"] == "Part A" + assert objects_by_id[100]["skipped"] is False + assert objects_by_id[200]["name"] == "Part B" + assert objects_by_id[200]["skipped"] is True + assert objects_by_id[300]["name"] == "Part C" + assert objects_by_id[300]["skipped"] is False + + # ======================================================================== + # Skip objects endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory): + """Verify objects list includes position data when available.""" + printer = await printer_factory(name="Printing Printer") + + # New format with position data + mock_client = MagicMock() + mock_client.state.printable_objects = { + 100: {"name": "Part A", "x": 50.0, "y": 100.0}, + 200: {"name": "Part B", "x": 150.0, "y": 100.0}, + } + mock_client.state.skipped_objects = [] + mock_client.state.state = "RUNNING" + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects") + + assert response.status_code == 200 + result = response.json() + assert result["total"] == 2 + + # Check objects have position data + objects_by_id = {obj["id"]: obj for obj in result["objects"]} + assert objects_by_id[100]["name"] == "Part A" + assert objects_by_id[100]["x"] == 50.0 + assert objects_by_id[100]["y"] == 100.0 + assert objects_by_id[200]["name"] == "Part B" + assert objects_by_id[200]["x"] == 150.0 + assert objects_by_id[200]["y"] == 100.0 + + # ======================================================================== + # Skip objects endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_skip_objects_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100]) + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100]) + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory): + """Verify error when no object IDs provided.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.state.printable_objects = {100: "Part A"} + mock_client.state.skipped_objects = [] + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[]) + + assert response.status_code == 400 + assert "no object" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory): + """Verify error when object ID doesn't exist.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.state.printable_objects = {100: "Part A"} + mock_client.state.skipped_objects = [] + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999]) + + assert response.status_code == 400 + assert "invalid" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory): + """Verify successful skip objects request.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.state.printable_objects = {100: "Part A", 200: "Part B"} + mock_client.state.skipped_objects = [] + mock_client.skip_objects.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100]) + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert 100 in result["skipped_objects"] + mock_client.skip_objects.assert_called_once_with([100]) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory): + """Verify skipping multiple objects at once.""" + printer = await printer_factory(name="Printing Printer") + + mock_client = MagicMock() + mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"} + mock_client.state.skipped_objects = [] + mock_client.skip_objects.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200]) + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert 100 in result["skipped_objects"] + assert 200 in result["skipped_objects"] + mock_client.skip_objects.assert_called_once_with([100, 200]) diff --git a/backend/tests/integration/test_spoolman_api.py b/backend/tests/integration/test_spoolman_api.py new file mode 100644 index 000000000..f22c54bad --- /dev/null +++ b/backend/tests/integration/test_spoolman_api.py @@ -0,0 +1,423 @@ +"""Integration tests for Spoolman API endpoints.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient + + +class TestSpoolmanAPI: + """Integration tests for /api/v1/spoolman/ endpoints.""" + + @pytest.fixture + async def spoolman_settings(self, db_session): + """Create Spoolman settings in the database (enabled with URL).""" + from backend.app.models.settings import Settings + + # Both settings are required for Spoolman to work + enabled_setting = Settings(key="spoolman_enabled", value="true") + url_setting = Settings(key="spoolman_url", value="http://localhost:7912") + db_session.add(enabled_setting) + db_session.add(url_setting) + await db_session.commit() + return {"enabled": enabled_setting, "url": url_setting} + + @pytest.fixture + async def spoolman_url_only(self, db_session): + """Create only the URL setting (not enabled).""" + from backend.app.models.settings import Settings + + setting = Settings(key="spoolman_url", value="http://localhost:7912") + db_session.add(setting) + await db_session.commit() + return setting + + @pytest.fixture + def mock_spoolman_client(self): + """Mock the Spoolman client functions.""" + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.base_url = "http://localhost:7912" + mock_client.health_check = AsyncMock(return_value=True) + mock_client.get_spools = AsyncMock(return_value=[]) + mock_client.get_filaments = AsyncMock(return_value=[]) + mock_client.create_spool = AsyncMock(return_value={"id": 1}) + mock_client.update_spool = AsyncMock(return_value={"id": 1}) + mock_client.close = AsyncMock() + + with ( + patch( + "backend.app.api.routes.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.api.routes.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.api.routes.spoolman.close_spoolman_client", + AsyncMock(), + ), + ): + yield mock_client + + @pytest.fixture + def mock_spoolman_disconnected(self): + """Mock the Spoolman client as disconnected (returns None).""" + with ( + patch( + "backend.app.api.routes.spoolman.get_spoolman_client", + AsyncMock(return_value=None), + ), + patch( + "backend.app.api.routes.spoolman.init_spoolman_client", + AsyncMock(return_value=None), + ), + ): + yield + + # ========================================================================= + # Status Endpoint Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_status_not_configured(self, async_client: AsyncClient): + """Verify status shows not enabled when no settings exist.""" + response = await async_client.get("/api/v1/spoolman/status") + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + assert data["connected"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_status_url_only_not_enabled(self, async_client: AsyncClient, spoolman_url_only): + """Verify status shows not enabled when only URL is set.""" + response = await async_client.get("/api/v1/spoolman/status") + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + assert data["url"] == "http://localhost:7912" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_status_enabled_and_connected( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify status shows enabled and connected when properly configured.""" + response = await async_client.get("/api/v1/spoolman/status") + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + assert data["connected"] is True + assert data["url"] == "http://localhost:7912" + + # ========================================================================= + # Connect/Disconnect Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_connect_not_enabled(self, async_client: AsyncClient): + """Verify connect fails when not enabled.""" + response = await async_client.post("/api/v1/spoolman/connect") + assert response.status_code == 400 + assert "not enabled" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_connect_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify successful connection to Spoolman.""" + response = await async_client.post("/api/v1/spoolman/connect") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "connected" in data["message"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_disconnect(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify disconnect works.""" + response = await async_client.post("/api/v1/spoolman/disconnect") + assert response.status_code == 200 + assert "disconnected" in response.json()["message"].lower() + + # ========================================================================= + # Spools Endpoint Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_spools_not_enabled(self, async_client: AsyncClient): + """Verify get spools fails when not enabled.""" + response = await async_client.get("/api/v1/spoolman/spools") + assert response.status_code == 400 + assert "not enabled" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify get spools returns data in expected format.""" + mock_spool = { + "id": 1, + "remaining_weight": 500, + "used_weight": 500, + "filament": { + "id": 1, + "name": "PLA Basic", + "material": "PLA", + "color_hex": "FF0000", + }, + "first_used": "2024-01-01", + "last_used": "2024-01-15", + "location": "AMS1", + "lot_nr": "LOT123", + "comment": "Test spool", + "extra": {"tag": '"ABC123"'}, + } + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool]) + + response = await async_client.get("/api/v1/spoolman/spools") + assert response.status_code == 200 + data = response.json() + assert "spools" in data + assert isinstance(data["spools"], list) + assert len(data["spools"]) == 1 + assert data["spools"][0]["id"] == 1 + + # ========================================================================= + # Unlinked Spools Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_unlinked_spools_not_enabled(self, async_client: AsyncClient): + """Verify get unlinked spools fails when not enabled.""" + response = await async_client.get("/api/v1/spoolman/spools/unlinked") + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_unlinked_spools_success( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify get unlinked spools returns spools without tags.""" + # Mock spool without extra.tag (unlinked) + mock_spool = { + "id": 1, + "remaining_weight": 800, + "used_weight": 200, + "extra": {}, # No tag = unlinked + "filament": { + "id": 1, + "name": "PLA Basic", + "material": "PLA", + "color_hex": "FF0000", + }, + "location": "Shelf A", + } + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool]) + + response = await async_client.get("/api/v1/spoolman/spools/unlinked") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["id"] == 1 + assert data[0]["filament_name"] == "PLA Basic" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_unlinked_spools_excludes_linked( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify linked spools (with tag) are excluded.""" + # Mock spool with extra.tag (linked) + mock_spool_linked = { + "id": 1, + "remaining_weight": 800, + "used_weight": 200, + "extra": {"tag": '"ABC123"'}, # Has tag = linked + "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"}, + } + + # Mock spool without tag (unlinked) + mock_spool_unlinked = { + "id": 2, + "remaining_weight": 900, + "used_weight": 100, + "extra": {}, # No tag = unlinked + "filament": {"id": 2, "name": "PLA Blue", "material": "PLA", "color_hex": "0000FF"}, + } + + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked]) + + response = await async_client.get("/api/v1/spoolman/spools/unlinked") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == 2 # Only unlinked spool + + # ========================================================================= + # Link Spool Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_link_spool_not_enabled(self, async_client: AsyncClient): + """Verify link spool fails when not enabled.""" + response = await async_client.post( + "/api/v1/spoolman/spools/1/link", + json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"}, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_link_spool_invalid_uuid_length( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify link spool fails with invalid UUID length.""" + response = await async_client.post( + "/api/v1/spoolman/spools/1/link", + json={"tray_uuid": "ABC123"}, # Too short + ) + assert response.status_code == 400 + assert "32 hex characters" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_link_spool_invalid_uuid_format( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify link spool fails with non-hex UUID.""" + response = await async_client.post( + "/api/v1/spoolman/spools/1/link", + json={"tray_uuid": "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"}, # Not hex + ) + assert response.status_code == 400 + assert "hex" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_link_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify successfully linking a spool to AMS tray.""" + mock_spoolman_client.update_spool = AsyncMock( + return_value={"id": 1, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}} + ) + + response = await async_client.post( + "/api/v1/spoolman/spools/1/link", + json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "linked" in data["message"].lower() + + # Verify update_spool was called + mock_spoolman_client.update_spool.assert_called_once() + + # ========================================================================= + # Sync Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_printer_not_enabled(self, async_client: AsyncClient, printer_factory): + """Verify sync fails when Spoolman not enabled.""" + printer = await printer_factory() + response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}") + assert response.status_code == 400 + assert "not enabled" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_printer_not_found(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify sync fails for non-existent printer.""" + response = await async_client.post("/api/v1/spoolman/sync/9999") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_returns_result_structure( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + printer_factory, + ): + """Verify sync returns proper result structure.""" + printer = await printer_factory() + + # Mock printer manager to return AMS data + with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock: + mock_state = MagicMock() + mock_state.raw_data = {"ams": [{"id": 0, "tray": []}]} + pm_mock.get_status = MagicMock(return_value=mock_state) + + response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}") + assert response.status_code == 200 + data = response.json() + # Verify SyncResult structure + assert "success" in data + assert "synced_count" in data + assert "skipped_count" in data + assert "skipped" in data + assert "errors" in data + assert isinstance(data["skipped"], list) + assert isinstance(data["errors"], list) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_printer_not_connected( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + printer_factory, + ): + """Verify sync fails when printer is not connected (no status).""" + printer = await printer_factory() + + with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock: + pm_mock.get_status = MagicMock(return_value=None) + + response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}") + assert response.status_code == 404 + assert "not connected" in response.json()["detail"].lower() + + # ========================================================================= + # Filaments Endpoint Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_filaments_not_enabled(self, async_client: AsyncClient): + """Verify get filaments fails when not enabled.""" + response = await async_client.get("/api/v1/spoolman/filaments") + assert response.status_code == 400 + assert "not enabled" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_filaments_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify get filaments returns data in expected format.""" + mock_filament = { + "id": 1, + "name": "PLA Basic", + "material": "PLA", + "color_hex": "FF0000", + "vendor_id": 1, + "weight": 1000, + } + mock_spoolman_client.get_filaments = AsyncMock(return_value=[mock_filament]) + + response = await async_client.get("/api/v1/spoolman/filaments") + assert response.status_code == 200 + data = response.json() + assert "filaments" in data + assert isinstance(data["filaments"], list) + assert len(data["filaments"]) == 1 + assert data["filaments"][0]["name"] == "PLA Basic" diff --git a/docker-compose.yml b/docker-compose.yml index 43d9d9742..990d4873f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,10 @@ services: bambuddy: + image: ghcr.io/maziggy/bambuddy:latest build: . + # Usage: + # docker compose up -d → pulls pre-built image from ghcr.io + # docker compose up -d --build → builds locally from source container_name: bambuddy # Network mode options: # - Default (bridge): Works for basic usage, but printer discovery and diff --git a/docker-publish.sh b/docker-publish.sh new file mode 100755 index 000000000..69df45832 --- /dev/null +++ b/docker-publish.sh @@ -0,0 +1,196 @@ +#!/bin/bash +# Build and push multi-architecture Docker image to GitHub Container Registry +# +# Usage: +# ./scripts/docker-publish.sh [version] [--parallel] +# +# Examples: +# ./scripts/docker-publish.sh 0.1.6 # Sequential build (default) +# ./scripts/docker-publish.sh 0.1.6 --parallel # Build both archs simultaneously +# ./scripts/docker-publish.sh 0.1.6-beta # Pre-release (no latest tag) +# +# Prerequisites: +# 1. Log in to ghcr.io first: +# echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin +# +# 2. Create a GitHub Personal Access Token with 'write:packages' scope: +# https://github.com/settings/tokens/new?scopes=write:packages +# +# Supported architectures: +# - linux/amd64 (x86_64, most servers/desktops) +# - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation) + +set -e + +# Configuration +REGISTRY="ghcr.io" +IMAGE_NAME="maziggy/bambuddy" +FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}" +PLATFORMS="linux/amd64,linux/arm64" +BUILDER_NAME="bambuddy-builder" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Parse arguments +VERSION="" +PARALLEL=false +for arg in "$@"; do + case $arg in + --parallel) + PARALLEL=true + ;; + *) + if [ -z "$VERSION" ]; then + VERSION="$arg" + fi + ;; + esac +done + +if [ -z "$VERSION" ]; then + echo -e "${YELLOW}Usage: $0 [--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}" diff --git a/frontend/src/__tests__/components/SpoolmanSettings.test.tsx b/frontend/src/__tests__/components/SpoolmanSettings.test.tsx new file mode 100644 index 000000000..7df21116f --- /dev/null +++ b/frontend/src/__tests__/components/SpoolmanSettings.test.tsx @@ -0,0 +1,314 @@ +/** + * Tests for the SpoolmanSettings component. + * + * Tests the Spoolman integration UI including: + * - Enable/disable toggle + * - URL configuration + * - Connection status + * - Sync functionality + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import { SpoolmanSettings } from '../../components/SpoolmanSettings'; + +// Mock the API client +vi.mock('../../api/client', () => ({ + api: { + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + getSpoolmanStatus: vi.fn(), + connectSpoolman: vi.fn(), + disconnectSpoolman: vi.fn(), + syncAllPrintersAms: vi.fn(), + syncPrinterAms: vi.fn(), + getPrinters: vi.fn(), + }, +})); + +// Import mocked module +import { api } from '../../api/client'; + +// Mock fetch for Spoolman settings endpoints +const mockFetchResponse = (data: object) => ({ + ok: true, + json: () => Promise.resolve(data), +}); + +describe('SpoolmanSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default API mocks + vi.mocked(api.getSpoolmanStatus).mockResolvedValue({ + enabled: false, + connected: false, + url: null, + }); + vi.mocked(api.getPrinters).mockResolvedValue([]); + vi.mocked(api.connectSpoolman).mockResolvedValue({ success: true, message: 'Connected' }); + vi.mocked(api.disconnectSpoolman).mockResolvedValue({ success: true, message: 'Disconnected' }); + vi.mocked(api.syncAllPrintersAms).mockResolvedValue({ + success: true, + synced_count: 3, + skipped_count: 1, + skipped: [], + errors: [], + }); + + // Default fetch mock for settings - disabled state + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('/api/v1/settings/spoolman')) { + return Promise.resolve( + mockFetchResponse({ + spoolman_enabled: 'false', + spoolman_url: '', + spoolman_sync_mode: 'auto', + }) + ); + } + return Promise.reject(new Error('Unknown URL')); + }) as any; + }); + + describe('rendering', () => { + it('renders loading state initially', () => { + // Delay the fetch response to catch loading state + global.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as any; + render(); + + // Should show loading spinner + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('renders component title', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Spoolman Integration')).toBeInTheDocument(); + }); + }); + + it('renders enable toggle', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Enable Spoolman')).toBeInTheDocument(); + }); + }); + + it('renders URL input', async () => { + render(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('Sync Mode')).toBeInTheDocument(); + }); + }); + + it('renders info banner about sync', async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument(); + }); + }); + }); + + describe('sync mode options', () => { + it('shows Automatic option', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument(); + }); + }); + + it('shows Manual Only option', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument(); + }); + }); + }); + + describe('info text', () => { + it('shows URL help text', async () => { + render(); + + 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(); + + await waitFor(() => { + expect( + screen.getByText('AMS data syncs automatically when changes are detected') + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 435bfaf6d..24789a101 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -163,6 +163,8 @@ export interface PrinterStatus { mc_print_sub_stage: number; // Timestamp of last AMS data update (for RFID refresh detection) last_ams_update: number; + // Number of printable objects in current print (for skip objects feature) + printable_objects_count: number; } export interface PrinterCreate { @@ -221,6 +223,7 @@ export interface Archive { cost: number | null; photos: string[] | null; failure_reason: string | null; + quantity: number; energy_kwh: number | null; energy_cost: number | null; created_at: string; @@ -331,6 +334,7 @@ export interface SimilarArchive { // Project types export interface ProjectStats { total_archives: number; + total_items: number; // Sum of quantities (total items printed) completed_prints: number; failed_prints: number; queued_prints: number; @@ -401,7 +405,8 @@ export interface ProjectListItem { status: string; target_count: number | null; created_at: string; - archive_count: number; + archive_count: number; // Number of print jobs + total_items: number; // Sum of quantities (total items printed) queue_count: number; progress_percent: number | null; archives: ArchivePreview[]; @@ -1117,12 +1122,30 @@ export interface SpoolmanStatus { url: string | null; } +export interface SkippedSpool { + location: string; + reason: string; + filament_type: string | null; + color: string | null; +} + export interface SpoolmanSyncResult { success: boolean; synced_count: number; + skipped_count: number; + skipped: SkippedSpool[]; errors: string[]; } +export interface UnlinkedSpool { + id: number; + filament_name: string | null; + filament_material: string | null; + filament_color_hex: string | null; + remaining_weight: number | null; + location: string | null; +} + // Update types export interface VersionInfo { version: string; @@ -1275,6 +1298,45 @@ export const api = { method: 'POST', }), + // Print Control + stopPrint: (printerId: number) => + request<{ success: boolean; message: string }>(`/printers/${printerId}/print/stop`, { + method: 'POST', + }), + pausePrint: (printerId: number) => + request<{ success: boolean; message: string }>(`/printers/${printerId}/print/pause`, { + method: 'POST', + }), + resumePrint: (printerId: number) => + request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, { + method: 'POST', + }), + + // Skip Objects + getPrintableObjects: (printerId: number) => + request<{ + objects: Array<{ id: number; name: string; x: number | null; y: number | null; skipped: boolean }>; + total: number; + skipped_count: number; + is_printing: boolean; + }>(`/printers/${printerId}/print/objects`), + + skipObjects: (printerId: number, objectIds: number[]) => + request<{ success: boolean; message: string; skipped_objects: number[] }>( + `/printers/${printerId}/print/skip-objects`, + { + method: 'POST', + body: JSON.stringify(objectIds), + } + ), + + // AMS Control + refreshAmsSlot: (printerId: number, amsId: number, slotId: number) => + request<{ success: boolean; message: string }>( + `/printers/${printerId}/ams/${amsId}/slot/${slotId}/refresh`, + { method: 'POST' } + ), + // MQTT Debug Logging enableMQTTLogging: (printerId: number) => request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, { @@ -1348,6 +1410,7 @@ export const api = { cost?: number; failure_reason?: string | null; status?: string; + quantity?: number; }) => request(`/archives/${id}`, { method: 'PATCH', @@ -1996,6 +2059,13 @@ export const api = { request<{ spools: unknown[] }>('/spoolman/spools'), getSpoolmanFilaments: () => request<{ filaments: unknown[] }>('/spoolman/filaments'), + getUnlinkedSpools: () => + request('/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('/updates/version'), diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index fdab477a8..620a57212 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban } from 'lucide-react'; +import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; @@ -49,6 +49,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr const [tags, setTags] = useState(archive.tags || ''); const [failureReason, setFailureReason] = useState(archive.failure_reason || ''); const [status, setStatus] = useState(archive.status); + const [quantity, setQuantity] = useState(archive.quantity ?? 1); const [photos, setPhotos] = useState(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 + {/* Quantity - number of items printed */} +
+ + 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" + /> +

+ Number of items produced in this print job +

+
+ {/* Notes */}
diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index 9977fd018..a85f92f8f 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, type ReactNode } from 'react'; -import { Droplets } from 'lucide-react'; +import { Droplets, Link2, Copy, Check } from 'lucide-react'; interface FilamentData { vendor: 'Bambu Lab' | 'Generic'; @@ -8,6 +8,12 @@ interface FilamentData { colorHex: string | null; kFactor: string; fillLevel: number | null; // null = unknown + trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking +} + +interface SpoolmanConfig { + enabled: boolean; + onLinkSpool?: (trayUuid: string) => void; } interface FilamentHoverCardProps { @@ -15,19 +21,56 @@ interface FilamentHoverCardProps { children: ReactNode; disabled?: boolean; className?: string; + spoolman?: SpoolmanConfig; } /** * A hover card that displays filament details when hovering over AMS slots. * Replaces the basic browser tooltip with a styled popover. */ -export function FilamentHoverCard({ data, children, disabled, className = '' }: FilamentHoverCardProps) { +export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); + const [copied, setCopied] = useState(false); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | 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 = '' }: )}
+ + {/* Spoolman section - only show if enabled */} + {spoolman?.enabled && data.trayUuid && ( +
+ {/* Tray UUID with copy button */} +
+ + Spool ID + + +
+ + {/* Link Spool button */} + {spoolman.onLinkSpool && ( + + )} +
+ )} diff --git a/frontend/src/components/LinkSpoolModal.tsx b/frontend/src/components/LinkSpoolModal.tsx new file mode 100644 index 000000000..e3e524fe0 --- /dev/null +++ b/frontend/src/components/LinkSpoolModal.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { X, Loader2, Link2, Check } from 'lucide-react'; +import { api } from '../api/client'; +import { Button } from './Button'; + +interface LinkSpoolModalProps { + isOpen: boolean; + onClose: () => void; + trayUuid: string; + trayInfo?: { + type: string; + color: string; + location: string; + }; +} + +export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) { + const queryClient = useQueryClient(); + const [selectedSpoolId, setSelectedSpoolId] = useState(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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

Link to Spoolman

+
+ +
+ + {/* Content */} +
+ {/* Tray info */} + {trayInfo && ( +
+

Linking AMS tray:

+
+ {trayInfo.color && ( + + )} + {trayInfo.type} + ({trayInfo.location}) +
+
+ )} + + {/* Spool UUID */} +
+

Spool UUID:

+ {trayUuid} +
+ + {/* Spool list */} +
+

+ Select a Spoolman spool to link: +

+ + {isLoading ? ( +
+ +
+ ) : unlinkedSpools && unlinkedSpools.length > 0 ? ( +
+ {unlinkedSpools.map((spool) => ( + + ))} +
+ ) : ( +
+

No unlinked spools found in Spoolman.

+

All spools are already linked to AMS trays.

+
+ )} +
+
+ + {/* Footer */} +
+ + +
+ + {/* Error */} + {linkMutation.isError && ( +
+ {(linkMutation.error as Error).message} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/SpoolmanSettings.tsx b/frontend/src/components/SpoolmanSettings.tsx index 120691bf4..a02651d97 100644 --- a/frontend/src/components/SpoolmanSettings.tsx +++ b/frontend/src/components/SpoolmanSettings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react'; +import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react'; import { api } from '../api/client'; import type { SpoolmanSyncResult, Printer } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; @@ -40,6 +40,7 @@ export function SpoolmanSettings() { const [showSaved, setShowSaved] = useState(false); const [selectedPrinterId, setSelectedPrinterId] = useState('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.

+ {/* Info banner about sync requirements */} +
+
+ +
+

How Sync Works

+
    +
  • Only official Bambu Lab spools with RFID are synced
  • +
  • New spools are auto-created in Spoolman on first sync
  • +
  • Non-Bambu Lab spools (third-party, refilled) are skipped
  • +
+

Linking Existing Spools

+

+ To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman". +

+
+
+
+ {/* Enable toggle */}
@@ -355,16 +375,72 @@ export function SpoolmanSettings() { {/* Sync result */} {syncSuccess && syncResult && ( -
- {syncResult.success - ? `Synced ${syncResult.synced_count} trays successfully` - : `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`} +
+ {/* Main result */} +
+ {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' : ''}`} +
+ + {/* Skipped spools */} + {syncResult.skipped_count > 0 && ( +
+
+
+ + + {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped + +
+ {syncResult.skipped_count > 5 && ( + + )} +
+
    + {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => ( +
  • + {s.color && ( + + )} + {s.location} + - {s.reason} +
  • + ))} + {!showAllSkipped && syncResult.skipped_count > 5 && ( +
  • + ...and {syncResult.skipped_count - 5} more +
  • + )} +
+
+ )} + + {/* Errors */} + {syncResult.errors.length > 0 && ( +
+
Errors:
+
    + {syncResult.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )}
)}
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 6e4954b18..5c6cc8910 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -188,7 +188,18 @@ export function useWebSocket() { } break; + case 'print_start': + // Refetch printer status immediately when print starts to get printable_objects_count + if (message.printer_id !== undefined) { + queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] }); + } + break; + case 'print_complete': + // Refetch printer status when print completes to clear printable_objects_count + if (message.printer_id !== undefined) { + queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] }); + } debouncedInvalidate('archives'); debouncedInvalidate('archiveStats'); break; diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 06bd9ed06..0254cde9c 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -13,6 +13,7 @@ import { Box, HardDrive, AlertTriangle, + AlertCircle, Terminal, Power, PowerOff, @@ -28,7 +29,25 @@ import { Video, Search, Loader2, + Square, + Pause, + Play, + X, + Monitor, } from 'lucide-react'; + +// Custom Skip Objects icon - arrow jumping over boxes +const SkipObjectsIcon = ({ className }: { className?: string }) => ( + + {/* Three boxes at the bottom */} + + + + {/* Curved arrow jumping over first box */} + + + +); import { useNavigate } from 'react-router-dom'; import { api, discoveryApi } from '../api/client'; import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client'; @@ -41,79 +60,269 @@ import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; import { AMSHistoryModal } from '../components/AMSHistoryModal'; import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard'; +import { LinkSpoolModal } from '../components/LinkSpoolModal'; +import { useToast } from '../contexts/ToastContext'; -// Bambu Lab color code mapping (color suffix from tray_id_name -> color name) -// tray_id_name format: "A00-Y2" where Y2 is the color code -const BAMBU_COLOR_CODES: Record = { - // Yellows - 'Y0': 'Yellow', - 'Y1': 'Savana Yellow', - 'Y2': 'Sunflower Yellow', - 'Y3': 'Lemon Yellow', - // Oranges - 'O0': 'Orange', - 'O1': 'Mandarin Orange', - 'O2': 'Coral Orange', - // Reds - 'R0': 'Red', - 'R1': 'Scarlet Red', - 'R2': 'Magenta', - 'R3': 'Sakura Pink', - 'R4': 'Raspberry Red', - // Pinks - 'P0': 'Pink', - 'P1': 'Sakura Pink', - // Purples - 'V0': 'Purple', - 'V1': 'Violet', - 'V2': 'Lilac Purple', - // Blues - 'B0': 'Blue', - 'B1': 'Sky Blue', - 'B2': 'Navy Blue', - 'B3': 'Ice Blue', - 'B4': 'Cyan', - // Greens - 'G0': 'Green', - 'G1': 'Grass Green', - 'G2': 'Lime Green', - 'G3': 'Mint Green', - 'G4': 'Olive Green', - 'G5': 'Jungle Green', - 'G6': 'Bambu Green', - // Browns - 'N0': 'Brown', - 'N1': 'Peanut Brown', - 'N2': 'Coffee Brown', - 'N3': 'Caramel Brown', - // Grays - 'A0': 'Gray', - 'A1': 'Charcoal Gray', - 'A2': 'Silver Gray', - 'A3': 'Titan Gray', - // Blacks - 'K0': 'Black', - 'K1': 'Black', - // Whites - 'W0': 'White', - 'W1': 'Jade White', - 'W2': 'Ivory White', - // Special - 'T0': 'Transparent', - 'C0': 'Marble', - 'X0': 'Bronze', - 'X1': 'Gold', - 'X2': 'Silver', +// Complete Bambu Lab filament color mapping by tray_id_name +// Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library +const BAMBU_FILAMENT_COLORS: Record = { + // PLA Basic (A00) + 'A00-W1': 'Jade White', + 'A00-P0': 'Beige', + 'A00-D2': 'Light Gray', + 'A00-Y0': 'Yellow', + 'A00-Y2': 'Sunflower Yellow', + 'A00-A1': 'Pumpkin Orange', + 'A00-A0': 'Orange', + 'A00-Y4': 'Gold', + 'A00-G3': 'Bright Green', + 'A00-G1': 'Bambu Green', + 'A00-G2': 'Mistletoe Green', + 'A00-R3': 'Hot Pink', + 'A00-P6': 'Magenta', + 'A00-R0': 'Red', + 'A00-R2': 'Maroon Red', + 'A00-P5': 'Purple', + 'A00-P2': 'Indigo Purple', + 'A00-B5': 'Turquoise', + 'A00-B8': 'Cyan', + 'A00-B3': 'Cobalt Blue', + 'A00-N0': 'Brown', + 'A00-N1': 'Cocoa Brown', + 'A00-Y3': 'Bronze', + 'A00-D0': 'Gray', + 'A00-D1': 'Silver', + 'A00-B1': 'Blue Grey', + 'A00-D3': 'Dark Gray', + 'A00-K0': 'Black', + // PLA Basic Gradient (A00-M*) + 'A00-M3': 'Pink Citrus', + 'A00-M6': 'Dusk Glare', + 'A00-M0': 'Arctic Whisper', + 'A00-M1': 'Solar Breeze', + 'A00-M5': 'Blueberry Bubblegum', + 'A00-M4': 'Mint Lime', + 'A00-M2': 'Ocean to Meadow', + 'A00-M7': 'Cotton Candy Cloud', + // PLA Lite (A18) + 'A18-K0': 'Black', + 'A18-D0': 'Gray', + 'A18-W0': 'White', + 'A18-R0': 'Red', + 'A18-Y0': 'Yellow', + 'A18-B0': 'Cyan', + 'A18-B1': 'Blue', + 'A18-P0': 'Matte Beige', + // PLA Matte (A01) + 'A01-W2': 'Ivory White', + 'A01-W3': 'Bone White', + 'A01-Y2': 'Lemon Yellow', + 'A01-A2': 'Mandarin Orange', + 'A01-P3': 'Sakura Pink', + 'A01-P4': 'Lilac Purple', + 'A01-R3': 'Plum', + 'A01-R1': 'Scarlet Red', + 'A01-R4': 'Dark Red', + 'A01-G0': 'Apple Green', + 'A01-G1': 'Grass Green', + 'A01-G7': 'Dark Green', + 'A01-B4': 'Ice Blue', + 'A01-B0': 'Sky Blue', + 'A01-B3': 'Marine Blue', + 'A01-B6': 'Dark Blue', + 'A01-Y3': 'Desert Tan', + 'A01-N1': 'Latte Brown', + 'A01-N3': 'Caramel', + 'A01-R2': 'Terracotta', + 'A01-N2': 'Dark Brown', + 'A01-N0': 'Dark Chocolate', + 'A01-D3': 'Ash Gray', + 'A01-D0': 'Nardo Gray', + 'A01-K1': 'Charcoal', + // PLA Glow (A12) + 'A12-G0': 'Green', + 'A12-R0': 'Pink', + 'A12-A0': 'Orange', + 'A12-Y0': 'Yellow', + 'A12-B0': 'Blue', + // PLA Marble (A07) + 'A07-R5': 'Red Granite', + 'A07-D4': 'White Marble', + // PLA Aero (A11) + 'A11-W0': 'White', + 'A11-K0': 'Black', + // PLA Sparkle (A08) + 'A08-G3': 'Alpine Green Sparkle', + 'A08-D5': 'Slate Gray Sparkle', + 'A08-B7': 'Royal Purple Sparkle', + 'A08-R2': 'Crimson Red Sparkle', + 'A08-K2': 'Onyx Black Sparkle', + 'A08-Y1': 'Classic Gold Sparkle', + // PLA Metal (A02) + 'A02-B2': 'Cobalt Blue Metallic', + 'A02-G2': 'Oxide Green Metallic', + 'A02-Y1': 'Iridium Gold Metallic', + 'A02-D2': 'Iron Gray Metallic', + // PLA Translucent (A17) + 'A17-B1': 'Blue', + 'A17-A0': 'Orange', + 'A17-P0': 'Purple', + // PLA Silk+ (A06) + 'A06-Y1': 'Gold', + 'A06-D0': 'Titan Gray', + 'A06-D1': 'Silver', + 'A06-W0': 'White', + 'A06-R0': 'Candy Red', + 'A06-G0': 'Candy Green', + 'A06-G1': 'Mint', + 'A06-B1': 'Blue', + 'A06-B0': 'Baby Blue', + 'A06-P0': 'Purple', + 'A06-R1': 'Rose Gold', + 'A06-R2': 'Pink', + 'A06-Y0': 'Champagne', + // PLA Silk Multi-Color (A05) + 'A05-M8': 'Dawn Radiance', + 'A05-M4': 'Aurora Purple', + 'A05-M1': 'South Beach', + 'A05-T3': 'Neon City', + 'A05-T2': 'Midnight Blaze', + 'A05-T1': 'Gilded Rose', + 'A05-T4': 'Blue Hawaii', + 'A05-T5': 'Velvet Eclipse', + // PLA Galaxy (A15) + 'A15-B0': 'Purple', + 'A15-G0': 'Green', + 'A15-G1': 'Nebulae', + 'A15-R0': 'Brown', + // PLA Wood (A16) + 'A16-K0': 'Black Walnut', + 'A16-R0': 'Rosewood', + 'A16-N0': 'Clay Brown', + 'A16-G0': 'Classic Birch', + 'A16-W0': 'White Oak', + 'A16-Y0': 'Ochre Yellow', + // PLA-CF (A50) + 'A50-D6': 'Lava Gray', + 'A50-K0': 'Black', + 'A50-B6': 'Royal Blue', + // PLA Tough+ (A10) + 'A10-W0': 'White', + 'A10-D0': 'Gray', + // PLA Tough (A09) + 'A09-B5': 'Lavender Blue', + 'A09-B4': 'Light Blue', + 'A09-A0': 'Orange', + 'A09-D1': 'Silver', + 'A09-R3': 'Vermilion Red', + 'A09-Y0': 'Yellow', + // PETG HF (G02) + 'G02-K0': 'Black', + 'G02-W0': 'White', + 'G02-R0': 'Red', + 'G02-D0': 'Gray', + 'G02-D1': 'Dark Gray', + 'G02-Y1': 'Cream', + 'G02-Y0': 'Yellow', + 'G02-A0': 'Orange', + 'G02-N1': 'Peanut Brown', + 'G02-G1': 'Lime Green', + 'G02-G0': 'Green', + 'G02-G2': 'Forest Green', + 'G02-B1': 'Lake Blue', + 'G02-B0': 'Blue', + // PETG Translucent (G01) + 'G01-G1': 'Translucent Teal', + 'G01-B0': 'Translucent Light Blue', + 'G01-C0': 'Clear', + 'G01-D0': 'Translucent Gray', + 'G01-G0': 'Translucent Olive', + 'G01-N0': 'Translucent Brown', + 'G01-A0': 'Translucent Orange', + 'G01-P1': 'Translucent Pink', + 'G01-P0': 'Translucent Purple', + // PETG-CF (G50) + 'G50-P7': 'Violet Purple', + 'G50-K0': 'Black', + // ABS (B00) + 'B00-D1': 'Silver', + 'B00-K0': 'Black', + 'B00-W0': 'White', + 'B00-G6': 'Bambu Green', + 'B00-G7': 'Olive', + 'B00-Y1': 'Tangerine Yellow', + 'B00-A0': 'Orange', + 'B00-R0': 'Red', + 'B00-B4': 'Azure', + 'B00-B0': 'Blue', + 'B00-B6': 'Navy Blue', + // ABS-GF (B50) + 'B50-A0': 'Orange', + 'B50-K0': 'Black', + // ASA (B01) + 'B01-W0': 'White', + 'B01-K0': 'Black', + 'B01-D0': 'Gray', + // ASA Aero (B02) + 'B02-W0': 'White', + // PC (C00) + 'C00-C1': 'Transparent', + 'C00-C0': 'Clear Black', + 'C00-K0': 'Black', + 'C00-W0': 'White', + // PC FR (C01) + 'C01-K0': 'Black', + // TPU for AMS (U02) + 'U02-B0': 'Blue', + 'U02-D0': 'Gray', + 'U02-K0': 'Black', + // PAHT-CF (N04) + 'N04-K0': 'Black', + // PA6-GF (N08) + 'N08-K0': 'Black', + // Support for PLA/PETG (S02, S05) + 'S02-W0': 'Nature', + 'S02-W1': 'White', + 'S05-C0': 'Black', + // Support for ABS (S06) + 'S06-W0': 'White', + // Support for PA/PET (S03) + 'S03-G1': 'Green', + // PVA (S04) + 'S04-Y0': 'Clear', +}; + +// Fallback color codes for unknown material prefixes +const BAMBU_COLOR_CODE_FALLBACK: Record = { + 'W0': 'White', 'W1': 'Jade White', 'W2': 'Ivory White', 'W3': 'Bone White', + 'Y0': 'Yellow', 'Y1': 'Gold', 'Y2': 'Sunflower Yellow', 'Y3': 'Bronze', 'Y4': 'Gold', + 'A0': 'Orange', 'A1': 'Pumpkin Orange', 'A2': 'Mandarin Orange', + 'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Maroon Red', 'R3': 'Hot Pink', 'R4': 'Dark Red', 'R5': 'Red Granite', + 'P0': 'Beige', 'P1': 'Pink', 'P2': 'Indigo Purple', 'P3': 'Sakura Pink', 'P4': 'Lilac Purple', 'P5': 'Purple', 'P6': 'Magenta', 'P7': 'Violet Purple', + 'B0': 'Blue', 'B1': 'Blue Grey', 'B2': 'Cobalt Blue', 'B3': 'Cobalt Blue', 'B4': 'Ice Blue', 'B5': 'Turquoise', 'B6': 'Navy Blue', 'B7': 'Royal Purple', 'B8': 'Cyan', + 'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Mistletoe Green', 'G3': 'Bright Green', 'G6': 'Bambu Green', 'G7': 'Dark Green', + 'N0': 'Brown', 'N1': 'Peanut Brown', 'N2': 'Dark Brown', 'N3': 'Caramel', + 'D0': 'Gray', 'D1': 'Silver', 'D2': 'Light Gray', 'D3': 'Dark Gray', 'D4': 'White Marble', 'D5': 'Slate Gray', 'D6': 'Lava Gray', + 'K0': 'Black', 'K1': 'Charcoal', 'K2': 'Onyx Black', + 'C0': 'Clear Black', 'C1': 'Transparent', + 'M0': 'Arctic Whisper', 'M1': 'Solar Breeze', 'M2': 'Ocean to Meadow', 'M3': 'Pink Citrus', 'M4': 'Aurora Purple', 'M5': 'Blueberry Bubblegum', 'M6': 'Dusk Glare', 'M7': 'Cotton Candy Cloud', 'M8': 'Dawn Radiance', + 'T1': 'Gilded Rose', 'T2': 'Midnight Blaze', 'T3': 'Neon City', 'T4': 'Blue Hawaii', 'T5': 'Velvet Eclipse', }; // Get color name from Bambu Lab tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow") function getBambuColorName(trayIdName: string | null | undefined): string | null { if (!trayIdName) return null; - // Extract color code after the dash (e.g., "A00-Y2" -> "Y2") + + // First try exact match with full tray_id_name + if (BAMBU_FILAMENT_COLORS[trayIdName]) { + return BAMBU_FILAMENT_COLORS[trayIdName]; + } + + // Fall back to color code suffix lookup for unknown material prefixes const parts = trayIdName.split('-'); if (parts.length < 2) return null; const colorCode = parts[1]; - return BAMBU_COLOR_CODES[colorCode] || null; + return BAMBU_COLOR_CODE_FALLBACK[colorCode] || null; } // Convert hex color to basic color name @@ -649,6 +858,7 @@ function PrinterCard({ maintenanceInfo, viewMode = 'expanded', amsThresholds, + spoolmanEnabled = false, }: { printer: Printer; hideIfDisconnected?: boolean; @@ -660,9 +870,11 @@ function PrinterCard({ tempGood: number; tempFair: number; }; + spoolmanEnabled?: boolean; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); + const { showToast } = useToast(); const [showMenu, setShowMenu] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteArchives, setDeleteArchives] = useState(true); @@ -672,11 +884,19 @@ function PrinterCard({ const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false); const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false); const [showHMSModal, setShowHMSModal] = useState(false); + const [showStopConfirm, setShowStopConfirm] = useState(false); + const [showPauseConfirm, setShowPauseConfirm] = useState(false); + const [showResumeConfirm, setShowResumeConfirm] = useState(false); + const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false); const [amsHistoryModal, setAmsHistoryModal] = useState<{ amsId: number; amsLabel: string; mode: 'humidity' | 'temperature'; } | null>(null); + const [linkSpoolModal, setLinkSpoolModal] = useState<{ + trayUuid: string; + trayInfo: { type: string; color: string; location: string }; + } | null>(null); const { data: status } = useQuery({ queryKey: ['printerStatus', printer.id], @@ -828,6 +1048,147 @@ function PrinterCard({ }, }); + // Print control mutations + const stopPrintMutation = useMutation({ + mutationFn: () => api.stopPrint(printer.id), + onSuccess: () => { + showToast('Print stopped'); + queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); + }, + onError: (error: Error) => showToast(error.message || 'Failed to stop print', 'error'), + }); + + const pausePrintMutation = useMutation({ + mutationFn: () => api.pausePrint(printer.id), + onSuccess: () => { + showToast('Print paused'); + queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); + }, + onError: (error: Error) => showToast(error.message || 'Failed to pause print', 'error'), + }); + + const resumePrintMutation = useMutation({ + mutationFn: () => api.resumePrint(printer.id), + onSuccess: () => { + showToast('Print resumed'); + queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); + }, + onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'), + }); + + // Query for printable objects (for skip functionality) + // Fetch when printing with 2+ objects OR when modal is open + const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2; + const { data: objectsData, refetch: refetchObjects } = useQuery({ + queryKey: ['printableObjects', printer.id], + queryFn: () => api.getPrintableObjects(printer.id), + enabled: showSkipObjectsModal || isPrintingWithObjects, + refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise + }); + + // Skip objects mutation + const skipObjectsMutation = useMutation({ + mutationFn: (objectIds: number[]) => api.skipObjects(printer.id, objectIds), + onSuccess: (data) => { + showToast(data.message || 'Objects skipped'); + refetchObjects(); + }, + onError: (error: Error) => showToast(error.message || 'Failed to skip objects', 'error'), + }); + + // State for tracking which AMS slot is being refreshed + const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null); + // Track if we've seen the printer enter "busy" state (ams_status_main !== 0) + const seenBusyStateRef = useRef(false); + // Fallback timeout ref + const refreshTimeoutRef = useRef | null>(null); + // Minimum display time passed + const minTimePassedRef = useRef(false); + + // AMS slot refresh mutation + const refreshAmsSlotMutation = useMutation({ + mutationFn: ({ amsId, slotId }: { amsId: number; slotId: number }) => + api.refreshAmsSlot(printer.id, amsId, slotId), + onMutate: ({ amsId, slotId }) => { + // Clear any existing timeout + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + // Reset state + seenBusyStateRef.current = false; + minTimePassedRef.current = false; + setRefreshingSlot({ amsId, slotId }); + // Minimum display time (2 seconds) + setTimeout(() => { + minTimePassedRef.current = true; + }, 2000); + // Fallback timeout (30 seconds max) + refreshTimeoutRef.current = setTimeout(() => { + setRefreshingSlot(null); + }, 30000); + }, + onSuccess: (data) => { + showToast(data.message || 'RFID re-read initiated'); + }, + onError: (error: Error) => { + showToast(error.message || 'Failed to re-read RFID', 'error'); + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + setRefreshingSlot(null); + }, + }); + + // Watch ams_status_main to detect when RFID read completes + // ams_status_main: 0=idle, 2=rfid_identifying + const deferredClearRef = useRef | null>(null); + + useEffect(() => { + if (!refreshingSlot) return; + + const amsStatus = status?.ams_status_main ?? 0; + + // Track when we see non-idle state (printer is working) + if (amsStatus !== 0) { + seenBusyStateRef.current = true; + // Cancel any deferred clear since we're back to busy + if (deferredClearRef.current) { + clearTimeout(deferredClearRef.current); + deferredClearRef.current = null; + } + } + + // When we've seen busy and now idle, clear (with min time check) + if (seenBusyStateRef.current && amsStatus === 0) { + if (minTimePassedRef.current) { + // Min time passed - clear now + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + setRefreshingSlot(null); + } else { + // Schedule clear after min time (2 seconds from start) + if (!deferredClearRef.current) { + deferredClearRef.current = setTimeout(() => { + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + setRefreshingSlot(null); + }, 2000); + } + } + } + + return () => { + if (deferredClearRef.current) { + clearTimeout(deferredClearRef.current); + } + }; + }, [status?.ams_status_main, refreshingSlot]); + + // State for AMS slot menu + const [amsSlotMenu, setAmsSlotMenu] = useState<{ amsId: number; slotId: number } | null>(null); + if (shouldHide) { return null; } @@ -1114,7 +1475,32 @@ function PrinterCard({ /* Expanded: Full status section */ <> {/* Current Print or Idle Placeholder */} -
+
+ {/* Skip Objects button - top right corner, always visible */} +
{/* Cover Image */} )} - {/* Temperatures */} + {/* Temperatures + Print Controls */} {status.temperatures && viewMode === 'expanded' && (() => { // Use actual heater states from MQTT stream const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false; const bedHeating = status.temperatures.bed_heating || false; const chamberHeating = status.temperatures.chamber_heating || false; + // Determine print state for control buttons + const isRunning = status.state === 'RUNNING'; + const isPaused = status.state === 'PAUSED' || status.state === 'PAUSE'; + const isPrinting = isRunning || isPaused; + const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending; + return ( -
- {/* Nozzle temp - combined for dual nozzle */} -
- - {status.temperatures.nozzle_2 !== undefined ? ( - <> -

Left / Right

-

- {Math.round(status.temperatures.nozzle || 0)}°C / {Math.round(status.temperatures.nozzle_2 || 0)}°C -

- - ) : ( - <> -

Nozzle

-

- {Math.round(status.temperatures.nozzle || 0)}°C -

- - )} -
-
- -

Bed

-

- {Math.round(status.temperatures.bed || 0)}°C -

-
- {status.temperatures.chamber !== undefined && ( +
+ {/* Temperature cards */} +
+ {/* Nozzle temp - combined for dual nozzle */}
- -

Chamber

-

- {Math.round(status.temperatures.chamber || 0)}°C + + {status.temperatures.nozzle_2 !== undefined ? ( + <> +

L / R

+

+ {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}° +

+ + ) : ( + <> +

Nozzle

+

+ {Math.round(status.temperatures.nozzle || 0)}°C +

+ + )} +
+
+ +

Bed

+

+ {Math.round(status.temperatures.bed || 0)}°C

- )} + {status.temperatures.chamber !== undefined ? ( +
+ +

Chamber

+

+ {Math.round(status.temperatures.chamber || 0)}°C +

+
+ ) : ( +
/* Empty placeholder to maintain grid */ + )} +
+ + {/* Print control buttons */} +
+ {/* Stop button - visible when printing/paused */} + + + {/* Pause/Resume button */} + +
); })()} @@ -1338,11 +1776,17 @@ function PrinterCard({ colorHex: tray.tray_color || null, kFactor: formatKValue(tray.k), fillLevel: hasFillLevel ? tray.remain : null, + trayUuid: tray.tray_uuid || null, } : null; - const slotContent = ( + // Check if this specific slot is being refreshed + const isRefreshing = refreshingSlot?.amsId === ams.id && + refreshingSlot?.slotId === slotIdx; + + // Slot visual content (goes inside hover card) + const slotVisual = (
); - return filamentData ? ( - - {slotContent} - - ) : ( - - {slotContent} - + // Wrapper with menu button, dropdown, and loading overlay (outside hover card) + return ( +
+ {/* Loading overlay during RFID re-read */} + {isRefreshing && ( +
+ +
+ )} + {/* Menu button - appears on hover, hidden when printer busy */} + {status?.state !== 'RUNNING' && ( + + )} + {/* Dropdown menu */} + {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && ( +
+ +
+ )} + {/* Hover card wraps only the visual content */} + {filamentData ? ( + { + setLinkSpoolModal({ + trayUuid: uuid, + trayInfo: { + type: filamentData.profile, + color: filamentData.colorHex || '', + location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`, + }, + }); + } : undefined, + }} + > + {slotVisual} + + ) : ( + + {slotVisual} + + )} +
); })}
@@ -1417,11 +1922,18 @@ function PrinterCard({ colorHex: tray.tray_color || null, kFactor: formatKValue(tray.k), fillLevel: hasFillLevel ? tray.remain : null, + trayUuid: tray.tray_uuid || null, } : null; - const slotContent = ( + const htSlotId = tray?.id ?? 0; + // Check if this specific slot is being refreshed + const isHtRefreshing = refreshingSlot?.amsId === ams.id && + refreshingSlot?.slotId === htSlotId; + + // Slot visual content (goes inside hover card) + const slotVisual = (
{/* Row 2: Slot (left) + Stats (right stacked) */}
- {/* Slot - takes remaining width */} - {filamentData ? ( - - {slotContent} - - ) : ( - - {slotContent} - - )} + {/* Slot wrapper with menu button, dropdown, and loading overlay */} +
+ {/* Loading overlay during RFID re-read */} + {isHtRefreshing && ( +
+ +
+ )} + {/* Menu button - appears on hover, hidden when printer busy */} + {status?.state !== 'RUNNING' && ( + + )} + {/* Dropdown menu */} + {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && ( +
+ +
+ )} + {/* Hover card wraps only the visual content */} + {filamentData ? ( + { + setLinkSpoolModal({ + trayUuid: uuid, + trayInfo: { + type: filamentData.profile, + color: filamentData.colorHex || '', + location: getAmsLabel(ams.id, ams.tray.length), + }, + }); + } : undefined, + }} + > + {slotVisual} + + ) : ( + + {slotVisual} + + )} +
{/* Stats stacked vertically: Temp on top, Humidity below */} {(ams.humidity != null || ams.temp != null) && (
@@ -1525,6 +2095,7 @@ function PrinterCard({ colorHex: extTray.tray_color || null, kFactor: formatKValue(extTray.k), fillLevel: null, // External spool has unknown fill level + trayUuid: extTray.tray_uuid || null, }; const extSlotContent = ( @@ -1553,7 +2124,22 @@ function PrinterCard({ External
{/* Row 2: Slot (full width since no stats) */} - + { + setLinkSpoolModal({ + trayUuid: uuid, + trayInfo: { + type: extFilamentData.profile, + color: extFilamentData.colorHex || '', + location: 'External Spool', + }, + }); + } : undefined, + }} + > {extSlotContent}
@@ -1749,6 +2335,249 @@ function PrinterCard({ /> )} + {/* Stop Print Confirmation */} + {showStopConfirm && ( + { + stopPrintMutation.mutate(); + setShowStopConfirm(false); + }} + onCancel={() => setShowStopConfirm(false)} + /> + )} + + {/* Pause Print Confirmation */} + {showPauseConfirm && ( + { + pausePrintMutation.mutate(); + setShowPauseConfirm(false); + }} + onCancel={() => setShowPauseConfirm(false)} + /> + )} + + {/* Resume Print Confirmation */} + {showResumeConfirm && ( + { + resumePrintMutation.mutate(); + setShowResumeConfirm(false); + }} + onCancel={() => setShowResumeConfirm(false)} + /> + )} + + {/* Skip Objects Popup */} + {showSkipObjectsModal && ( +
setShowSkipObjectsModal(false)} + onKeyDown={(e) => e.key === 'Escape' && setShowSkipObjectsModal(false)} + tabIndex={-1} + ref={(el) => el?.focus()} + > + {/* Backdrop */} +
+ {/* Modal */} +
e.stopPropagation()} + > + {/* Header */} +
+
+ + Skip Objects +
+ +
+ + {!objectsData ? ( +
+ +
+ ) : objectsData.objects.length === 0 ? ( +
+

No objects found

+

Objects are loaded when a print starts

+
+ ) : ( +
+ {/* Info Banner */} +
+
+ +
+
+

Match IDs with your printer display

+

The printer screen shows object IDs on the build plate

+
+
+ {objectsData.skipped_count}/{objectsData.total} skipped +
+
+ + {/* Layer Warning */} + {(status?.layer_num ?? 0) <= 1 && ( +
+ +

+ Wait for layer 2+ to skip objects (currently layer {status?.layer_num ?? 0}) +

+
+ )} + + {/* Content: Image + List side by side */} +
+ {/* Left: Preview Image with object markers */} +
+
+ {status?.cover_url ? ( + Print preview + ) : ( +
+ +
+ )} + {/* Object ID markers overlay - positioned based on object data */} + {objectsData.objects.length > 0 && ( +
+ {objectsData.objects.map((obj, idx) => { + // Build plate is typically 256x256mm for X1C + const buildPlateSize = 256; + let x: number, y: number; + + // Use position data if available, otherwise fall back to grid + if (obj.x != null && obj.y != null) { + // Convert mm position to percentage (0-100) + // Clamp to valid range and add padding + // Y axis is inverted: 3D printing Y goes back, image Y goes down + x = Math.max(10, Math.min(90, (obj.x / buildPlateSize) * 100)); + y = Math.max(10, Math.min(90, 100 - (obj.y / buildPlateSize) * 100)); + } else { + // Fallback: arrange in a grid pattern over the build plate area + const cols = Math.ceil(Math.sqrt(objectsData.objects.length)); + const row = Math.floor(idx / cols); + const col = idx % cols; + const rows = Math.ceil(objectsData.objects.length / cols); + x = 15 + (col * (70 / cols)) + (35 / cols); + y = 15 + (row * (70 / rows)) + (35 / rows); + } + + return ( +
+ {obj.id} +
+ ); + })} +
+ )} + {/* Object count overlay */} +
+ {objectsData.objects.filter(o => !o.skipped).length} active +
+
+
+ + {/* Right: Object List with prominent IDs */} +
+ {objectsData.objects.map((obj) => ( +
+ {/* Large prominent ID badge */} +
+ + {obj.id} + + + ID + +
+ + {/* Object name and status */} +
+ + {obj.name} + + {obj.skipped && ( + Will be skipped + )} +
+ + {/* Skip button */} + {!obj.skipped ? ( + + ) : ( + + Skipped + + )} +
+ ))} +
+
+
+ )} +
+
+ )} + {/* HMS Error Modal */} {showHMSModal && ( )} + {/* Link Spool Modal */} + {linkSpoolModal && ( + setLinkSpoolModal(null)} + trayUuid={linkSpoolModal.trayUuid} + trayInfo={linkSpoolModal.trayInfo} + /> + )} + {/* Edit Printer Modal */} {showEditModal && ( setShowEditModal(false)} /> )} + + {/* AMS Slot Menu Backdrop - closes menu when clicking outside */} + {amsSlotMenu && ( +
setAmsSlotMenu(null)} + /> + )} ); } @@ -2442,6 +3289,14 @@ export function PrintersPage() { staleTime: 60 * 1000, // 1 minute }); + // Fetch Spoolman status to enable link spool feature + const { data: spoolmanStatus } = useQuery({ + queryKey: ['spoolman-status'], + queryFn: api.getSpoolmanStatus, + staleTime: 60 * 1000, // 1 minute + }); + const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected; + // Create a map of printer_id -> maintenance info for quick lookup const maintenanceByPrinter = maintenanceOverview?.reduce( (acc, overview) => { @@ -2716,6 +3571,7 @@ export function PrintersPage() { tempGood: Number(settings.ams_temp_good) || 28, tempFair: Number(settings.ams_temp_fair) || 35, } : undefined} + spoolmanEnabled={spoolmanEnabled} /> ))}
@@ -2736,6 +3592,7 @@ export function PrintersPage() { hideIfDisconnected={hideDisconnected} maintenanceInfo={maintenanceByPrinter[printer.id]} viewMode={viewMode} + spoolmanEnabled={spoolmanEnabled} amsThresholds={settings ? { humidityGood: Number(settings.ams_humidity_good) || 40, humidityFair: Number(settings.ams_humidity_fair) || 60, diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index c97b41594..77c44161c 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -434,8 +434,8 @@ export function ProjectDetailPage() { const stats = project.stats; const progressPercent = stats?.progress_percent ?? 0; - const successRate = stats && stats.total_archives > 0 - ? ((stats.completed_prints / stats.total_archives) * 100).toFixed(0) + const successRate = stats && stats.total_items > 0 + ? ((stats.completed_prints / stats.total_items) * 100).toFixed(0) : null; return ( @@ -485,7 +485,7 @@ export function ProjectDetailPage() {
Progress - {stats?.completed_prints || 0} / {project.target_count} prints + {stats?.completed_prints || 0} / {project.target_count} items
@@ -516,16 +516,16 @@ export function ProjectDetailPage() {
0 ? `${stats.failed_prints} failed` : undefined} + subValue={stats.failed_prints > 0 ? `${stats.failed_prints} failed` : (successRate ? `${successRate}% success` : undefined)} color="text-blue-400" /> - {project.archive_count}/{project.target_count} parts + {project.total_items}/{project.target_count} items - ) : project.archive_count > 0 ? ( + ) : project.total_items > 0 ? ( - {project.archive_count} print{project.archive_count !== 1 ? 's' : ''} + {project.total_items} item{project.total_items !== 1 ? 's' : ''} ) : null} {isCompleted && ( @@ -365,7 +365,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
Progress = 100 ? 'text-bambu-green font-medium' : 'text-white'}> - {project.archive_count} / {project.target_count} + {project.total_items} / {project.target_count}
@@ -384,11 +384,11 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) { {progressPercent.toFixed(0)}% complete
- ) : project.archive_count > 0 ? ( + ) : project.total_items > 0 ? (
- {project.archive_count} print{project.archive_count !== 1 ? 's' : ''} completed + {project.total_items} item{project.total_items !== 1 ? 's' : ''} completed
{project.queue_count > 0 && (
@@ -444,9 +444,9 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) { {/* Stats footer */}
-
+
- {project.archive_count} + {project.total_items}
{project.queue_count > 0 && (
@@ -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'); }, }); diff --git a/static/assets/index-3umWYOC3.js b/static/assets/index-BaU_6oPV.js similarity index 54% rename from static/assets/index-3umWYOC3.js rename to static/assets/index-BaU_6oPV.js index 121c02627..63ba4a2f6 100644 --- a/static/assets/index-3umWYOC3.js +++ b/static/assets/index-BaU_6oPV.js @@ -1,20 +1,20 @@ -function Zre(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const a of i)if(a.type==="childList")for(const s of a.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function n(i){const a={};return i.integrity&&(a.integrity=i.integrity),i.referrerPolicy&&(a.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?a.credentials="include":i.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(i){if(i.ep)return;i.ep=!0;const a=n(i);fetch(i.href,a)}})();var wv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function bo(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jT={exports:{}},fx={};var SF;function Qre(){if(SF)return fx;SF=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,a){var s=null;if(a!==void 0&&(s=""+a),i.key!==void 0&&(s=""+i.key),"key"in i){a={};for(var l in i)l!=="key"&&(a[l]=i[l])}else a=i;return i=a.ref,{$$typeof:t,type:r,key:s,ref:i!==void 0?i:null,props:a}}return fx.Fragment=e,fx.jsx=n,fx.jsxs=n,fx}var MF;function Jre(){return MF||(MF=1,jT.exports=Qre()),jT.exports}var o=Jre(),RT={exports:{}},tn={};var EF;function eie(){if(EF)return tn;EF=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),a=Symbol.for("react.consumer"),s=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),g=Symbol.iterator;function p(B){return B===null||typeof B!="object"?null:(B=g&&B[g]||B["@@iterator"],typeof B=="function"?B:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,y={};function b(B,G,Q){this.props=B,this.context=G,this.refs=y,this.updater=Q||x}b.prototype.isReactComponent={},b.prototype.setState=function(B,G){if(typeof B!="object"&&typeof B!="function"&&B!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,B,G,"setState")},b.prototype.forceUpdate=function(B){this.updater.enqueueForceUpdate(this,B,"forceUpdate")};function E(){}E.prototype=b.prototype;function w(B,G,Q){this.props=B,this.context=G,this.refs=y,this.updater=Q||x}var T=w.prototype=new E;T.constructor=w,v(T,b.prototype),T.isPureReactComponent=!0;var A=Array.isArray;function C(){}var N={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function k(B,G,Q){var te=Q.ref;return{$$typeof:t,type:B,key:G,ref:te!==void 0?te:null,props:Q}}function R(B,G){return k(B.type,G,B.props)}function I(B){return typeof B=="object"&&B!==null&&B.$$typeof===t}function L(B){var G={"=":"=0",":":"=2"};return"$"+B.replace(/[=:]/g,function(Q){return G[Q]})}var q=/\/+/g;function j(B,G){return typeof B=="object"&&B!==null&&B.key!=null?L(""+B.key):G.toString(36)}function V(B){switch(B.status){case"fulfilled":return B.value;case"rejected":throw B.reason;default:switch(typeof B.status=="string"?B.then(C,C):(B.status="pending",B.then(function(G){B.status==="pending"&&(B.status="fulfilled",B.value=G)},function(G){B.status==="pending"&&(B.status="rejected",B.reason=G)})),B.status){case"fulfilled":return B.value;case"rejected":throw B.reason}}throw B}function K(B,G,Q,te,he){var oe=typeof B;(oe==="undefined"||oe==="boolean")&&(B=null);var xe=!1;if(B===null)xe=!0;else switch(oe){case"bigint":case"string":case"number":xe=!0;break;case"object":switch(B.$$typeof){case t:case e:xe=!0;break;case d:return xe=B._init,K(xe(B._payload),G,Q,te,he)}}if(xe)return he=he(B),xe=te===""?"."+j(B,0):te,A(he)?(Q="",xe!=null&&(Q=xe.replace(q,"$&/")+"/"),K(he,G,Q,"",function(ce){return ce})):he!=null&&(I(he)&&(he=R(he,Q+(he.key==null||B&&B.key===he.key?"":(""+he.key).replace(q,"$&/")+"/")+xe)),G.push(he)),1;xe=0;var Te=te===""?".":te+":";if(A(B))for(var me=0;me>>1,Z=K[ee];if(0>>1;eei(Q,J))tei(he,Q)?(K[ee]=he,K[te]=J,ee=te):(K[ee]=Q,K[G]=J,ee=G);else if(tei(he,J))K[ee]=he,K[te]=J,ee=te;else break e}}return W}function i(K,W){var J=K.sortIndex-W.sortIndex;return J!==0?J:K.id-W.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;t.unstable_now=function(){return a.now()}}else{var s=Date,l=s.now();t.unstable_now=function(){return s.now()-l}}var c=[],u=[],d=1,f=null,g=3,p=!1,x=!1,v=!1,y=!1,b=typeof setTimeout=="function"?setTimeout:null,E=typeof clearTimeout=="function"?clearTimeout:null,w=typeof setImmediate<"u"?setImmediate:null;function T(K){for(var W=n(u);W!==null;){if(W.callback===null)r(u);else if(W.startTime<=K)r(u),W.sortIndex=W.expirationTime,e(c,W);else break;W=n(u)}}function A(K){if(v=!1,T(K),!x)if(n(c)!==null)x=!0,C||(C=!0,L());else{var W=n(u);W!==null&&V(A,W.startTime-K)}}var C=!1,N=-1,F=5,k=-1;function R(){return y?!0:!(t.unstable_now()-kK&&R());){var ee=f.callback;if(typeof ee=="function"){f.callback=null,g=f.priorityLevel;var Z=ee(f.expirationTime<=K);if(K=t.unstable_now(),typeof Z=="function"){f.callback=Z,T(K),W=!0;break t}f===n(c)&&r(c),T(K)}else r(c);f=n(c)}if(f!==null)W=!0;else{var B=n(u);B!==null&&V(A,B.startTime-K),W=!1}}break e}finally{f=null,g=J,p=!1}W=void 0}}finally{W?L():C=!1}}}var L;if(typeof w=="function")L=function(){w(I)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,j=q.port2;q.port1.onmessage=I,L=function(){j.postMessage(null)}}else L=function(){b(I,0)};function V(K,W){N=b(function(){K(t.unstable_now())},W)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(K){K.callback=null},t.unstable_forceFrameRate=function(K){0>K||125ee?(K.sortIndex=J,e(u,K),n(c)===null&&K===n(u)&&(v?(E(N),N=-1):v=!0,V(A,J-ee))):(K.sortIndex=Z,e(c,K),x||p||(x=!0,C||(C=!0,L()))),K},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(K){var W=g;return function(){var J=g;g=W;try{return K.apply(this,arguments)}finally{g=J}}}})(LT)),LT}var NF;function rie(){return NF||(NF=1,OT.exports=nie()),OT.exports}var IT={exports:{}},ca={};var CF;function iie(){if(CF)return ca;CF=1;var t=Jp();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),IT.exports=iie(),IT.exports}var PF;function aie(){if(PF)return mx;PF=1;var t=rie(),e=Jp(),n=X9();function r(h){var m="https://react.dev/errors/"+h;if(1Z||(h.current=ee[Z],ee[Z]=null,Z--)}function Q(h,m){Z++,ee[Z]=h.current,h.current=m}var te=B(null),he=B(null),oe=B(null),xe=B(null);function Te(h,m){switch(Q(oe,m),Q(he,h),Q(te,null),m.nodeType){case 9:case 11:h=(h=m.documentElement)&&(h=h.namespaceURI)?W5(h):0;break;default:if(h=m.tagName,m=m.namespaceURI)m=W5(m),h=q5(m,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}G(te),Q(te,h)}function me(){G(te),G(he),G(oe)}function ce(h){h.memoizedState!==null&&Q(xe,h);var m=te.current,_=q5(m,h.type);m!==_&&(Q(he,h),Q(te,_))}function Ce(h){he.current===h&&(G(te),G(he)),xe.current===h&&(G(xe),cx._currentValue=J)}var fe,Ie;function z(h){if(fe===void 0)try{throw Error()}catch(_){var m=_.stack.trim().match(/\n( *(at )?)/);fe=m&&m[1]||"",Ie=-1<_.stack.indexOf(` +function Jre(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const a of i)if(a.type==="childList")for(const s of a.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function n(i){const a={};return i.integrity&&(a.integrity=i.integrity),i.referrerPolicy&&(a.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?a.credentials="include":i.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(i){if(i.ep)return;i.ep=!0;const a=n(i);fetch(i.href,a)}})();var Mv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function yo(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var DA={exports:{}},mx={};var MF;function eie(){if(MF)return mx;MF=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,a){var s=null;if(a!==void 0&&(s=""+a),i.key!==void 0&&(s=""+i.key),"key"in i){a={};for(var l in i)l!=="key"&&(a[l]=i[l])}else a=i;return i=a.ref,{$$typeof:t,type:r,key:s,ref:i!==void 0?i:null,props:a}}return mx.Fragment=e,mx.jsx=n,mx.jsxs=n,mx}var EF;function tie(){return EF||(EF=1,DA.exports=eie()),DA.exports}var o=tie(),OA={exports:{}},on={};var AF;function nie(){if(AF)return on;AF=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),a=Symbol.for("react.consumer"),s=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),g=Symbol.iterator;function p(z){return z===null||typeof z!="object"?null:(z=g&&z[g]||z["@@iterator"],typeof z=="function"?z:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,y={};function b(z,V,X){this.props=z,this.context=V,this.refs=y,this.updater=X||x}b.prototype.isReactComponent={},b.prototype.setState=function(z,V){if(typeof z!="object"&&typeof z!="function"&&z!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,z,V,"setState")},b.prototype.forceUpdate=function(z){this.updater.enqueueForceUpdate(this,z,"forceUpdate")};function E(){}E.prototype=b.prototype;function w(z,V,X){this.props=z,this.context=V,this.refs=y,this.updater=X||x}var A=w.prototype=new E;A.constructor=w,v(A,b.prototype),A.isPureReactComponent=!0;var T=Array.isArray;function C(){}var N={H:null,A:null,T:null,S:null},I=Object.prototype.hasOwnProperty;function k(z,V,X){var te=X.ref;return{$$typeof:t,type:z,key:V,ref:te!==void 0?te:null,props:X}}function j(z,V){return k(z.type,V,z.props)}function B(z){return typeof z=="object"&&z!==null&&z.$$typeof===t}function L(z){var V={"=":"=0",":":"=2"};return"$"+z.replace(/[=:]/g,function(X){return V[X]})}var K=/\/+/g;function R(z,V){return typeof z=="object"&&z!==null&&z.key!=null?L(""+z.key):V.toString(36)}function q(z){switch(z.status){case"fulfilled":return z.value;case"rejected":throw z.reason;default:switch(typeof z.status=="string"?z.then(C,C):(z.status="pending",z.then(function(V){z.status==="pending"&&(z.status="fulfilled",z.value=V)},function(V){z.status==="pending"&&(z.status="rejected",z.reason=V)})),z.status){case"fulfilled":return z.value;case"rejected":throw z.reason}}throw z}function W(z,V,X,te,ie){var le=typeof z;(le==="undefined"||le==="boolean")&&(z=null);var ge=!1;if(z===null)ge=!0;else switch(le){case"bigint":case"string":case"number":ge=!0;break;case"object":switch(z.$$typeof){case t:case e:ge=!0;break;case d:return ge=z._init,W(ge(z._payload),V,X,te,ie)}}if(ge)return ie=ie(z),ge=te===""?"."+R(z,0):te,T(ie)?(X="",ge!=null&&(X=ge.replace(K,"$&/")+"/"),W(ie,V,X,"",function(ue){return ue})):ie!=null&&(B(ie)&&(ie=j(ie,X+(ie.key==null||z&&z.key===ie.key?"":(""+ie.key).replace(K,"$&/")+"/")+ge)),V.push(ie)),1;ge=0;var Ee=te===""?".":te+":";if(T(z))for(var pe=0;pe>>1,J=W[ee];if(0>>1;eei(X,Q))tei(ie,X)?(W[ee]=ie,W[te]=Q,ee=te):(W[ee]=X,W[V]=Q,ee=V);else if(tei(ie,Q))W[ee]=ie,W[te]=Q,ee=te;else break e}}return $}function i(W,$){var Q=W.sortIndex-$.sortIndex;return Q!==0?Q:W.id-$.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;t.unstable_now=function(){return a.now()}}else{var s=Date,l=s.now();t.unstable_now=function(){return s.now()-l}}var c=[],u=[],d=1,f=null,g=3,p=!1,x=!1,v=!1,y=!1,b=typeof setTimeout=="function"?setTimeout:null,E=typeof clearTimeout=="function"?clearTimeout:null,w=typeof setImmediate<"u"?setImmediate:null;function A(W){for(var $=n(u);$!==null;){if($.callback===null)r(u);else if($.startTime<=W)r(u),$.sortIndex=$.expirationTime,e(c,$);else break;$=n(u)}}function T(W){if(v=!1,A(W),!x)if(n(c)!==null)x=!0,C||(C=!0,L());else{var $=n(u);$!==null&&q(T,$.startTime-W)}}var C=!1,N=-1,I=5,k=-1;function j(){return y?!0:!(t.unstable_now()-kW&&j());){var ee=f.callback;if(typeof ee=="function"){f.callback=null,g=f.priorityLevel;var J=ee(f.expirationTime<=W);if(W=t.unstable_now(),typeof J=="function"){f.callback=J,A(W),$=!0;break t}f===n(c)&&r(c),A(W)}else r(c);f=n(c)}if(f!==null)$=!0;else{var z=n(u);z!==null&&q(T,z.startTime-W),$=!1}}break e}finally{f=null,g=Q,p=!1}$=void 0}}finally{$?L():C=!1}}}var L;if(typeof w=="function")L=function(){w(B)};else if(typeof MessageChannel<"u"){var K=new MessageChannel,R=K.port2;K.port1.onmessage=B,L=function(){R.postMessage(null)}}else L=function(){b(B,0)};function q(W,$){N=b(function(){W(t.unstable_now())},$)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(W){W.callback=null},t.unstable_forceFrameRate=function(W){0>W||125ee?(W.sortIndex=Q,e(u,W),n(c)===null&&W===n(u)&&(v?(E(N),N=-1):v=!0,q(T,Q-ee))):(W.sortIndex=J,e(c,W),x||p||(x=!0,C||(C=!0,L()))),W},t.unstable_shouldYield=j,t.unstable_wrapCallback=function(W){var $=g;return function(){var Q=g;g=$;try{return W.apply(this,arguments)}finally{g=Q}}}})(FA)),FA}var CF;function aie(){return CF||(CF=1,IA.exports=iie()),IA.exports}var UA={exports:{}},ua={};var kF;function sie(){if(kF)return ua;kF=1;var t=eg();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),UA.exports=sie(),UA.exports}var jF;function oie(){if(jF)return px;jF=1;var t=aie(),e=eg(),n=QG();function r(h){var m="https://react.dev/errors/"+h;if(1J||(h.current=ee[J],ee[J]=null,J--)}function X(h,m){J++,ee[J]=h.current,h.current=m}var te=z(null),ie=z(null),le=z(null),ge=z(null);function Ee(h,m){switch(X(le,m),X(ie,h),X(te,null),m.nodeType){case 9:case 11:h=(h=m.documentElement)&&(h=h.namespaceURI)?q5(h):0;break;default:if(h=m.tagName,m=m.namespaceURI)m=q5(m),h=K5(m,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}V(te),X(te,h)}function pe(){V(te),V(ie),V(le)}function ue(h){h.memoizedState!==null&&X(ge,h);var m=te.current,_=K5(m,h.type);m!==_&&(X(ie,h),X(te,_))}function Re(h){ie.current===h&&(V(te),V(ie)),ge.current===h&&(V(ge),ux._currentValue=Q)}var he,Fe;function U(h){if(he===void 0)try{throw Error()}catch(_){var m=_.stack.trim().match(/\n( *(at )?)/);he=m&&m[1]||"",Fe=-1<_.stack.indexOf(` at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` -`+fe+h+Ie}var H=!1;function re(h,m){if(!h||H)return"";H=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var S={DetermineComponentFrameRoot:function(){try{if(m){var ft=function(){throw Error()};if(Object.defineProperty(ft.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(ft,[])}catch(st){var tt=st}Reflect.construct(h,[],ft)}else{try{ft.call()}catch(st){tt=st}h.call(ft.prototype)}}else{try{throw Error()}catch(st){tt=st}(ft=h())&&typeof ft.catch=="function"&&ft.catch(function(){})}}catch(st){if(st&&tt&&typeof st.stack=="string")return[st.stack,tt.stack]}return[null,null]}};S.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var P=Object.getOwnPropertyDescriptor(S.DetermineComponentFrameRoot,"name");P&&P.configurable&&Object.defineProperty(S.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var D=S.DetermineComponentFrameRoot(),X=D[0],le=D[1];if(X&&le){var Ne=X.split(` -`),Je=le.split(` -`);for(P=S=0;SP||Ne[S]!==Je[P]){var ut=` -`+Ne[S].replace(" at new "," at ");return h.displayName&&ut.includes("")&&(ut=ut.replace("",h.displayName)),ut}while(1<=S&&0<=P);break}}}finally{H=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?z(_):""}function $(h,m){switch(h.tag){case 26:case 27:case 5:return z(h.type);case 16:return z("Lazy");case 13:return h.child!==m&&m!==null?z("Suspense Fallback"):z("Suspense");case 19:return z("SuspenseList");case 0:case 15:return re(h.type,!1);case 11:return re(h.type.render,!1);case 1:return re(h.type,!0);case 31:return z("Activity");default:return""}}function Y(h){try{var m="",_=null;do m+=$(h,_),_=h,h=h.return;while(h);return m}catch(S){return` +`+he+h+Fe}var H=!1;function re(h,m){if(!h||H)return"";H=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var S={DetermineComponentFrameRoot:function(){try{if(m){var mt=function(){throw Error()};if(Object.defineProperty(mt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(mt,[])}catch(lt){var rt=lt}Reflect.construct(h,[],mt)}else{try{mt.call()}catch(lt){rt=lt}h.call(mt.prototype)}}else{try{throw Error()}catch(lt){rt=lt}(mt=h())&&typeof mt.catch=="function"&&mt.catch(function(){})}}catch(lt){if(lt&&rt&&typeof lt.stack=="string")return[lt.stack,rt.stack]}return[null,null]}};S.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var P=Object.getOwnPropertyDescriptor(S.DetermineComponentFrameRoot,"name");P&&P.configurable&&Object.defineProperty(S.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var D=S.DetermineComponentFrameRoot(),Y=D[0],ce=D[1];if(Y&&ce){var Ce=Y.split(` +`),et=ce.split(` +`);for(P=S=0;SP||Ce[S]!==et[P]){var dt=` +`+Ce[S].replace(" at new "," at ");return h.displayName&&dt.includes("")&&(dt=dt.replace("",h.displayName)),dt}while(1<=S&&0<=P);break}}}finally{H=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?U(_):""}function G(h,m){switch(h.tag){case 26:case 27:case 5:return U(h.type);case 16:return U("Lazy");case 13:return h.child!==m&&m!==null?U("Suspense Fallback"):U("Suspense");case 19:return U("SuspenseList");case 0:case 15:return re(h.type,!1);case 11:return re(h.type.render,!1);case 1:return re(h.type,!0);case 31:return U("Activity");default:return""}}function Z(h){try{var m="",_=null;do m+=G(h,_),_=h,h=h.return;while(h);return m}catch(S){return` Error generating stack: `+S.message+` -`+S.stack}}var se=Object.prototype.hasOwnProperty,ue=t.unstable_scheduleCallback,Ee=t.unstable_cancelCallback,Me=t.unstable_shouldYield,U=t.unstable_requestPaint,O=t.unstable_now,ne=t.unstable_getCurrentPriorityLevel,be=t.unstable_ImmediatePriority,ye=t.unstable_UserBlockingPriority,_e=t.unstable_NormalPriority,We=t.unstable_LowPriority,Ve=t.unstable_IdlePriority,Ye=t.log,lt=t.unstable_setDisableYieldValue,de=null,ae=null;function ze(h){if(typeof Ye=="function"&<(h),ae&&typeof ae.setStrictMode=="function")try{ae.setStrictMode(de,h)}catch{}}var Ue=Math.clz32?Math.clz32:pe,et=Math.log,dt=Math.LN2;function pe(h){return h>>>=0,h===0?32:31-(et(h)/dt|0)|0}var nt=256,ve=262144,He=4194304;function je(h){var m=h&42;if(m!==0)return m;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function we(h,m,_){var S=h.pendingLanes;if(S===0)return 0;var P=0,D=h.suspendedLanes,X=h.pingedLanes;h=h.warmLanes;var le=S&134217727;return le!==0?(S=le&~D,S!==0?P=je(S):(X&=le,X!==0?P=je(X):_||(_=le&~h,_!==0&&(P=je(_))))):(le=S&~D,le!==0?P=je(le):X!==0?P=je(X):_||(_=S&~h,_!==0&&(P=je(_)))),P===0?0:m!==0&&m!==P&&(m&D)===0&&(D=P&-P,_=m&-m,D>=_||D===32&&(_&4194048)!==0)?m:P}function Xe(h,m){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&m)===0}function ot(h,m){switch(h){case 1:case 2:case 4:case 8:case 64:return m+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return m+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function St(){var h=He;return He<<=1,(He&62914560)===0&&(He=4194304),h}function Dt(h){for(var m=[],_=0;31>_;_++)m.push(h);return m}function qt(h,m){h.pendingLanes|=m,m!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function Ft(h,m,_,S,P,D){var X=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var le=h.entanglements,Ne=h.expirationTimes,Je=h.hiddenUpdates;for(_=X&~_;0<_;){var ut=31-Ue(_),ft=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Wr=/[\n"\\]/g;function Fn(h){return h.replace(Wr,function(m){return"\\"+m.charCodeAt(0).toString(16)+" "})}function vo(h,m,_,S,P,D,X,le){h.name="",X!=null&&typeof X!="function"&&typeof X!="symbol"&&typeof X!="boolean"?h.type=X:h.removeAttribute("type"),m!=null?X==="number"?(m===0&&h.value===""||h.value!=m)&&(h.value=""+pn(m)):h.value!==""+pn(m)&&(h.value=""+pn(m)):X!=="submit"&&X!=="reset"||h.removeAttribute("value"),m!=null?ki(h,X,pn(m)):_!=null?ki(h,X,pn(_)):S!=null&&h.removeAttribute("value"),P==null&&D!=null&&(h.defaultChecked=!!D),P!=null&&(h.checked=P&&typeof P!="function"&&typeof P!="symbol"),le!=null&&typeof le!="function"&&typeof le!="symbol"&&typeof le!="boolean"?h.name=""+pn(le):h.removeAttribute("name")}function zn(h,m,_,S,P,D,X,le){if(D!=null&&typeof D!="function"&&typeof D!="symbol"&&typeof D!="boolean"&&(h.type=D),m!=null||_!=null){if(!(D!=="submit"&&D!=="reset"||m!=null)){en(h);return}_=_!=null?""+pn(_):"",m=m!=null?""+pn(m):_,le||m===h.value||(h.value=m),h.defaultValue=m}S=S??P,S=typeof S!="function"&&typeof S!="symbol"&&!!S,h.checked=le?h.checked:!!S,h.defaultChecked=!!S,X!=null&&typeof X!="function"&&typeof X!="symbol"&&typeof X!="boolean"&&(h.name=X),en(h)}function ki(h,m,_){m==="number"&&Ir(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function mi(h,m,_,S){if(h=h.options,m){m={};for(var P=0;P<_.length;P++)m["$"+_[P]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),jE=!1;if(Il)try{var Ag={};Object.defineProperty(Ag,"passive",{get:function(){jE=!0}}),window.addEventListener("test",Ag,Ag),window.removeEventListener("test",Ag,Ag)}catch{jE=!1}var tu=null,RE=null,my=null;function qL(){if(my)return my;var h,m=RE,_=m.length,S,P="value"in tu?tu.value:tu.textContent,D=P.length;for(h=0;h<_&&m[h]===P[h];h++);var X=_-h;for(S=1;S<=X&&m[_-S]===P[D-S];S++);return my=P.slice(h,1=kg),JL=" ",e4=!1;function t4(h,m){switch(h){case"keyup":return yne.indexOf(m.keyCode)!==-1;case"keydown":return m.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function n4(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Nf=!1;function _ne(h,m){switch(h){case"compositionend":return n4(m);case"keypress":return m.which!==32?null:(e4=!0,JL);case"textInput":return h=m.data,h===JL&&e4?null:h;default:return null}}function wne(h,m){if(Nf)return h==="compositionend"||!FE&&t4(h,m)?(h=qL(),my=RE=tu=null,Nf=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(m.ctrlKey||m.altKey||m.metaKey)||m.ctrlKey&&m.altKey){if(m.char&&1=m)return{node:_,offset:m-h};h=S}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=u4(_)}}function h4(h,m){return h&&m?h===m?!0:h&&h.nodeType===3?!1:m&&m.nodeType===3?h4(h,m.parentNode):"contains"in h?h.contains(m):h.compareDocumentPosition?!!(h.compareDocumentPosition(m)&16):!1:!1}function f4(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var m=Ir(h.document);m instanceof h.HTMLIFrameElement;){try{var _=typeof m.contentWindow.location.href=="string"}catch{_=!1}if(_)h=m.contentWindow;else break;m=Ir(h.document)}return m}function BE(h){var m=h&&h.nodeName&&h.nodeName.toLowerCase();return m&&(m==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||m==="textarea"||h.contentEditable==="true")}var kne=Il&&"documentMode"in document&&11>=document.documentMode,Cf=null,HE=null,Dg=null,$E=!1;function m4(h,m,_){var S=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;$E||Cf==null||Cf!==Ir(S)||(S=Cf,"selectionStart"in S&&BE(S)?S={start:S.selectionStart,end:S.selectionEnd}:(S=(S.ownerDocument&&S.ownerDocument.defaultView||window).getSelection(),S={anchorNode:S.anchorNode,anchorOffset:S.anchorOffset,focusNode:S.focusNode,focusOffset:S.focusOffset}),Dg&&Rg(Dg,S)||(Dg=S,S=ov(HE,"onSelect"),0>=X,P-=X,Xo=1<<32-Ue(m)+P|_<sn?(Sn=It,It=null):Sn=It.sibling;var jn=tt($e,It,Qe[sn],ht);if(jn===null){It===null&&(It=Sn);break}h&&It&&jn.alternate===null&&m($e,It),Re=D(jn,Re,sn),Pn===null?Bt=jn:Pn.sibling=jn,Pn=jn,It=Sn}if(sn===Qe.length)return _($e,It),Tn&&Ul($e,sn),Bt;if(It===null){for(;snsn?(Sn=It,It=null):Sn=It.sibling;var Su=tt($e,It,jn.value,ht);if(Su===null){It===null&&(It=Sn);break}h&&It&&Su.alternate===null&&m($e,It),Re=D(Su,Re,sn),Pn===null?Bt=Su:Pn.sibling=Su,Pn=Su,It=Sn}if(jn.done)return _($e,It),Tn&&Ul($e,sn),Bt;if(It===null){for(;!jn.done;sn++,jn=Qe.next())jn=ft($e,jn.value,ht),jn!==null&&(Re=D(jn,Re,sn),Pn===null?Bt=jn:Pn.sibling=jn,Pn=jn);return Tn&&Ul($e,sn),Bt}for(It=S(It);!jn.done;sn++,jn=Qe.next())jn=st(It,$e,sn,jn.value,ht),jn!==null&&(h&&jn.alternate!==null&&It.delete(jn.key===null?sn:jn.key),Re=D(jn,Re,sn),Pn===null?Bt=jn:Pn.sibling=jn,Pn=jn);return h&&It.forEach(function(Yre){return m($e,Yre)}),Tn&&Ul($e,sn),Bt}function Yn($e,Re,Qe,ht){if(typeof Qe=="object"&&Qe!==null&&Qe.type===v&&Qe.key===null&&(Qe=Qe.props.children),typeof Qe=="object"&&Qe!==null){switch(Qe.$$typeof){case p:e:{for(var Bt=Qe.key;Re!==null;){if(Re.key===Bt){if(Bt=Qe.type,Bt===v){if(Re.tag===7){_($e,Re.sibling),ht=P(Re,Qe.props.children),ht.return=$e,$e=ht;break e}}else if(Re.elementType===Bt||typeof Bt=="object"&&Bt!==null&&Bt.$$typeof===F&&Gd(Bt)===Re.type){_($e,Re.sibling),ht=P(Re,Qe.props),zg(ht,Qe),ht.return=$e,$e=ht;break e}_($e,Re);break}else m($e,Re);Re=Re.sibling}Qe.type===v?(ht=zd(Qe.props.children,$e.mode,ht,Qe.key),ht.return=$e,$e=ht):(ht=My(Qe.type,Qe.key,Qe.props,null,$e.mode,ht),zg(ht,Qe),ht.return=$e,$e=ht)}return X($e);case x:e:{for(Bt=Qe.key;Re!==null;){if(Re.key===Bt)if(Re.tag===4&&Re.stateNode.containerInfo===Qe.containerInfo&&Re.stateNode.implementation===Qe.implementation){_($e,Re.sibling),ht=P(Re,Qe.children||[]),ht.return=$e,$e=ht;break e}else{_($e,Re);break}else m($e,Re);Re=Re.sibling}ht=YE(Qe,$e.mode,ht),ht.return=$e,$e=ht}return X($e);case F:return Qe=Gd(Qe),Yn($e,Re,Qe,ht)}if(V(Qe))return Ot($e,Re,Qe,ht);if(L(Qe)){if(Bt=L(Qe),typeof Bt!="function")throw Error(r(150));return Qe=Bt.call(Qe),Wt($e,Re,Qe,ht)}if(typeof Qe.then=="function")return Yn($e,Re,Py(Qe),ht);if(Qe.$$typeof===w)return Yn($e,Re,Ay($e,Qe),ht);jy($e,Qe)}return typeof Qe=="string"&&Qe!==""||typeof Qe=="number"||typeof Qe=="bigint"?(Qe=""+Qe,Re!==null&&Re.tag===6?(_($e,Re.sibling),ht=P(Re,Qe),ht.return=$e,$e=ht):(_($e,Re),ht=XE(Qe,$e.mode,ht),ht.return=$e,$e=ht),X($e)):_($e,Re)}return function($e,Re,Qe,ht){try{Ug=0;var Bt=Yn($e,Re,Qe,ht);return zf=null,Bt}catch(It){if(It===Uf||It===Cy)throw It;var Pn=ds(29,It,null,$e.mode);return Pn.lanes=ht,Pn.return=$e,Pn}finally{}}}var qd=I4(!0),F4=I4(!1),su=!1;function l2(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function c2(h,m){h=h.updateQueue,m.updateQueue===h&&(m.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function ou(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function lu(h,m,_){var S=h.updateQueue;if(S===null)return null;if(S=S.shared,(Ln&2)!==0){var P=S.pending;return P===null?m.next=m:(m.next=P.next,P.next=m),S.pending=m,m=Sy(h),_4(h,null,_),m}return wy(h,S,m,_),Sy(h)}function Bg(h,m,_){if(m=m.updateQueue,m!==null&&(m=m.shared,(_&4194048)!==0)){var S=m.lanes;S&=h.pendingLanes,_|=S,m.lanes=_,Nn(h,_)}}function u2(h,m){var _=h.updateQueue,S=h.alternate;if(S!==null&&(S=S.updateQueue,_===S)){var P=null,D=null;if(_=_.firstBaseUpdate,_!==null){do{var X={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};D===null?P=D=X:D=D.next=X,_=_.next}while(_!==null);D===null?P=D=m:D=D.next=m}else P=D=m;_={baseState:S.baseState,firstBaseUpdate:P,lastBaseUpdate:D,shared:S.shared,callbacks:S.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=m:h.next=m,_.lastBaseUpdate=m}var d2=!1;function Hg(){if(d2){var h=Ff;if(h!==null)throw h}}function $g(h,m,_,S){d2=!1;var P=h.updateQueue;su=!1;var D=P.firstBaseUpdate,X=P.lastBaseUpdate,le=P.shared.pending;if(le!==null){P.shared.pending=null;var Ne=le,Je=Ne.next;Ne.next=null,X===null?D=Je:X.next=Je,X=Ne;var ut=h.alternate;ut!==null&&(ut=ut.updateQueue,le=ut.lastBaseUpdate,le!==X&&(le===null?ut.firstBaseUpdate=Je:le.next=Je,ut.lastBaseUpdate=Ne))}if(D!==null){var ft=P.baseState;X=0,ut=Je=Ne=null,le=D;do{var tt=le.lane&-536870913,st=tt!==le.lane;if(st?(wn&tt)===tt:(S&tt)===tt){tt!==0&&tt===If&&(d2=!0),ut!==null&&(ut=ut.next={lane:0,tag:le.tag,payload:le.payload,callback:null,next:null});e:{var Ot=h,Wt=le;tt=m;var Yn=_;switch(Wt.tag){case 1:if(Ot=Wt.payload,typeof Ot=="function"){ft=Ot.call(Yn,ft,tt);break e}ft=Ot;break e;case 3:Ot.flags=Ot.flags&-65537|128;case 0:if(Ot=Wt.payload,tt=typeof Ot=="function"?Ot.call(Yn,ft,tt):Ot,tt==null)break e;ft=f({},ft,tt);break e;case 2:su=!0}}tt=le.callback,tt!==null&&(h.flags|=64,st&&(h.flags|=8192),st=P.callbacks,st===null?P.callbacks=[tt]:st.push(tt))}else st={lane:tt,tag:le.tag,payload:le.payload,callback:le.callback,next:null},ut===null?(Je=ut=st,Ne=ft):ut=ut.next=st,X|=tt;if(le=le.next,le===null){if(le=P.shared.pending,le===null)break;st=le,le=st.next,st.next=null,P.lastBaseUpdate=st,P.shared.pending=null}}while(!0);ut===null&&(Ne=ft),P.baseState=Ne,P.firstBaseUpdate=Je,P.lastBaseUpdate=ut,D===null&&(P.shared.lanes=0),fu|=X,h.lanes=X,h.memoizedState=ft}}function U4(h,m){if(typeof h!="function")throw Error(r(191,h));h.call(m)}function z4(h,m){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)U4(_[h],m)}var Bf=B(null),Ry=B(0);function B4(h,m){h=Yl,Q(Ry,h),Q(Bf,m),Yl=h|m.baseLanes}function h2(){Q(Ry,Yl),Q(Bf,Bf.current)}function f2(){Yl=Ry.current,G(Bf),G(Ry)}var hs=B(null),Us=null;function cu(h){var m=h.alternate;Q(qr,qr.current&1),Q(hs,h),Us===null&&(m===null||Bf.current!==null||m.memoizedState!==null)&&(Us=h)}function m2(h){Q(qr,qr.current),Q(hs,h),Us===null&&(Us=h)}function H4(h){h.tag===22?(Q(qr,qr.current),Q(hs,h),Us===null&&(Us=h)):uu()}function uu(){Q(qr,qr.current),Q(hs,hs.current)}function fs(h){G(hs),Us===h&&(Us=null),G(qr)}var qr=B(0);function Dy(h){for(var m=h;m!==null;){if(m.tag===13){var _=m.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||vT(_)||_T(_)))return m}else if(m.tag===19&&(m.memoizedProps.revealOrder==="forwards"||m.memoizedProps.revealOrder==="backwards"||m.memoizedProps.revealOrder==="unstable_legacy-backwards"||m.memoizedProps.revealOrder==="together")){if((m.flags&128)!==0)return m}else if(m.child!==null){m.child.return=m,m=m.child;continue}if(m===h)break;for(;m.sibling===null;){if(m.return===null||m.return===h)return null;m=m.return}m.sibling.return=m.return,m=m.sibling}return null}var Hl=0,an=null,Kn=null,ni=null,Oy=!1,Hf=!1,Kd=!1,Ly=0,Vg=0,$f=null,zne=0;function Fr(){throw Error(r(321))}function p2(h,m){if(m===null)return!1;for(var _=0;_D?D:8;var X=K.T,le={};K.T=le,k2(h,!1,m,_);try{var Ne=P(),Je=K.S;if(Je!==null&&Je(le,Ne),Ne!==null&&typeof Ne=="object"&&typeof Ne.then=="function"){var ut=Une(Ne,S);Wg(h,m,ut,gs(h))}else Wg(h,m,S,gs(h))}catch(ft){Wg(h,m,{then:function(){},status:"rejected",reason:ft},gs())}finally{W.p=D,X!==null&&le.types!==null&&(X.types=le.types),K.T=X}}function Gne(){}function N2(h,m,_,S){if(h.tag!==5)throw Error(r(476));var P=bI(h).queue;xI(h,P,m,J,_===null?Gne:function(){return yI(h),_(S)})}function bI(h){var m=h.memoizedState;if(m!==null)return m;m={memoizedState:J,baseState:J,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:$l,lastRenderedState:J},next:null};var _={};return m.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:$l,lastRenderedState:_},next:null},h.memoizedState=m,h=h.alternate,h!==null&&(h.memoizedState=m),m}function yI(h){var m=bI(h);m.next===null&&(m=h.alternate.memoizedState),Wg(h,m.next.queue,{},gs())}function C2(){return Ki(cx)}function vI(){return Kr().memoizedState}function _I(){return Kr().memoizedState}function Wne(h){for(var m=h.return;m!==null;){switch(m.tag){case 24:case 3:var _=gs();h=ou(_);var S=lu(m,h,_);S!==null&&(Wa(S,m,_),Bg(S,m,_)),m={cache:i2()},h.payload=m;return}m=m.return}}function qne(h,m,_){var S=gs();_={lane:S,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},Hy(h)?SI(m,_):(_=qE(h,m,_,S),_!==null&&(Wa(_,h,S),MI(_,m,S)))}function wI(h,m,_){var S=gs();Wg(h,m,_,S)}function Wg(h,m,_,S){var P={lane:S,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(Hy(h))SI(m,P);else{var D=h.alternate;if(h.lanes===0&&(D===null||D.lanes===0)&&(D=m.lastRenderedReducer,D!==null))try{var X=m.lastRenderedState,le=D(X,_);if(P.hasEagerState=!0,P.eagerState=le,us(le,X))return wy(h,m,P,0),ar===null&&_y(),!1}catch{}finally{}if(_=qE(h,m,P,S),_!==null)return Wa(_,h,S),MI(_,m,S),!0}return!1}function k2(h,m,_,S){if(S={lane:2,revertLane:lT(),gesture:null,action:S,hasEagerState:!1,eagerState:null,next:null},Hy(h)){if(m)throw Error(r(479))}else m=qE(h,_,S,2),m!==null&&Wa(m,h,2)}function Hy(h){var m=h.alternate;return h===an||m!==null&&m===an}function SI(h,m){Hf=Oy=!0;var _=h.pending;_===null?m.next=m:(m.next=_.next,_.next=m),h.pending=m}function MI(h,m,_){if((_&4194048)!==0){var S=m.lanes;S&=h.pendingLanes,_|=S,m.lanes=_,Nn(h,_)}}var qg={readContext:Ki,use:Fy,useCallback:Fr,useContext:Fr,useEffect:Fr,useImperativeHandle:Fr,useLayoutEffect:Fr,useInsertionEffect:Fr,useMemo:Fr,useReducer:Fr,useRef:Fr,useState:Fr,useDebugValue:Fr,useDeferredValue:Fr,useTransition:Fr,useSyncExternalStore:Fr,useId:Fr,useHostTransitionStatus:Fr,useFormState:Fr,useActionState:Fr,useOptimistic:Fr,useMemoCache:Fr,useCacheRefresh:Fr};qg.useEffectEvent=Fr;var EI={readContext:Ki,use:Fy,useCallback:function(h,m){return Ea().memoizedState=[h,m===void 0?null:m],h},useContext:Ki,useEffect:lI,useImperativeHandle:function(h,m,_){_=_!=null?_.concat([h]):null,zy(4194308,4,hI.bind(null,m,h),_)},useLayoutEffect:function(h,m){return zy(4194308,4,h,m)},useInsertionEffect:function(h,m){zy(4,2,h,m)},useMemo:function(h,m){var _=Ea();m=m===void 0?null:m;var S=h();if(Kd){ze(!0);try{h()}finally{ze(!1)}}return _.memoizedState=[S,m],S},useReducer:function(h,m,_){var S=Ea();if(_!==void 0){var P=_(m);if(Kd){ze(!0);try{_(m)}finally{ze(!1)}}}else P=m;return S.memoizedState=S.baseState=P,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:P},S.queue=h,h=h.dispatch=qne.bind(null,an,h),[S.memoizedState,h]},useRef:function(h){var m=Ea();return h={current:h},m.memoizedState=h},useState:function(h){h=S2(h);var m=h.queue,_=wI.bind(null,an,m);return m.dispatch=_,[h.memoizedState,_]},useDebugValue:T2,useDeferredValue:function(h,m){var _=Ea();return A2(_,h,m)},useTransition:function(){var h=S2(!1);return h=xI.bind(null,an,h.queue,!0,!1),Ea().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,m,_){var S=an,P=Ea();if(Tn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=m(),ar===null)throw Error(r(349));(wn&127)!==0||W4(S,m,_)}P.memoizedState=_;var D={value:_,getSnapshot:m};return P.queue=D,lI(K4.bind(null,S,D,h),[h]),S.flags|=2048,Vf(9,{destroy:void 0},q4.bind(null,S,D,_,m),null),_},useId:function(){var h=Ea(),m=ar.identifierPrefix;if(Tn){var _=Yo,S=Xo;_=(S&~(1<<32-Ue(S)-1)).toString(32)+_,m="_"+m+"R_"+_,_=Ly++,0<_&&(m+="H"+_.toString(32)),m+="_"}else _=zne++,m="_"+m+"r_"+_.toString(32)+"_";return h.memoizedState=m},useHostTransitionStatus:C2,useFormState:rI,useActionState:rI,useOptimistic:function(h){var m=Ea();m.memoizedState=m.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return m.queue=_,m=k2.bind(null,an,!0,_),_.dispatch=m,[h,m]},useMemoCache:v2,useCacheRefresh:function(){return Ea().memoizedState=Wne.bind(null,an)},useEffectEvent:function(h){var m=Ea(),_={impl:h};return m.memoizedState=_,function(){if((Ln&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},P2={readContext:Ki,use:Fy,useCallback:mI,useContext:Ki,useEffect:E2,useImperativeHandle:fI,useInsertionEffect:uI,useLayoutEffect:dI,useMemo:pI,useReducer:Uy,useRef:oI,useState:function(){return Uy($l)},useDebugValue:T2,useDeferredValue:function(h,m){var _=Kr();return gI(_,Kn.memoizedState,h,m)},useTransition:function(){var h=Uy($l)[0],m=Kr().memoizedState;return[typeof h=="boolean"?h:Gg(h),m]},useSyncExternalStore:G4,useId:vI,useHostTransitionStatus:C2,useFormState:iI,useActionState:iI,useOptimistic:function(h,m){var _=Kr();return Z4(_,Kn,h,m)},useMemoCache:v2,useCacheRefresh:_I};P2.useEffectEvent=cI;var TI={readContext:Ki,use:Fy,useCallback:mI,useContext:Ki,useEffect:E2,useImperativeHandle:fI,useInsertionEffect:uI,useLayoutEffect:dI,useMemo:pI,useReducer:w2,useRef:oI,useState:function(){return w2($l)},useDebugValue:T2,useDeferredValue:function(h,m){var _=Kr();return Kn===null?A2(_,h,m):gI(_,Kn.memoizedState,h,m)},useTransition:function(){var h=w2($l)[0],m=Kr().memoizedState;return[typeof h=="boolean"?h:Gg(h),m]},useSyncExternalStore:G4,useId:vI,useHostTransitionStatus:C2,useFormState:sI,useActionState:sI,useOptimistic:function(h,m){var _=Kr();return Kn!==null?Z4(_,Kn,h,m):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:v2,useCacheRefresh:_I};TI.useEffectEvent=cI;function j2(h,m,_,S){m=h.memoizedState,_=_(S,m),_=_==null?m:f({},m,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var R2={enqueueSetState:function(h,m,_){h=h._reactInternals;var S=gs(),P=ou(S);P.payload=m,_!=null&&(P.callback=_),m=lu(h,P,S),m!==null&&(Wa(m,h,S),Bg(m,h,S))},enqueueReplaceState:function(h,m,_){h=h._reactInternals;var S=gs(),P=ou(S);P.tag=1,P.payload=m,_!=null&&(P.callback=_),m=lu(h,P,S),m!==null&&(Wa(m,h,S),Bg(m,h,S))},enqueueForceUpdate:function(h,m){h=h._reactInternals;var _=gs(),S=ou(_);S.tag=2,m!=null&&(S.callback=m),m=lu(h,S,_),m!==null&&(Wa(m,h,_),Bg(m,h,_))}};function AI(h,m,_,S,P,D,X){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(S,D,X):m.prototype&&m.prototype.isPureReactComponent?!Rg(_,S)||!Rg(P,D):!0}function NI(h,m,_,S){h=m.state,typeof m.componentWillReceiveProps=="function"&&m.componentWillReceiveProps(_,S),typeof m.UNSAFE_componentWillReceiveProps=="function"&&m.UNSAFE_componentWillReceiveProps(_,S),m.state!==h&&R2.enqueueReplaceState(m,m.state,null)}function Xd(h,m){var _=m;if("ref"in m){_={};for(var S in m)S!=="ref"&&(_[S]=m[S])}if(h=h.defaultProps){_===m&&(_=f({},_));for(var P in h)_[P]===void 0&&(_[P]=h[P])}return _}function CI(h){vy(h)}function kI(h){console.error(h)}function PI(h){vy(h)}function $y(h,m){try{var _=h.onUncaughtError;_(m.value,{componentStack:m.stack})}catch(S){setTimeout(function(){throw S})}}function jI(h,m,_){try{var S=h.onCaughtError;S(_.value,{componentStack:_.stack,errorBoundary:m.tag===1?m.stateNode:null})}catch(P){setTimeout(function(){throw P})}}function D2(h,m,_){return _=ou(_),_.tag=3,_.payload={element:null},_.callback=function(){$y(h,m)},_}function RI(h){return h=ou(h),h.tag=3,h}function DI(h,m,_,S){var P=_.type.getDerivedStateFromError;if(typeof P=="function"){var D=S.value;h.payload=function(){return P(D)},h.callback=function(){jI(m,_,S)}}var X=_.stateNode;X!==null&&typeof X.componentDidCatch=="function"&&(h.callback=function(){jI(m,_,S),typeof P!="function"&&(mu===null?mu=new Set([this]):mu.add(this));var le=S.stack;this.componentDidCatch(S.value,{componentStack:le!==null?le:""})})}function Kne(h,m,_,S,P){if(_.flags|=32768,S!==null&&typeof S=="object"&&typeof S.then=="function"){if(m=_.alternate,m!==null&&Lf(m,_,P,!0),_=hs.current,_!==null){switch(_.tag){case 31:case 13:return Us===null?tv():_.alternate===null&&Ur===0&&(Ur=3),_.flags&=-257,_.flags|=65536,_.lanes=P,S===ky?_.flags|=16384:(m=_.updateQueue,m===null?_.updateQueue=new Set([S]):m.add(S),aT(h,S,P)),!1;case 22:return _.flags|=65536,S===ky?_.flags|=16384:(m=_.updateQueue,m===null?(m={transitions:null,markerInstances:null,retryQueue:new Set([S])},_.updateQueue=m):(_=m.retryQueue,_===null?m.retryQueue=new Set([S]):_.add(S)),aT(h,S,P)),!1}throw Error(r(435,_.tag))}return aT(h,S,P),tv(),!1}if(Tn)return m=hs.current,m!==null?((m.flags&65536)===0&&(m.flags|=256),m.flags|=65536,m.lanes=P,S!==JE&&(h=Error(r(422),{cause:S}),Lg(Os(h,_)))):(S!==JE&&(m=Error(r(423),{cause:S}),Lg(Os(m,_))),h=h.current.alternate,h.flags|=65536,P&=-P,h.lanes|=P,S=Os(S,_),P=D2(h.stateNode,S,P),u2(h,P),Ur!==4&&(Ur=2)),!1;var D=Error(r(520),{cause:S});if(D=Os(D,_),tx===null?tx=[D]:tx.push(D),Ur!==4&&(Ur=2),m===null)return!0;S=Os(S,_),_=m;do{switch(_.tag){case 3:return _.flags|=65536,h=P&-P,_.lanes|=h,h=D2(_.stateNode,S,h),u2(_,h),!1;case 1:if(m=_.type,D=_.stateNode,(_.flags&128)===0&&(typeof m.getDerivedStateFromError=="function"||D!==null&&typeof D.componentDidCatch=="function"&&(mu===null||!mu.has(D))))return _.flags|=65536,P&=-P,_.lanes|=P,P=RI(P),DI(P,h,_,S),u2(_,P),!1}_=_.return}while(_!==null);return!1}var O2=Error(r(461)),ri=!1;function Xi(h,m,_,S){m.child=h===null?F4(m,null,_,S):qd(m,h.child,_,S)}function OI(h,m,_,S,P){_=_.render;var D=m.ref;if("ref"in S){var X={};for(var le in S)le!=="ref"&&(X[le]=S[le])}else X=S;return $d(m),S=g2(h,m,_,X,D,P),le=x2(),h!==null&&!ri?(b2(h,m,P),Vl(h,m,P)):(Tn&&le&&ZE(m),m.flags|=1,Xi(h,m,S,P),m.child)}function LI(h,m,_,S,P){if(h===null){var D=_.type;return typeof D=="function"&&!KE(D)&&D.defaultProps===void 0&&_.compare===null?(m.tag=15,m.type=D,II(h,m,D,S,P)):(h=My(_.type,null,S,m,m.mode,P),h.ref=m.ref,h.return=m,m.child=h)}if(D=h.child,!$2(h,P)){var X=D.memoizedProps;if(_=_.compare,_=_!==null?_:Rg,_(X,S)&&h.ref===m.ref)return Vl(h,m,P)}return m.flags|=1,h=Fl(D,S),h.ref=m.ref,h.return=m,m.child=h}function II(h,m,_,S,P){if(h!==null){var D=h.memoizedProps;if(Rg(D,S)&&h.ref===m.ref)if(ri=!1,m.pendingProps=S=D,$2(h,P))(h.flags&131072)!==0&&(ri=!0);else return m.lanes=h.lanes,Vl(h,m,P)}return L2(h,m,_,S,P)}function FI(h,m,_,S){var P=S.children,D=h!==null?h.memoizedState:null;if(h===null&&m.stateNode===null&&(m.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),S.mode==="hidden"){if((m.flags&128)!==0){if(D=D!==null?D.baseLanes|_:_,h!==null){for(S=m.child=h.child,P=0;S!==null;)P=P|S.lanes|S.childLanes,S=S.sibling;S=P&~D}else S=0,m.child=null;return UI(h,m,D,_,S)}if((_&536870912)!==0)m.memoizedState={baseLanes:0,cachePool:null},h!==null&&Ny(m,D!==null?D.cachePool:null),D!==null?B4(m,D):h2(),H4(m);else return S=m.lanes=536870912,UI(h,m,D!==null?D.baseLanes|_:_,_,S)}else D!==null?(Ny(m,D.cachePool),B4(m,D),uu(),m.memoizedState=null):(h!==null&&Ny(m,null),h2(),uu());return Xi(h,m,P,_),m.child}function Kg(h,m){return h!==null&&h.tag===22||m.stateNode!==null||(m.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),m.sibling}function UI(h,m,_,S,P){var D=s2();return D=D===null?null:{parent:ti._currentValue,pool:D},m.memoizedState={baseLanes:_,cachePool:D},h!==null&&Ny(m,null),h2(),H4(m),h!==null&&Lf(h,m,S,!0),m.childLanes=P,null}function Vy(h,m){return m=Wy({mode:m.mode,children:m.children},h.mode),m.ref=h.ref,h.child=m,m.return=h,m}function zI(h,m,_){return qd(m,h.child,null,_),h=Vy(m,m.pendingProps),h.flags|=2,fs(m),m.memoizedState=null,h}function Xne(h,m,_){var S=m.pendingProps,P=(m.flags&128)!==0;if(m.flags&=-129,h===null){if(Tn){if(S.mode==="hidden")return h=Vy(m,S),m.lanes=536870912,Kg(null,h);if(m2(m),(h=hr)?(h=Q5(h,Fs),h=h!==null&&h.data==="&"?h:null,h!==null&&(m.memoizedState={dehydrated:h,treeContext:nu!==null?{id:Xo,overflow:Yo}:null,retryLane:536870912,hydrationErrors:null},_=S4(h),_.return=m,m.child=_,qi=m,hr=null)):h=null,h===null)throw iu(m);return m.lanes=536870912,null}return Vy(m,S)}var D=h.memoizedState;if(D!==null){var X=D.dehydrated;if(m2(m),P)if(m.flags&256)m.flags&=-257,m=zI(h,m,_);else if(m.memoizedState!==null)m.child=h.child,m.flags|=128,m=null;else throw Error(r(558));else if(ri||Lf(h,m,_,!1),P=(_&h.childLanes)!==0,ri||P){if(S=ar,S!==null&&(X=Lr(S,_),X!==0&&X!==D.retryLane))throw D.retryLane=X,Ud(h,X),Wa(S,h,X),O2;tv(),m=zI(h,m,_)}else h=D.treeContext,hr=zs(X.nextSibling),qi=m,Tn=!0,ru=null,Fs=!1,h!==null&&T4(m,h),m=Vy(m,S),m.flags|=4096;return m}return h=Fl(h.child,{mode:S.mode,children:S.children}),h.ref=m.ref,m.child=h,h.return=m,h}function Gy(h,m){var _=m.ref;if(_===null)h!==null&&h.ref!==null&&(m.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(m.flags|=4194816)}}function L2(h,m,_,S,P){return $d(m),_=g2(h,m,_,S,void 0,P),S=x2(),h!==null&&!ri?(b2(h,m,P),Vl(h,m,P)):(Tn&&S&&ZE(m),m.flags|=1,Xi(h,m,_,P),m.child)}function BI(h,m,_,S,P,D){return $d(m),m.updateQueue=null,_=V4(m,S,_,P),$4(h),S=x2(),h!==null&&!ri?(b2(h,m,D),Vl(h,m,D)):(Tn&&S&&ZE(m),m.flags|=1,Xi(h,m,_,D),m.child)}function HI(h,m,_,S,P){if($d(m),m.stateNode===null){var D=jf,X=_.contextType;typeof X=="object"&&X!==null&&(D=Ki(X)),D=new _(S,D),m.memoizedState=D.state!==null&&D.state!==void 0?D.state:null,D.updater=R2,m.stateNode=D,D._reactInternals=m,D=m.stateNode,D.props=S,D.state=m.memoizedState,D.refs={},l2(m),X=_.contextType,D.context=typeof X=="object"&&X!==null?Ki(X):jf,D.state=m.memoizedState,X=_.getDerivedStateFromProps,typeof X=="function"&&(j2(m,_,X,S),D.state=m.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof D.getSnapshotBeforeUpdate=="function"||typeof D.UNSAFE_componentWillMount!="function"&&typeof D.componentWillMount!="function"||(X=D.state,typeof D.componentWillMount=="function"&&D.componentWillMount(),typeof D.UNSAFE_componentWillMount=="function"&&D.UNSAFE_componentWillMount(),X!==D.state&&R2.enqueueReplaceState(D,D.state,null),$g(m,S,D,P),Hg(),D.state=m.memoizedState),typeof D.componentDidMount=="function"&&(m.flags|=4194308),S=!0}else if(h===null){D=m.stateNode;var le=m.memoizedProps,Ne=Xd(_,le);D.props=Ne;var Je=D.context,ut=_.contextType;X=jf,typeof ut=="object"&&ut!==null&&(X=Ki(ut));var ft=_.getDerivedStateFromProps;ut=typeof ft=="function"||typeof D.getSnapshotBeforeUpdate=="function",le=m.pendingProps!==le,ut||typeof D.UNSAFE_componentWillReceiveProps!="function"&&typeof D.componentWillReceiveProps!="function"||(le||Je!==X)&&NI(m,D,S,X),su=!1;var tt=m.memoizedState;D.state=tt,$g(m,S,D,P),Hg(),Je=m.memoizedState,le||tt!==Je||su?(typeof ft=="function"&&(j2(m,_,ft,S),Je=m.memoizedState),(Ne=su||AI(m,_,Ne,S,tt,Je,X))?(ut||typeof D.UNSAFE_componentWillMount!="function"&&typeof D.componentWillMount!="function"||(typeof D.componentWillMount=="function"&&D.componentWillMount(),typeof D.UNSAFE_componentWillMount=="function"&&D.UNSAFE_componentWillMount()),typeof D.componentDidMount=="function"&&(m.flags|=4194308)):(typeof D.componentDidMount=="function"&&(m.flags|=4194308),m.memoizedProps=S,m.memoizedState=Je),D.props=S,D.state=Je,D.context=X,S=Ne):(typeof D.componentDidMount=="function"&&(m.flags|=4194308),S=!1)}else{D=m.stateNode,c2(h,m),X=m.memoizedProps,ut=Xd(_,X),D.props=ut,ft=m.pendingProps,tt=D.context,Je=_.contextType,Ne=jf,typeof Je=="object"&&Je!==null&&(Ne=Ki(Je)),le=_.getDerivedStateFromProps,(Je=typeof le=="function"||typeof D.getSnapshotBeforeUpdate=="function")||typeof D.UNSAFE_componentWillReceiveProps!="function"&&typeof D.componentWillReceiveProps!="function"||(X!==ft||tt!==Ne)&&NI(m,D,S,Ne),su=!1,tt=m.memoizedState,D.state=tt,$g(m,S,D,P),Hg();var st=m.memoizedState;X!==ft||tt!==st||su||h!==null&&h.dependencies!==null&&Ty(h.dependencies)?(typeof le=="function"&&(j2(m,_,le,S),st=m.memoizedState),(ut=su||AI(m,_,ut,S,tt,st,Ne)||h!==null&&h.dependencies!==null&&Ty(h.dependencies))?(Je||typeof D.UNSAFE_componentWillUpdate!="function"&&typeof D.componentWillUpdate!="function"||(typeof D.componentWillUpdate=="function"&&D.componentWillUpdate(S,st,Ne),typeof D.UNSAFE_componentWillUpdate=="function"&&D.UNSAFE_componentWillUpdate(S,st,Ne)),typeof D.componentDidUpdate=="function"&&(m.flags|=4),typeof D.getSnapshotBeforeUpdate=="function"&&(m.flags|=1024)):(typeof D.componentDidUpdate!="function"||X===h.memoizedProps&&tt===h.memoizedState||(m.flags|=4),typeof D.getSnapshotBeforeUpdate!="function"||X===h.memoizedProps&&tt===h.memoizedState||(m.flags|=1024),m.memoizedProps=S,m.memoizedState=st),D.props=S,D.state=st,D.context=Ne,S=ut):(typeof D.componentDidUpdate!="function"||X===h.memoizedProps&&tt===h.memoizedState||(m.flags|=4),typeof D.getSnapshotBeforeUpdate!="function"||X===h.memoizedProps&&tt===h.memoizedState||(m.flags|=1024),S=!1)}return D=S,Gy(h,m),S=(m.flags&128)!==0,D||S?(D=m.stateNode,_=S&&typeof _.getDerivedStateFromError!="function"?null:D.render(),m.flags|=1,h!==null&&S?(m.child=qd(m,h.child,null,P),m.child=qd(m,null,_,P)):Xi(h,m,_,P),m.memoizedState=D.state,h=m.child):h=Vl(h,m,P),h}function $I(h,m,_,S){return Bd(),m.flags|=256,Xi(h,m,_,S),m.child}var I2={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function F2(h){return{baseLanes:h,cachePool:j4()}}function U2(h,m,_){return h=h!==null?h.childLanes&~_:0,m&&(h|=ps),h}function VI(h,m,_){var S=m.pendingProps,P=!1,D=(m.flags&128)!==0,X;if((X=D)||(X=h!==null&&h.memoizedState===null?!1:(qr.current&2)!==0),X&&(P=!0,m.flags&=-129),X=(m.flags&32)!==0,m.flags&=-33,h===null){if(Tn){if(P?cu(m):uu(),(h=hr)?(h=Q5(h,Fs),h=h!==null&&h.data!=="&"?h:null,h!==null&&(m.memoizedState={dehydrated:h,treeContext:nu!==null?{id:Xo,overflow:Yo}:null,retryLane:536870912,hydrationErrors:null},_=S4(h),_.return=m,m.child=_,qi=m,hr=null)):h=null,h===null)throw iu(m);return _T(h)?m.lanes=32:m.lanes=536870912,null}var le=S.children;return S=S.fallback,P?(uu(),P=m.mode,le=Wy({mode:"hidden",children:le},P),S=zd(S,P,_,null),le.return=m,S.return=m,le.sibling=S,m.child=le,S=m.child,S.memoizedState=F2(_),S.childLanes=U2(h,X,_),m.memoizedState=I2,Kg(null,S)):(cu(m),z2(m,le))}var Ne=h.memoizedState;if(Ne!==null&&(le=Ne.dehydrated,le!==null)){if(D)m.flags&256?(cu(m),m.flags&=-257,m=B2(h,m,_)):m.memoizedState!==null?(uu(),m.child=h.child,m.flags|=128,m=null):(uu(),le=S.fallback,P=m.mode,S=Wy({mode:"visible",children:S.children},P),le=zd(le,P,_,null),le.flags|=2,S.return=m,le.return=m,S.sibling=le,m.child=S,qd(m,h.child,null,_),S=m.child,S.memoizedState=F2(_),S.childLanes=U2(h,X,_),m.memoizedState=I2,m=Kg(null,S));else if(cu(m),_T(le)){if(X=le.nextSibling&&le.nextSibling.dataset,X)var Je=X.dgst;X=Je,S=Error(r(419)),S.stack="",S.digest=X,Lg({value:S,source:null,stack:null}),m=B2(h,m,_)}else if(ri||Lf(h,m,_,!1),X=(_&h.childLanes)!==0,ri||X){if(X=ar,X!==null&&(S=Lr(X,_),S!==0&&S!==Ne.retryLane))throw Ne.retryLane=S,Ud(h,S),Wa(X,h,S),O2;vT(le)||tv(),m=B2(h,m,_)}else vT(le)?(m.flags|=192,m.child=h.child,m=null):(h=Ne.treeContext,hr=zs(le.nextSibling),qi=m,Tn=!0,ru=null,Fs=!1,h!==null&&T4(m,h),m=z2(m,S.children),m.flags|=4096);return m}return P?(uu(),le=S.fallback,P=m.mode,Ne=h.child,Je=Ne.sibling,S=Fl(Ne,{mode:"hidden",children:S.children}),S.subtreeFlags=Ne.subtreeFlags&65011712,Je!==null?le=Fl(Je,le):(le=zd(le,P,_,null),le.flags|=2),le.return=m,S.return=m,S.sibling=le,m.child=S,Kg(null,S),S=m.child,le=h.child.memoizedState,le===null?le=F2(_):(P=le.cachePool,P!==null?(Ne=ti._currentValue,P=P.parent!==Ne?{parent:Ne,pool:Ne}:P):P=j4(),le={baseLanes:le.baseLanes|_,cachePool:P}),S.memoizedState=le,S.childLanes=U2(h,X,_),m.memoizedState=I2,Kg(h.child,S)):(cu(m),_=h.child,h=_.sibling,_=Fl(_,{mode:"visible",children:S.children}),_.return=m,_.sibling=null,h!==null&&(X=m.deletions,X===null?(m.deletions=[h],m.flags|=16):X.push(h)),m.child=_,m.memoizedState=null,_)}function z2(h,m){return m=Wy({mode:"visible",children:m},h.mode),m.return=h,h.child=m}function Wy(h,m){return h=ds(22,h,null,m),h.lanes=0,h}function B2(h,m,_){return qd(m,h.child,null,_),h=z2(m,m.pendingProps.children),h.flags|=2,m.memoizedState=null,h}function GI(h,m,_){h.lanes|=m;var S=h.alternate;S!==null&&(S.lanes|=m),n2(h.return,m,_)}function H2(h,m,_,S,P,D){var X=h.memoizedState;X===null?h.memoizedState={isBackwards:m,rendering:null,renderingStartTime:0,last:S,tail:_,tailMode:P,treeForkCount:D}:(X.isBackwards=m,X.rendering=null,X.renderingStartTime=0,X.last=S,X.tail=_,X.tailMode=P,X.treeForkCount=D)}function WI(h,m,_){var S=m.pendingProps,P=S.revealOrder,D=S.tail;S=S.children;var X=qr.current,le=(X&2)!==0;if(le?(X=X&1|2,m.flags|=128):X&=1,Q(qr,X),Xi(h,m,S,_),S=Tn?Og:0,!le&&h!==null&&(h.flags&128)!==0)e:for(h=m.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&GI(h,_,m);else if(h.tag===19)GI(h,_,m);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===m)break e;for(;h.sibling===null;){if(h.return===null||h.return===m)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(P){case"forwards":for(_=m.child,P=null;_!==null;)h=_.alternate,h!==null&&Dy(h)===null&&(P=_),_=_.sibling;_=P,_===null?(P=m.child,m.child=null):(P=_.sibling,_.sibling=null),H2(m,!1,P,_,D,S);break;case"backwards":case"unstable_legacy-backwards":for(_=null,P=m.child,m.child=null;P!==null;){if(h=P.alternate,h!==null&&Dy(h)===null){m.child=P;break}h=P.sibling,P.sibling=_,_=P,P=h}H2(m,!0,_,null,D,S);break;case"together":H2(m,!1,null,null,void 0,S);break;default:m.memoizedState=null}return m.child}function Vl(h,m,_){if(h!==null&&(m.dependencies=h.dependencies),fu|=m.lanes,(_&m.childLanes)===0)if(h!==null){if(Lf(h,m,_,!1),(_&m.childLanes)===0)return null}else return null;if(h!==null&&m.child!==h.child)throw Error(r(153));if(m.child!==null){for(h=m.child,_=Fl(h,h.pendingProps),m.child=_,_.return=m;h.sibling!==null;)h=h.sibling,_=_.sibling=Fl(h,h.pendingProps),_.return=m;_.sibling=null}return m.child}function $2(h,m){return(h.lanes&m)!==0?!0:(h=h.dependencies,!!(h!==null&&Ty(h)))}function Yne(h,m,_){switch(m.tag){case 3:Te(m,m.stateNode.containerInfo),au(m,ti,h.memoizedState.cache),Bd();break;case 27:case 5:ce(m);break;case 4:Te(m,m.stateNode.containerInfo);break;case 10:au(m,m.type,m.memoizedProps.value);break;case 31:if(m.memoizedState!==null)return m.flags|=128,m2(m),null;break;case 13:var S=m.memoizedState;if(S!==null)return S.dehydrated!==null?(cu(m),m.flags|=128,null):(_&m.child.childLanes)!==0?VI(h,m,_):(cu(m),h=Vl(h,m,_),h!==null?h.sibling:null);cu(m);break;case 19:var P=(h.flags&128)!==0;if(S=(_&m.childLanes)!==0,S||(Lf(h,m,_,!1),S=(_&m.childLanes)!==0),P){if(S)return WI(h,m,_);m.flags|=128}if(P=m.memoizedState,P!==null&&(P.rendering=null,P.tail=null,P.lastEffect=null),Q(qr,qr.current),S)break;return null;case 22:return m.lanes=0,FI(h,m,_,m.pendingProps);case 24:au(m,ti,h.memoizedState.cache)}return Vl(h,m,_)}function qI(h,m,_){if(h!==null)if(h.memoizedProps!==m.pendingProps)ri=!0;else{if(!$2(h,_)&&(m.flags&128)===0)return ri=!1,Yne(h,m,_);ri=(h.flags&131072)!==0}else ri=!1,Tn&&(m.flags&1048576)!==0&&E4(m,Og,m.index);switch(m.lanes=0,m.tag){case 16:e:{var S=m.pendingProps;if(h=Gd(m.elementType),m.type=h,typeof h=="function")KE(h)?(S=Xd(h,S),m.tag=1,m=HI(null,m,h,S,_)):(m.tag=0,m=L2(null,m,h,S,_));else{if(h!=null){var P=h.$$typeof;if(P===T){m.tag=11,m=OI(null,m,h,S,_);break e}else if(P===N){m.tag=14,m=LI(null,m,h,S,_);break e}}throw m=j(h)||h,Error(r(306,m,""))}}return m;case 0:return L2(h,m,m.type,m.pendingProps,_);case 1:return S=m.type,P=Xd(S,m.pendingProps),HI(h,m,S,P,_);case 3:e:{if(Te(m,m.stateNode.containerInfo),h===null)throw Error(r(387));S=m.pendingProps;var D=m.memoizedState;P=D.element,c2(h,m),$g(m,S,null,_);var X=m.memoizedState;if(S=X.cache,au(m,ti,S),S!==D.cache&&r2(m,[ti],_,!0),Hg(),S=X.element,D.isDehydrated)if(D={element:S,isDehydrated:!1,cache:X.cache},m.updateQueue.baseState=D,m.memoizedState=D,m.flags&256){m=$I(h,m,S,_);break e}else if(S!==P){P=Os(Error(r(424)),m),Lg(P),m=$I(h,m,S,_);break e}else{switch(h=m.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(hr=zs(h.firstChild),qi=m,Tn=!0,ru=null,Fs=!0,_=F4(m,null,S,_),m.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(Bd(),S===P){m=Vl(h,m,_);break e}Xi(h,m,S,_)}m=m.child}return m;case 26:return Gy(h,m),h===null?(_=iF(m.type,null,m.pendingProps,null))?m.memoizedState=_:Tn||(_=m.type,h=m.pendingProps,S=lv(oe.current).createElement(_),S[rn]=m,S[Oe]=h,Yi(S,_,h),Le(S),m.stateNode=S):m.memoizedState=iF(m.type,h.memoizedProps,m.pendingProps,h.memoizedState),null;case 27:return ce(m),h===null&&Tn&&(S=m.stateNode=tF(m.type,m.pendingProps,oe.current),qi=m,Fs=!0,P=hr,bu(m.type)?(wT=P,hr=zs(S.firstChild)):hr=P),Xi(h,m,m.pendingProps.children,_),Gy(h,m),h===null&&(m.flags|=4194304),m.child;case 5:return h===null&&Tn&&((P=S=hr)&&(S=Tre(S,m.type,m.pendingProps,Fs),S!==null?(m.stateNode=S,qi=m,hr=zs(S.firstChild),Fs=!1,P=!0):P=!1),P||iu(m)),ce(m),P=m.type,D=m.pendingProps,X=h!==null?h.memoizedProps:null,S=D.children,xT(P,D)?S=null:X!==null&&xT(P,X)&&(m.flags|=32),m.memoizedState!==null&&(P=g2(h,m,Bne,null,null,_),cx._currentValue=P),Gy(h,m),Xi(h,m,S,_),m.child;case 6:return h===null&&Tn&&((h=_=hr)&&(_=Are(_,m.pendingProps,Fs),_!==null?(m.stateNode=_,qi=m,hr=null,h=!0):h=!1),h||iu(m)),null;case 13:return VI(h,m,_);case 4:return Te(m,m.stateNode.containerInfo),S=m.pendingProps,h===null?m.child=qd(m,null,S,_):Xi(h,m,S,_),m.child;case 11:return OI(h,m,m.type,m.pendingProps,_);case 7:return Xi(h,m,m.pendingProps,_),m.child;case 8:return Xi(h,m,m.pendingProps.children,_),m.child;case 12:return Xi(h,m,m.pendingProps.children,_),m.child;case 10:return S=m.pendingProps,au(m,m.type,S.value),Xi(h,m,S.children,_),m.child;case 9:return P=m.type._context,S=m.pendingProps.children,$d(m),P=Ki(P),S=S(P),m.flags|=1,Xi(h,m,S,_),m.child;case 14:return LI(h,m,m.type,m.pendingProps,_);case 15:return II(h,m,m.type,m.pendingProps,_);case 19:return WI(h,m,_);case 31:return Xne(h,m,_);case 22:return FI(h,m,_,m.pendingProps);case 24:return $d(m),S=Ki(ti),h===null?(P=s2(),P===null&&(P=ar,D=i2(),P.pooledCache=D,D.refCount++,D!==null&&(P.pooledCacheLanes|=_),P=D),m.memoizedState={parent:S,cache:P},l2(m),au(m,ti,P)):((h.lanes&_)!==0&&(c2(h,m),$g(m,null,null,_),Hg()),P=h.memoizedState,D=m.memoizedState,P.parent!==S?(P={parent:S,cache:S},m.memoizedState=P,m.lanes===0&&(m.memoizedState=m.updateQueue.baseState=P),au(m,ti,S)):(S=D.cache,au(m,ti,S),S!==P.cache&&r2(m,[ti],_,!0))),Xi(h,m,m.pendingProps.children,_),m.child;case 29:throw m.pendingProps}throw Error(r(156,m.tag))}function Gl(h){h.flags|=4}function V2(h,m,_,S,P){if((m=(h.mode&32)!==0)&&(m=!1),m){if(h.flags|=16777216,(P&335544128)===P)if(h.stateNode.complete)h.flags|=8192;else if(v5())h.flags|=8192;else throw Wd=ky,o2}else h.flags&=-16777217}function KI(h,m){if(m.type!=="stylesheet"||(m.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!cF(m))if(v5())h.flags|=8192;else throw Wd=ky,o2}function qy(h,m){m!==null&&(h.flags|=4),h.flags&16384&&(m=h.tag!==22?St():536870912,h.lanes|=m,Kf|=m)}function Xg(h,m){if(!Tn)switch(h.tailMode){case"hidden":m=h.tail;for(var _=null;m!==null;)m.alternate!==null&&(_=m),m=m.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var S=null;_!==null;)_.alternate!==null&&(S=_),_=_.sibling;S===null?m||h.tail===null?h.tail=null:h.tail.sibling=null:S.sibling=null}}function fr(h){var m=h.alternate!==null&&h.alternate.child===h.child,_=0,S=0;if(m)for(var P=h.child;P!==null;)_|=P.lanes|P.childLanes,S|=P.subtreeFlags&65011712,S|=P.flags&65011712,P.return=h,P=P.sibling;else for(P=h.child;P!==null;)_|=P.lanes|P.childLanes,S|=P.subtreeFlags,S|=P.flags,P.return=h,P=P.sibling;return h.subtreeFlags|=S,h.childLanes=_,m}function Zne(h,m,_){var S=m.pendingProps;switch(QE(m),m.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return fr(m),null;case 1:return fr(m),null;case 3:return _=m.stateNode,S=null,h!==null&&(S=h.memoizedState.cache),m.memoizedState.cache!==S&&(m.flags|=2048),Bl(ti),me(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(Of(m)?Gl(m):h===null||h.memoizedState.isDehydrated&&(m.flags&256)===0||(m.flags|=1024,e2())),fr(m),null;case 26:var P=m.type,D=m.memoizedState;return h===null?(Gl(m),D!==null?(fr(m),KI(m,D)):(fr(m),V2(m,P,null,S,_))):D?D!==h.memoizedState?(Gl(m),fr(m),KI(m,D)):(fr(m),m.flags&=-16777217):(h=h.memoizedProps,h!==S&&Gl(m),fr(m),V2(m,P,h,S,_)),null;case 27:if(Ce(m),_=oe.current,P=m.type,h!==null&&m.stateNode!=null)h.memoizedProps!==S&&Gl(m);else{if(!S){if(m.stateNode===null)throw Error(r(166));return fr(m),null}h=te.current,Of(m)?A4(m):(h=tF(P,S,_),m.stateNode=h,Gl(m))}return fr(m),null;case 5:if(Ce(m),P=m.type,h!==null&&m.stateNode!=null)h.memoizedProps!==S&&Gl(m);else{if(!S){if(m.stateNode===null)throw Error(r(166));return fr(m),null}if(D=te.current,Of(m))A4(m);else{var X=lv(oe.current);switch(D){case 1:D=X.createElementNS("http://www.w3.org/2000/svg",P);break;case 2:D=X.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;default:switch(P){case"svg":D=X.createElementNS("http://www.w3.org/2000/svg",P);break;case"math":D=X.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;case"script":D=X.createElement("div"),D.innerHTML=" - + +